2026 10 12 xp pro - xopherdeep/do-it-for-the-xp GitHub Wiki
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.
- Plans, exactly:
monthly$4.99/month,yearly$39.99/year, both with a 7-day trial;lifetime$79.00 one-time, sold only while optionxp_pro_lifetime_on_saleis true. Store product idsxp_pro_monthly,xp_pro_yearly,xp_pro_lifetime. RevenueCat entitlementpro. - 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 fromABCDEFGHJKLMNPQRSTUVWXYZ23456789withrandom_int. One record per family in optiongk_xp_pro_{family_id}(autoload off); lookupsgk_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_atnull or now <expires_at+ 3 days). Max 10 devices per license. Remember the last 50 event ids per license. -
family_idis a lowercase UUID v4;device_idis 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_idis one ofgatekeeper_xp_pro_webhooks({stripe: id, revenuecat: id}) andHookshot_Signature::was_verified( $webhook_id )is true. - Sandbox licenses (
env === 'sandbox') are not Pro in production app builds, and the AI proxy refuses them unlesswp_get_environment_type()islocalordevelopment. - AI proxy: tasks
monster_name,checklist,full_beast,scheduleonly; prompts live in PHP; no free-form prompt; 100 calls per license per day (optionxp_pro_ai_daily_limit); key fromXophz_Compass_HTTP::get_gemini_api_key(). - App offline rules: recheck when the last check is older than 24 h or
expiresAthas passed; Pro stays on offline for 30 days sincelastValidatedAt. - COMPASS code (all PHP and the COMPASS Vue UI): no em dash characters (
—) anywhere, and no@example.com,@gmail.com,@test.comor555-values in fixtures (the harness'sassertNoEmDashes/assertNoSyntheticDatareject them). Fixture emails use@doit.forthexp.com. - COMPASS workflow (from
Xophz-COMPASS/CLAUDE.md): claim a chemx task and lock files before editing; commit onmaininside 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 tomainbuilds a release thatdoit.forthexp.cominstalls. - App workflow (from
do-it-for-the-xp/CLAUDE.md):chemx verifybefore every commit;chemx test <spec>for single specs; commit onmaster; no push unless the owner asks. Pages aredefineComponentwith sibling.tsand.scss; new components use<script setup lang="ts">; sounds viaplay$fx(key); toasts usecssClass: "rpg-toast", positiontop. - Prerequisite: background task "Close Gatekeeper license-validation bypasses" (removes the
CX-fallback, the hardcoded bypass keys and theuser_id = 1fallback) lands inxophz-compass-gatekeeperbefore Task 3. Pull it first; do not redo it.
- A subscription is refunded (
charge.refundedwhose charge has acustomerbut no storedpayment_intent) → the family's Pro is revoked. Test in Task 4. - A family holds the lifetime plan and a subscription; the subscription later ends → Pro stays on (lifetime wins). Test in Task 3.
- The device clock is set backwards so
lastValidatedAtis in the future → treat as needing a recheck, never as fresh. Test in Task 10. - 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. - 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.
The live site serves 219 .js.map files, which hand out the full private source.
Files:
- Modify:
vue.config.js(addproductionSourceMap: falseat the top level, besidepublicPathat 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 (undefinedis notfalse). - Step 3: Add
productionSourceMap: falsetovue.config.js. - Step 4: Run it again → PASS. Then
pnpm buildand confirmfind dist -name '*.map' | wc -lprints0. - Step 5: Commit (
chemx verifyfirst):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.
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.
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'tosave_webhook_meta()at line 461, andhookshot_verify_modeto the field list at line 402; save onlyhmacorbearer, anything else saveshmac) - Modify:
src/routes/hookshot/views/hookshot-detail.vue(a "Verify with" select,HMAC signature/Authorization: Bearer, beside thesig_headerfield at line 73; addverify_mode: "hmac"to the form defaults at line 420 and the load mapping at line 457) and the matching type insrc/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(hmacdefault |bearer). Inbearermodeverify()returnstrueand marks the webhook verified (sowas_verified()is true) only when theAuthorizationheader equals"Bearer " . $signing_secretunderhash_equals; a missing secret, missing header or mismatch returnsWP_Errorstatus 401.hmacmode is unchanged. -
Step 1: Write the failing tests in
verify-mode.test.mjs. Each snippet stubsget_post_metafrom a PHP array and a minimalWP_REST_Request(get_header($name),get_body()), requiresclass-hookshot-signature.php, and echoes JSON. Cases:-
bearer accepts the exact header and marks verified→verifyreturns 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 (unlikehmac, which lets an unsigned webhook through). -
hmac stays the default and still checks Stripe signatures→ a body signed withHookshot_Signature::sign()verifies with nohookshot_verify_modemeta. -
files contain no em dashes→assertNoEmDasheson 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
bearerbranch at the top ofverify()(readhookshot_verify_mode; onbearerdo the check above and return), then the REST mapping and the UI select. -
Step 4: Run the Hookshot tests → PASS;
chemx typecheckin 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.
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_onceafter 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 ): arrayreturning['changed' => bool, 'created_key' => bool, 'record' => ?array].$opis 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 */]. Withoutfamily_id,applyresolves the family throughref; unknown →changedfalse. An$event_idalready inevent_ids→changedfalse. 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; definedelete_option). Cases, using a fixed family3f6c2a1e-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_keyreturns the record;created_keytrue). -
a second grant keeps the key(created_keyfalse, 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.
Files:
- Create:
wp-content/plugins/xophz-compass-gatekeeper/includes/class-gatekeeper-xp-pro-stripe.php(+require_once) - Create:
tests/xp-pro-stripe.test.mjsandtests/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
$opshape from Task 3. - Produces:
Gatekeeper_Xp_Pro_Stripe::to_op( array $event ): array(pure) andGatekeeper_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_opthenGatekeeper_Xp_Pro_Licenses::applyon a granted family, thenis_entitledfalse). - Step 2: Run the Gatekeeper tests → the new ones FAIL.
- Step 3: Implement
to_opandevent_id. - Step 4: Run → PASS.
- Step 5: Commit:
feat(xp-pro): Stripe events to license operations.
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 noneandan 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.
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 togatekeeper_xp_pro_webhooks, thexp_pro_ai_daily_limitnumber, and a license list with a per-row "Reset devices" button callingreset_devices;manage_optionsand 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_idto the option, returns early whenclass_exists( 'Hookshot_Signature' )is false orwas_verifiedis false (logging viaerror_logwith the webhook id only), applies the op, and whencreated_keyis true and the record has an email sends onewp_mail(subjectYour XP Pro key; body: the key, the steps "Settings > XP Pro > Restore with key", and the support address fromget_option( 'admin_email' )). -
Step 1: Write the failing tests (stub
Hookshot_Signature::was_verifiedwith a small class in the snippet, stubwp_mailto 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.
Files:
- Create:
wp-content/plugins/xophz-compass-gatekeeper/includes/class-gatekeeper-xp-pro-rest.php(+require_oncebeforeclass-gatekeeper-rest.php) - Modify:
wp-content/plugins/xophz-compass-gatekeeper/includes/class-gatekeeper-rest.php(register_routes(): callGatekeeper_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, allpermission_callbackpublic):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 withpublic_view, 404{pro:false}, 409{error:'device_limit'}, 400{error:'invalid_input'}, 202 for recover, 429{error:'rate_limited'}.managefor a Stripe-sourced license POSTshttps://api.stripe.com/v1/billing_portal/sessions(customer,return_urlchecked byXophz_Compass_Security::is_allowed_redirect_url) withXophz_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(transientgk_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 stubbedwp_remote_postcall; 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/planson the local stack returns[]until Task 8. -
Step 5: Commit:
feat(xp-pro): claim, validate, recover, manage and plans endpoints.
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 intests/run-tests.mjs) - Modify:
wp-content/plugins/xophz-compass-xp/docs/CHANGELOG.md(create if missing)
Interfaces:
-
Produces
Xophz_Compass_Xp_Prowithpublic $filter_hooks = [ 'xophz_resolve_buy_request' => [ 'resolve_buy_request', 10, 3 ], 'gatekeeper_xp_pro_plans' => [ 'provide_plans', 10, 1 ] ];,static get_plans(): array(keysmonthly|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'; returnsWP_Error404 for an unknown or off-sale plan, 400 for a badfamily_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 leavessuccess_url/cancel_urlunset (Bazaar builds them fromreturn_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"→302to 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.
Files:
- Create:
wp-content/plugins/xophz-compass-gatekeeper/includes/class-gatekeeper-xp-pro-ai.php(+require_once; route registered fromGatekeeper_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 shapeGeminiService.tsreturns today forgenerateMonsterName,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.tsinto 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 asXophz_Compass_Gemini_APIreads it, falling back togemini-3.6-flash; key in thex-goog-api-keyheader only. Daily counter transientgk_xppro_ai_{family_id}_{Ymd}, limit(int) get_option( 'xp_pro_ai_daily_limit', 100 ). -
Step 1: Write the failing tests (stub
wp_remote_postto return a canned Geminicandidates[0].content.parts[0].textper task; stubwp_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(sendinput.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.
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; constantsGRACE_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: falseis never Pro; sandbox is Pro in a dev build and not in a production build;needsRechecktrue after 24 h, true onceexpiresAtpassed, true with no license, true whenlastValidatedAtis 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.
Files:
- Create:
src/lib/databases/ProDb.ts(pattern ofFamilySettingsDb.ts: storage name__xp_pro, one record keypro),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, extendtests/unit/apiHost.spec.ts
Interfaces:
-
Consumes:
CachedLicense,ProPlanIdfrom 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 fromuuidv4, 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, elsehttps://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 toProStatusand the status codes of spec 3.4 toProClientErrorcodes.
-
-
Step 1: Write the failing tests: identity is created once and stable across calls;
adoptFamilychanges familyId and keeps deviceId;compassSiteRootgiveshttps://doit.forthexp.comwithout hosting and the host origin with{ root: "https://doit.forthexp.com/wp-json/" };claimreturns null on 404 and a mappedProStatuson 200 (mockglobal.fetch); 409 becomesProClientError("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.
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.tsor the app's existing startup hook (calluseProStore().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, likechat.ts): stateisPro(computed fromisPro(license, new Date(), process.env.NODE_ENV === "production")),plan,expiresAt,licenseKey,loading; actionsinit(): 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 opensXpProUpsellwith the gate and returns false).handleProGuard(to): Promise<boolean | void>returns false and opens the upsell whento.meta.prois a gate and the family isn't Pro. -
XpProUpsellprops{ 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
proClientandProDbaschatStore.spec.tsmocks 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 stubbedwindow.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.
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(/pronamexp-pro,/pro/returnnamexp-pro-return), spread intosrc/router/index.tsbesideSettingsRoutes - Create:
src/app/SideMenu/XpSettings/components/ProSettings.vue; Modify:src/app/SideMenu/XpSettings/routes.ts(pathpro) 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 whenon_sale, "7 days free" on the two subscriptions, "Restore" linking to the panel. -
Return: reads
session_id,purchasedandstatusfromroute.query(vue-router puts the post-hash query there);status=cancelled→ replace to/proquietly; otherwiseclaimWithRetry()with the dialog "The shopkeeper is counting your gold…"; success →rpg-toast"XP Pro unlocked!" androuter.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.interceptthe 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/screenshotsat 390x844. -
Step 5: Commit:
feat(pro): Go Pro, purchase return and the XP Pro settings panel.
Files:
- Modify:
src/router/Admin/temples.routes.ts(meta: { pro: "templeCreator" }onxp-room-editorand thetemples/:templeIdeditor children: settings, layout, rooms, beasts) - Modify: the "New temple" button in
src/app/Admin/XpCompendium/views/XpTemples/(callrequirePro("templeCreator")first) - Modify:
src/app/Admin/XpCompendium/views/XpAchievements/XpConfigAchievement/components/AchievementScheduleTab.vue(when not Pro:customschedule, the times-of-day chips, and times-per-day above 1 show a padlock and callrequirePro("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.vueandAchievementScheduleTab.vue(padlock when not Pro;requirePro("aiHelpers")before the call) - Test:
tests/unit/proGates.spec.ts; extendtests/e2e/specs/pro.spec.js
Interfaces:
-
Consumes:
useProStore().requirePro,isPro; the route meta read byhandleProGuard. -
Step 1: Write the failing tests: unit (mount
AchievementScheduleTabwith 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.
Files:
- Modify:
src/lib/services/ai/GeminiService.ts(keep the four exported function names, parameters and return types; each callsaskAiwith its task and fields;isGeminiConfigured()returnsuseProStore().isPro; deleteGEMINI_API_URLand everyprocess.env.VUE_APP_GEMINI_API_KEYread) - Modify:
.env.example(removeVUE_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" srcprints nothing;pnpm buildthengrep -rlE "AIza[0-9A-Za-z_-]{35}" distprints nothing. -
Step 5: Commit:
fix(ai): call Gemini through the license-checked proxy; no API key in the app.
Files:
- Modify:
package.json(add@revenuecat/purchases-capacitor, the release line that supports Capacitor 6), thennpx cap sync - Create:
src/lib/services/pro/storeBilling.ts - Modify:
src/lib/store/stores/pro.ts(native branches ofpurchase,restoreWithKey, and a newrestoreStorePurchases()),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, extendtests/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)thenclaimWithRetry(); the app never unlocks from the SDK result alone.restoreWithKeyalso callslogIn(familyId)afteradoptFamily. -
Step 1: Write the failing tests (mock
@revenuecat/purchases-capacitorand@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.
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 itswhsec_secret in the local Hookshot webhook (modehmac, headerStripe-Signature); setgatekeeper_xp_pro_webhookslocally. Fromlocalhost:8100buy yearly with card4242 4242 4242 4242→ the Return page unlocks within 30 s. Then with a Stripe test clock: renewal extendsexpires_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 verifyin the app; every plugin'stests/run-tests.mjsgreen. - Step 4: Ask the owner before pushing. Pushing Hookshot, Gatekeeper and XP to
mainreleases them todoit.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).