diff --git a/.env.example b/.env.example index 09b2f89..944931c 100644 --- a/.env.example +++ b/.env.example @@ -1,58 +1,113 @@ # GravityKit MCP Configuration -# Required: Gravity Forms REST API v2 Credentials -# Generate these in your WordPress admin: Forms > Settings > REST API -GRAVITY_FORMS_CONSUMER_KEY=ck_your_consumer_key_here -GRAVITY_FORMS_CONSUMER_SECRET=cs_your_consumer_secret_here +# ============================================================ +# REQUIRED: WordPress credentials +# +# Recommended: a WordPress application password (Users > Profile > +# Application Passwords). KEY = your username, SECRET = the generated +# password. Access follows your WordPress capabilities, and on sites +# running GravityKit Foundation the same credential powers the +# GravityKit product tools too. +# +# Alternative (scoped access, e.g. read-only): a Gravity Forms API +# key from Forms > Settings > REST API (ck_... / cs_...). +# +# Either way, check "Enable access to the API" on Forms > Settings > +# REST API once — Gravity Forms doesn't register its REST routes +# without it. +# ============================================================ +GRAVITY_FORMS_CONSUMER_KEY=your_wp_username +GRAVITY_FORMS_CONSUMER_SECRET="xxxx xxxx xxxx xxxx xxxx xxxx" +# GRAVITY_FORMS_CONSUMER_KEY=ck_your_consumer_key_here +# GRAVITY_FORMS_CONSUMER_SECRET=cs_your_consumer_secret_here # Required: Your WordPress site URL (no trailing slash) GRAVITY_FORMS_BASE_URL=https://yoursite.com -# Example for local development: -# GRAVITY_FORMS_CONSUMER_KEY=ck_3f4d5e6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e -# GRAVITY_FORMS_CONSUMER_SECRET=cs_1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b -# GRAVITY_FORMS_BASE_URL=https://local.gravityforms.test +# Shorthand aliases also supported (used internally by test-config): +# GF_CONSUMER_KEY=ck_... +# GF_CONSUMER_SECRET=cs_... +# GF_URL=https://yoursite.com -# Example for live site: -# GRAVITY_FORMS_CONSUMER_KEY=ck_production_key_from_wordpress_admin -# GRAVITY_FORMS_CONSUMER_SECRET=cs_production_secret_from_wordpress_admin -# GRAVITY_FORMS_BASE_URL=https://www.yourwebsite.com +# ============================================================ +# AUTHENTICATION +# ============================================================ +# The client auto-selects the transport from your credentials: +# app-password creds use Basic (HTTPS or local URLs); ck_/cs_ key +# pairs use Basic on HTTPS and OAuth 1.0a on plain HTTP (Gravity +# Forms only accepts key-pair Basic auth over HTTPS). Set this ONLY +# to override — an explicit value is always honored, everywhere. +# GRAVITY_FORMS_AUTH_METHOD=basic -# Authentication Settings -# Authentication method: 'basic' (recommended) or 'oauth' -GRAVITY_FORMS_AUTH_METHOD=basic - -# Security Settings -# Set to 'true' to enable DELETE operations (forms and entries) +# ============================================================ +# SECURITY +# ============================================================ +# Set to 'true' to enable DELETE operations (forms, entries, feeds) # WARNING: This allows permanent deletion of data GRAVITY_FORMS_ALLOW_DELETE=false -# Optional: Connection Settings -GRAVITY_FORMS_TIMEOUT=30000 -GRAVITY_FORMS_MAX_RETRIES=3 -GRAVITY_FORMS_RETRY_DELAY=1000 - # SSL Certificate Validation (for local development only) # Set to 'true' to allow self-signed SSL certificates (Laravel Valet, MAMP, Local WP, etc.) -# ⚠️ SECURITY WARNING: Only enable for local development, never in production! -# MCP_ALLOW_SELF_SIGNED_CERTS=true +# SECURITY WARNING: Only enable for local development, never in production! +# GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true -# Optional: Debug Settings -# ⚠️ SECURITY WARNING: Debug logs may contain sensitive data (API keys, user info) -# Only enable in secure development environments. Never share debug logs publicly. -# Logs are automatically sanitized but review before sharing with support. +# ============================================================ +# CONNECTION +# ============================================================ +GRAVITY_FORMS_MAX_RETRIES=3 +GRAVITY_FORMS_TIMEOUT=30000 + +# ============================================================ +# DEBUG +# ============================================================ +# SECURITY WARNING: Debug logs may contain sensitive data (API keys, user info). +# Only enable in secure development environments. GRAVITY_FORMS_DEBUG=false -# Optional: Rate Limiting -GRAVITY_FORMS_RATE_LIMIT=100 -GRAVITY_FORMS_RATE_WINDOW=60000 +# ============================================================ +# TEST ENVIRONMENT +# Use a separate test/staging site to avoid affecting production data. +# ============================================================ -# Test Settings (for integration tests) -# Use a separate test site to avoid affecting production data +# Primary test env var names: GRAVITY_FORMS_TEST_BASE_URL=https://test.yoursite.com GRAVITY_FORMS_TEST_CONSUMER_KEY=ck_test_key_here GRAVITY_FORMS_TEST_CONSUMER_SECRET=cs_test_secret_here -# Enable test mode (MCP-specific setting) +# Additional test overrides (remapped to primary vars in test mode): +# GRAVITY_FORMS_TEST_URL=https://test.yoursite.com (alias for TEST_BASE_URL) +# GRAVITY_FORMS_TEST_AUTH_METHOD=basic +# GRAVITY_FORMS_TEST_TIMEOUT=30000 + +# Shorthand aliases also supported: +# TEST_GF_URL=https://test.yoursite.com +# TEST_GF_CONSUMER_KEY=ck_test_key_here +# TEST_GF_CONSUMER_SECRET=cs_test_secret_here + +# WordPress credentials (used by test scripts, not the MCP server itself): +# TEST_WP_USER=admin +# TEST_WP_PASSWORD=password + +# Enable test mode — when true, GRAVITY_FORMS_TEST_* vars are remapped +# to their primary equivalents so the client connects to the test site. # GRAVITYKIT_MCP_TEST_MODE=true -# Legacy name also supported: GRAVITYMCP_TEST_MODE=true \ No newline at end of file +# Legacy name also supported: GRAVITYMCP_TEST_MODE=true +# Also activated when NODE_ENV=test +# ============================================= +# GravityKit Abilities (gv_* and other product tools) +# ============================================= +# WordPress credentials for the abilities transport (Foundation catalog +# at /wp-json/gravitykit/v1 + WP core /wp-json/wp-abilities/v1). +# Optional — falls back to GRAVITY_FORMS_BASE_URL and the +# GRAVITY_FORMS_CONSUMER_KEY/SECRET pair when unset. +# GRAVITYKIT_WP_URL=https://your-site.com +# GRAVITYKIT_WP_USERNAME=admin +# GRAVITYKIT_WP_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx" +# GRAVITYKIT_TIMEOUT=30000 + +# Security-coverage fixtures for the integration suite (all optional — +# the deny tests skip cleanly when unset): +# GRAVITY_FORMS_TEST_LOWPRIV_USER=subscriber_login +# GRAVITY_FORMS_TEST_LOWPRIV_APP_PASSWORD="app password for a user WITHOUT GF capabilities" +# GRAVITY_FORMS_TEST_READONLY_CONSUMER_KEY=ck_key_with_read_permissions +# GRAVITY_FORMS_TEST_READONLY_CONSUMER_SECRET=cs_matching_secret diff --git a/.gitignore b/.gitignore index 65847b5..a700106 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,9 @@ test-results/ test-artifacts/ tmp/ +# Review reports (codex / adversarial output) +reports/ + # MCP specific mcp-server.log debug.log diff --git a/.mcp.json b/.mcp.json index 2c7ef81..ad04d07 100644 --- a/.mcp.json +++ b/.mcp.json @@ -151,13 +151,13 @@ "optional": [ { "name": "GRAVITY_FORMS_AUTH_METHOD", - "description": "Authentication method: 'basic' (recommended) or 'oauth'", + "description": "Authentication method: 'basic' (recommended) or 'oauth'/'oauth1'. Basic requires HTTPS; falls back to OAuth on HTTP.", "type": "string", "default": "basic" }, { "name": "GRAVITY_FORMS_ALLOW_DELETE", - "description": "Set to 'true' to enable DELETE operations (forms and entries)", + "description": "Set to 'true' to enable DELETE operations (forms, entries, feeds)", "type": "boolean", "default": false }, @@ -175,7 +175,13 @@ }, { "name": "GRAVITY_FORMS_DEBUG", - "description": "Enable debug logging", + "description": "Enable debug logging (output goes to stderr)", + "type": "boolean", + "default": false + }, + { + "name": "GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS", + "description": "Allow self-signed SSL certificates (local development only, never in production)", "type": "boolean", "default": false } @@ -187,7 +193,7 @@ "repository": "https://github.com/GravityKit/MCP", "documentation": "https://github.com/GravityKit/MCP#readme", "api_coverage": "100%", - "total_tools": 24, + "total_tools": 26, "authentication_methods": ["OAuth 1.0a", "Basic Authentication"], "features": [ "Complete REST API v2 coverage", diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 57e9ce5..0000000 --- a/.npmignore +++ /dev/null @@ -1,37 +0,0 @@ -# Source control -.git/ -.gitignore - -# Testing -src/tests/ -test-results/ -test-artifacts/ -coverage/ -*.test.js - -# Development -.env* -scripts/setup-test-data.js -tmp/ - -# IDE and editor files -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# Logs -*.log -logs/ - -# MacOS -.DS_Store - -# Documentation (keep only essential docs) -COMPREHENSIVE_PLAN.md -AGENTS.md -CLAUDE.md - -# Development dependencies -node_modules/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index c203abd..e7856ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,13 +1,27 @@ # AGENTS.md — GravityKit MCP -> MCP server providing 28 tools for full Gravity Forms REST API v2 coverage, enabling AI agents to manage forms, entries, feeds, notifications, and fields programmatically. +> MCP server for Gravity Forms (primary) and GravityKit products (secondary). It exposes 26 always-on Gravity Forms tools plus a dynamic set of GravityKit product tools generated from the connected site's Foundation Abilities catalog — GravityView is the only GravityKit product implemented so far. + +This is the single canonical doc for the project (agents and humans). `CLAUDE.md` simply re-exports it via `@AGENTS.md`. + +## Project Identity + +- **Package:** `@gravitykit/mcp` v2.1.0 +- **Type:** Node.js MCP server (ESM) +- **Purpose:** Full Gravity Forms REST API v2 coverage (26 Gravity Forms tools), plus dynamic GravityKit product tools (GravityView so far) via the WordPress Abilities API +- **Repo:** https://github.com/GravityKit/MCP ## Quick Start -**What this is:** A Node.js MCP (Model Context Protocol) server that wraps the Gravity Forms REST API v2. It authenticates via Basic Auth (preferred) or OAuth 1.0a and exposes 28 tools for CRUD operations on forms, entries, feeds, notifications, field filters, results, and intelligent field management. +**What this is:** A Node.js MCP (Model Context Protocol) server with two independent capability planes: + +- **Plane A — Gravity Forms (`gf_*`), primary.** 26 static tools wrapping the Gravity Forms REST API v2 (forms, entries, feeds, notifications, submissions, field filters, results, and intelligent field management). Always available when Gravity Forms REST credentials work — on any Gravity Forms site. +- **Plane B — GravityKit, secondary.** Tools generated at runtime from the connected site's GravityKit Foundation Abilities catalog; each product registers tools under its own server-owned prefix. They appear only when Foundation is active. GravityView is the only product wired up so far, using the `gv_*` prefix (View authoring, fields, widgets, search, layouts). The plane is product-agnostic: any GravityKit product that registers Foundation abilities shows up automatically under its own prefix. + +The two planes are independent: a GF-only site gets the full `gf_*` surface with no abilities; a GravityKit site without GF REST keys still gets its GravityKit tools. **Main entry point:** `src/index.js` -**Architecture style:** MCP SDK server with stdio transport, single API client, composable validation +**Architecture style:** MCP SDK server with stdio transport, one HTTP client per plane, composable validation **Key dependency:** `@modelcontextprotocol/sdk` ^1.0.0 ## Repository Map @@ -17,17 +31,26 @@ MCP/ ├── package.json # @gravitykit/mcp, ESM, npm scripts ├── mcp.json # MCP manifest (tool catalog, auth config) ├── .env.example # All env vars documented -├── CLAUDE.md # Project docs for AI context +├── AGENTS.md # Canonical agent + developer docs (this file) +├── CLAUDE.md # Claude Code entry point — re-exports AGENTS.md (@AGENTS.md) ├── src/ -│ ├── index.js # Server bootstrap, tool registration, handler routing -│ ├── gravity-forms-client.js # GravityFormsClient: HTTP client, all API methods -│ ├── field-operations/ # Intelligent field management layer +│ ├── index.js # Server bootstrap, two-plane init, tool registration, handler routing +│ ├── gravity-forms-client.js # GravityFormsClient: GF REST HTTP client, all gf_* API methods +│ ├── wp-client.js # WordPressClient: product-agnostic authenticated WP transport (Plane B) +│ ├── version.js # VERSION + USER_AGENT, single-sourced from package.json +│ ├── server-runtime.js # Pure helpers: runPlaneInit, buildToolList, classifyAbilityCall +│ ├── abilities/ +│ │ └── loader.js # loadAbilitiesAsTools() — turns the live Abilities catalog into product tools (GravityView → gv_*) +│ ├── gravityview/ # GravityView test/demo harness (NOT runtime — gv_* come from abilities/) +│ │ ├── inspector-client.js # Client for /wp-json/gravityview/v1 (only when DOING_GRAVITYVIEW_TESTS) +│ │ └── view-validator.js # Client-side structural + schema-aware validation for the inspector +│ ├── field-operations/ # Intelligent field management layer (gf_* field tools) │ │ ├── index.js # Factory, tool definitions, handler functions │ │ ├── field-manager.js # FieldManager: CRUD orchestrator │ │ ├── field-dependencies.js # DependencyTracker: conditional logic/merge tag scanning │ │ └── field-positioner.js # PositionEngine: page-aware field positioning │ ├── field-definitions/ -│ │ ├── field-registry.js # 44 field types with metadata, validation, storage patterns +│ │ ├── field-registry.js # 45 field types with metadata, validation, storage patterns │ │ └── loader.js # Registry loader │ ├── config/ │ │ ├── auth.js # BasicAuthHandler, OAuth1Handler, AuthManager @@ -38,32 +61,24 @@ MCP/ │ │ ├── validators.js # Domain-specific validators (forms, entries, feeds, etc.) │ │ ├── field-validation.js # FieldAwareValidator for field-specific rules │ │ └── test-config.js # Dual test/live environment config, TestFormManager -│ ├── utils/ -│ │ ├── compact.js # stripEmpty() — recursive null/empty/false stripping for token optimization -│ │ ├── logger.js # MCP-safe logger (stderr in MCP mode, console in test) -│ │ └── sanitize.js # Credential masking for safe logging -│ └── tests/ -│ ├── run.js # Test runner -│ ├── helpers.js # Mock data generators, test utilities -│ ├── integration.test.js # Live API integration tests -│ ├── server-tools.test.js # Tool registration validation -│ ├── forms.test.js # Forms endpoint tests -│ ├── entries.test.js # Entries endpoint tests -│ ├── feeds.test.js # Feeds endpoint tests -│ ├── submissions.test.js # Submission pipeline tests -│ ├── authentication.test.js # Auth method tests -│ ├── validation.test.js # Input validation tests -│ ├── field-validation.test.js # Field-specific validation -│ ├── field-manager.test.js # FieldManager unit tests -│ ├── field-dependencies.test.js # DependencyTracker tests -│ ├── field-positioner.test.js # PositionEngine tests -│ ├── field-registry.test.js # Field registry tests -│ ├── field-operations-e2e.test.js # Field operations E2E -│ ├── field-operations-integration.test.js # Field ops integration -│ ├── compact.test.js # stripEmpty compact utility tests -│ └── sanitize.test.js # Sanitization tests +│ └── utils/ +│ ├── compact.js # stripEmpty() — recursive null/empty/false stripping for token optimization +│ ├── logger.js # MCP-safe logger (stderr in MCP mode, console in test) +│ └── sanitize.js # Credential masking for safe logging +├── test/ # Test suites — top-level, NOT published (see Packaging) +│ ├── run.js # Custom test runner (npm run test:unit) +│ ├── helpers.js # Mock data generators, test utilities +│ ├── integration.test.js # Live API integration tests (npm test) +│ ├── views.test.js, views-stress.test.js # GravityView inspector + abilities coverage +│ ├── abilities-loader.test.js # Abilities catalog → gv_* tool generation +│ └── *.test.js # forms, entries, feeds, fields, validation, submissions, compact, sanitize, … ├── scripts/ │ ├── check-env.js # Environment validation script +│ ├── check-docs.mjs # Doc-freshness guard for AGENTS.md (offline; npm run lint:docs) +│ ├── verify-tool-names.mjs # Cross-check doc/instruction tool names vs registered tools (needs live site) +│ ├── lib/ +│ │ └── ability-catalog.mjs # collectAbilityNames() — paginated Abilities catalog reader +│ ├── stress-abilities.mjs # Synthetic abilities-loader stress/contract test │ ├── setup-test-data.js # Test data seeding │ ├── test-field-ops.js # Field operations smoke test │ ├── test-server-output.js # Server output verification @@ -76,43 +91,50 @@ MCP/ ## Architecture +### Two capability planes + +The server registers tools from two independent sources, initialized separately so a failure in one never blocks the other: + +- **Plane A — Gravity Forms (`gf_*`).** Static tool definitions in `src/index.js` (`GF_TOOL_DEFINITIONS`) plus the field tools from `src/field-operations/index.js` (`fieldOperationTools`). Backed by `GravityFormsClient` against the GF REST API v2. 26 tools, always present once GF credentials validate. +- **Plane B — GravityKit.** Generated at runtime by `src/abilities/loader.js` from the connected site's Abilities catalog, backed by `WordPressClient`; each product's tools carry its own prefix (GravityView → `gv_*`). The catalog is fetched in the background after startup; tools appear once it loads (the server advertises `tools.listChanged`). A single built-in tool, `gk_reload_abilities`, forces a re-fetch. + ### Initialization Flow -1. `src/index.js` loads env vars via dotenv (CWD first, then project dir) — `:30-32` -2. Creates MCP `Server` instance with `tools` capability — `:35-45` -3. `initializeClient()` constructs `GravityFormsClient` with `process.env` — `:57` -4. Client creates `AuthManager` which selects Basic or OAuth handler — `config/auth.js:223-268` -5. Client creates axios instance with auth interceptor — `gravity-forms-client.js:22-85` -6. `validateRestApiAccess()` tests Forms/Entries/Feeds endpoints — `config/auth.js:301-368` -7. Field operations initialized: `FieldManager`, `DependencyTracker`, `PositionEngine` — `:64-70` -8. Server connects to `StdioServerTransport` — `:641-644` +1. `src/index.js` loads env vars via dotenv (CWD first, then project dir). +2. Creates the MCP `Server` with `tools.listChanged` capability and the two-plane server instructions. +3. **Plane A:** `initializeClient()` constructs `GravityFormsClient` from `process.env`; its `AuthManager` selects Basic or OAuth; `validateRestApiAccess()` probes Forms/Entries/Feeds; field operations (`FieldManager`, `DependencyTracker`, `PositionEngine`) are wired up. +4. **Plane B:** constructs `WordPressClient` and kicks off a fire-and-forget abilities catalog fetch (`loadAbilitiesAsTools`). Startup never blocks on it; failures self-heal on the next `gv_*` call (after a cooldown) or via `gk_reload_abilities`. +5. Server connects to `StdioServerTransport`. ### Core Concepts -**GravityFormsClient** (`gravity-forms-client.js`): Single class wrapping all API endpoints. Each method uses `validateAndCall(toolName, input, apiCall)` pattern — validates input via `ValidationFactory`, then executes the HTTP call. Update operations (forms, entries, feeds) fetch-then-merge to preserve existing data. Responses return minimal payloads — just the essential data without redundant metadata. +**GravityFormsClient** (`gravity-forms-client.js`): Single class wrapping all GF API endpoints. Each method uses the `validateAndCall(toolName, input, apiCall)` pattern — validates input via `ValidationFactory`, then executes the HTTP call. Update operations (forms, entries, feeds) fetch-then-merge to preserve existing data. Returns minimal payloads. + +**WordPressClient** (`wp-client.js`): Product-agnostic authenticated WordPress transport for Plane B. The abilities loader rides it to reach the Foundation catalog (`/wp-json/gravitykit/v1/...`) and the WP core Abilities API (`/wp-json/wp-abilities/v1/...`). Auth is a WordPress Application Password via HTTP Basic; when `GRAVITYKIT_WP_*` creds aren't set it falls back to `GRAVITY_FORMS_CONSUMER_KEY`/`SECRET` (commonly the same WP user + app password). + +**Abilities loader** (`abilities/loader.js`): `loadAbilitiesAsTools(wpClient)` builds the GravityKit product tool definitions + handlers from the live catalog (GravityView's carry the `gv_*` prefix). Source-preference chain: (1) Foundation catalog `/wp-json/gravitykit/v1/abilities` (server-filtered to GravityKit, server-owned tool names), (2) WP core catalog `/wp-json/wp-abilities/v1/abilities` (filtered client-side on Foundation's stamped `meta.gk_registered_by`), (3) throw if neither is reachable (caller leaves `gv_*` unregistered and retries — self-healing). **Tool names are owned by the server** via each ability's `mcp_tool_name` (from the product's `mcp_prefix`, or the full product slug); abilities without one are skipped with a warning rather than client-invented. Handlers execute abilities at `/wp-abilities/v1/abilities/{name}/run` with the HTTP method derived from annotations (`readonly` → GET, `destructive`+`idempotent` → DELETE, else POST). -**AuthManager** (`config/auth.js`): Selects between `BasicAuthHandler` (primary, requires HTTPS) and `OAuth1Handler` (fallback). Auto-falls-back to OAuth if HTTPS isn't available. Auth headers injected via axios request interceptor. +**GravityView harness** (`gravityview/inspector-client.js`, `view-validator.js`): The Inspector client and validator target `/wp-json/gravityview/v1` routes that exist only when `DOING_GRAVITYVIEW_TESTS` is defined server-side. They are the integration-test and demo harness — **not** a runtime dependency. Runtime `gv_*` tools come from the abilities loader. + +**AuthManager** (`config/auth.js`): Credential-aware selection between `BasicAuthHandler` and `OAuth1Handler` — app-password creds get Basic (HTTPS or local URLs); `ck_`/`cs_` key pairs get Basic on HTTPS, OAuth on plain HTTP (matching the GF server's `is_ssl()` gate for key Basic auth). Explicit `GRAVITY_FORMS_AUTH_METHOD` overrides. **ValidationFactory** (`config/validation.js`): Central validation dispatcher. `validateToolInput(toolName, input)` routes to domain-specific validators. Composable rule chains (`validation-chain.js`) for reusable validation logic. -**FieldManager** (`field-operations/field-manager.js`): Handles field CRUD within REST API v2 constraints (fields are properties of form objects, not separate endpoints). Generates integer IDs via max+1 pattern, creates compound sub-inputs for address/name/creditcard fields. +**FieldManager** (`field-operations/field-manager.js`): Handles field CRUD within REST API v2 constraints (fields are properties of form objects, not separate endpoints). Generates integer IDs via max+1, creates compound sub-inputs for address/name/creditcard fields. -**Field Registry** (`field-definitions/field-registry.js`): Metadata for all 44 Gravity Forms field types including categories, storage patterns (simple/compound/special), validation rules, variants, and capability flags. +**Field Registry** (`field-definitions/field-registry.js`): Metadata for all 45 Gravity Forms field types — categories, storage patterns (simple/compound/special), validation rules, variants, and capability flags. ### Data Flow ``` MCP Client → stdio → Server.CallToolRequestSchema handler - → switch(name) routes to handler - → wrapHandler() wraps execution: - → GravityFormsClient.method(params) - → validateAndCall(toolName, input, apiCall) - → ValidationFactory.validateToolInput() → validated input - → apiCall(validatedInput) → axios HTTP request - → auth interceptor adds headers - → response interceptor handles errors - → minimal result object (no redundant fields) - → JSON.stringify(result) → compact MCP content block (no pretty-print) + → switch(name): + gf_* / field tools → wrapHandler(GravityFormsClient.method) + → validateAndCall → ValidationFactory → axios (auth interceptor) + → minimal result + gv_* → ability handler → WordPressClient → /wp-abilities/v1/.../run + gk_reload_abilities → force catalog re-fetch + → JSON.stringify(result) → compact MCP content block (no pretty-print) ← { content: [{ type: "text", text: "..." }] } ``` @@ -120,23 +142,27 @@ MCP Client → stdio → Server.CallToolRequestSchema handler Responses are optimized for minimal token usage: -- **Compact JSON**: `JSON.stringify(result)` — no pretty-printing (no `null, 2`) — `src/index.js:114` -- **Minimal payloads**: No redundant `message`, `created`/`updated` booleans, or echo-back of input IDs. GET methods return `{ resource: data }`, mutations return only what can't be inferred (e.g., delete returns `{ deleted: true, id, permanently }`) -- **Summary/detail modes**: `gf_list_field_types` defaults to summary mode (`type`, `label`, `category` only). Pass `detail=true` for full metadata (supports, storage, validation, icon). Pass `include_variants=true` with `detail=true` for variant data. -- **Compact mode (default on)**: `stripEmpty()` (`utils/compact.js`) recursively removes `null` and `""` values from all responses via `wrapHandler()`. `false` is preserved (semantic meaning). Entry tools also strip plugin-added meta keys (e.g., `gv_revision_*`, `helpscout_conversation_id`) via `stripEntryMeta()`, keeping only core properties and numbered field values. Pass `compact=false` for full raw data. -- **Concise tool descriptions**: All 28 tool descriptions and property descriptions are terse to reduce tool-list overhead +- **Compact JSON**: `JSON.stringify(result)` — no pretty-printing (no `null, 2`). +- **Minimal payloads**: No redundant `message`, `created`/`updated` booleans, or echo-back of input IDs. GET methods return `{ resource: data }`; mutations return only what can't be inferred (e.g., delete returns `{ deleted: true, id, permanently }`). +- **Summary/detail modes**: `gf_list_field_types` defaults to summary mode (`type`, `label`, `category`). Pass `detail=true` for full metadata; add `include_variants=true` for variant data. +- **Compact mode (default on)**: `stripEmpty()` (`utils/compact.js`) recursively removes `null` and `""` from all responses. `false` is preserved (semantic meaning). Entry tools also strip plugin-added meta keys (e.g., `gv_revision_*`, `helpscout_conversation_id`) via `stripEntryMeta()`, keeping only core properties and numbered field values. Pass `compact=false` for full raw data. +- **Terse descriptions**: All tool and property descriptions are kept terse to reduce `tools/list` overhead. ### Tool Categories +**Plane A — Gravity Forms (`gf_*`), 26 static tools:** + | Category | Tools | Client Methods | |----------|-------|----------------| | Forms | `gf_list_forms`, `gf_get_form`, `gf_create_form`, `gf_update_form`, `gf_delete_form`, `gf_validate_form` | `listForms`, `getForm`, `createForm`, `updateForm`, `deleteForm`, `validateForm` | | Entries | `gf_list_entries`, `gf_get_entry`, `gf_create_entry`, `gf_update_entry`, `gf_delete_entry` | `listEntries`, `getEntry`, `createEntry`, `updateEntry`, `deleteEntry` | | Submissions | `gf_submit_form_data`, `gf_validate_submission` | `submitFormData`, `validateSubmission` | | Notifications | `gf_send_notifications` | `sendNotifications` | -| Feeds | `gf_list_feeds`, `gf_get_feed`, `gf_list_form_feeds`, `gf_create_feed`, `gf_update_feed`, `gf_patch_feed`, `gf_delete_feed` | `listFeeds`, `getFeed`, `listFormFeeds`, `createFeed`, `updateFeed`, `patchFeed`, `deleteFeed` | +| Feeds | `gf_list_feeds`, `gf_get_feed`, `gf_create_feed`, `gf_update_feed`, `gf_patch_feed`, `gf_delete_feed` | `listFeeds`, `getFeed`, `createFeed`, `updateFeed`, `patchFeed`, `deleteFeed` | | Utilities | `gf_get_field_filters`, `gf_get_results` | `getFieldFilters`, `getResults` | -| Field Ops | `gf_add_field`, `gf_update_field`, `gf_delete_field`, `gf_list_field_types` | Handled via `fieldOperationHandlers` → `FieldManager` | +| Field Ops | `gf_add_field`, `gf_update_field`, `gf_delete_field`, `gf_list_field_types` | via `fieldOperationHandlers` → `FieldManager` | + +**Plane B — GravityKit, dynamic.** Generated from the catalog, so the exact set depends on the connected site's GravityKit products and versions — each product under its own prefix; discover at runtime, don't hard-code. GravityView (prefix `gv_*`) currently contributes tool families for View lifecycle (`gv_view_create`, `gv_view_config_apply`, `gv_view_delete`, …), fields (`gv_view_field_add`/`patch`/`move`/`remove`), grid rows, widgets, search fields, and discovery/schema (`gv_layouts_list`, `gv_widgets_list`, `gv_field_type_schema_get`, `gv_available_fields_get`, …). Plus the built-in `gk_reload_abilities`. Use the `gv_*_list` discovery tools and `gv_field_type_schema_get` to introspect what's available; the server `instructions` string documents the GravityView authoring flow. To re-verify that prose tool names still match the live catalog, run `npm run verify:tool-names` (see Releasing). ### Response Shapes @@ -147,7 +173,7 @@ GET/list methods return just the data: { entries: responseData, total_count } // gf_list_entries { entry: responseData } // gf_get_entry { feed: responseData } // gf_get_feed, gf_create_feed, gf_update_feed, gf_patch_feed -{ feeds: responseData } // gf_list_feeds, gf_list_form_feeds +{ feeds: responseData } // gf_list_feeds (pass form_id to scope to one form) ``` Mutation methods return minimal confirmation: @@ -164,21 +190,21 @@ Mutation methods return minimal confirmation: ### File & Class Naming - Files: `kebab-case.js` (e.g., `field-manager.js`, `gravity-forms-client.js`) -- Classes: `PascalCase` (e.g., `GravityFormsClient`, `FieldManager`, `AuthManager`) -- Exports: Named exports for classes, default export for primary class per file -- Test files: `{module-name}.test.js` alongside or in `tests/` directory +- Classes: `PascalCase` (e.g., `GravityFormsClient`, `FieldManager`, `WordPressClient`) +- Exports: Named exports for classes, default export for the primary class per file +- Test files: `{module-name}.test.js` in the top-level `test/` directory ### Module System - ESM throughout (`"type": "module"` in package.json) - All imports use `.js` extension (required for ESM) -- `__dirname` shimmed via `fileURLToPath(import.meta.url)` in `src/index.js:25-26` +- `__dirname` shimmed via `fileURLToPath(import.meta.url)` where needed ### Error Handling Pattern -All tool handlers use `wrapHandler()` (`src/index.js:99-125`): +All tool handlers use `wrapHandler()` in `src/index.js`: - Checks client initialization -- Wraps result in MCP content blocks `{ content: [{ type: "text", text: JSON.stringify(result) }] }` +- Wraps the result in MCP content blocks `{ content: [{ type: "text", text: JSON.stringify(result) }] }` - Catches errors → `createErrorResponse()` with sanitized details - Error details pass through `sanitize()` to mask credentials @@ -203,17 +229,17 @@ await this.httpClient.put(`/resource/${id}`, merged); ### Delete Safety -All delete operations (`deleteForm`, `deleteEntry`, `deleteFeed`) check `this.allowDelete` first, controlled by `GRAVITY_FORMS_ALLOW_DELETE=true` env var. Without it, deletes throw immediately. +All GF delete operations (`deleteForm`, `deleteEntry`, `deleteFeed`) check `this.allowDelete` first, controlled by `GRAVITY_FORMS_ALLOW_DELETE=true`. Without it, deletes throw immediately. ### Logging -`utils/logger.js` routes all logs to stderr in MCP mode (keeps stdout clean for JSON-RPC). In test mode, uses console.log. Sensitive data masked via `utils/sanitize.js`. +`utils/logger.js` routes all logs to stderr in MCP mode (keeps stdout clean for JSON-RPC). In test mode it uses console.log. Sensitive data is masked via `utils/sanitize.js`. Never use `console.log` in server code. ## Extension Patterns -### Adding a New Tool +### Adding a New Gravity Forms Tool -1. **Define the tool schema** in `src/index.js` inside the `ListToolsRequestSchema` handler (`:131-519`). Add to the tools array with concise descriptions: +1. **Define the tool schema** in `src/index.js` (in `GF_TOOL_DEFINITIONS`, surfaced by the `ListToolsRequestSchema` handler) with a concise description: ```javascript { name: 'gf_new_tool', @@ -221,31 +247,14 @@ All delete operations (`deleteForm`, `deleteEntry`, `deleteFeed`) check `this.al inputSchema: { type: 'object', properties: {...}, required: [...] } } ``` +2. **Add the client method** in `gravity-forms-client.js` using `validateAndCall`, returning minimal data. +3. **Add validation** in `config/validation.js` inside `ValidationFactory.validateToolInput()`. +4. **Add the handler route** in the `CallToolRequestSchema` switch in `src/index.js`. +5. **Write the failing test first** (TDD — see Test-Driven Development above), then implement steps 1–4 to make it pass. Tests live in `test/`, importing the source under test as `../src/…` (see `forms.test.js`). -2. **Add the client method** in `gravity-forms-client.js` using `validateAndCall`. Return minimal data: - ```javascript - async newToolMethod(params) { - return this.validateAndCall('gf_new_tool', params, async (validated) => { - const response = await this.httpClient.get('/endpoint'); - return { data: response.data }; // No message, no echo-back IDs - }); - } - ``` - -3. **Add validation** in `config/validation.js` inside `ValidationFactory.validateToolInput()` (`:463-628`): - ```javascript - case 'gf_new_tool': - BaseValidator.validateRequired(input, ['required_field']); - return { required_field: BaseValidator.validateId(input.required_field) }; - ``` +### Adding GravityKit Product Tools -4. **Add the handler route** in `src/index.js` inside the `CallToolRequestSchema` handler switch (`:537-628`): - ```javascript - case 'gf_new_tool': - return wrapHandler(() => gravityFormsClient.newToolMethod(params))(); - ``` - -5. **Add tests** — create test in `src/tests/` following existing patterns (see `forms.test.js` for reference). +GravityKit product tools (e.g. GravityView's `gv_*`) are **not** defined in this repo — they come from the connected site's Foundation Abilities catalog. To add or change them, register/modify abilities in the relevant GravityKit product (the server stamps each ability's `mcp_tool_name`); the loader picks them up automatically. After a catalog change, run `gk_reload_abilities` (live) or `npm run verify:tool-names` to confirm names. ### Adding a New Field Type to the Registry @@ -262,8 +271,7 @@ newfield: { variants: { default: { label: 'Default', settings: {} } } } ``` - -For compound fields (multi-input like address/name), set `storage.type: 'compound'` and add sub-input generation logic in `field-manager.js:generateSubInputs()` (`:206-267`). +For compound fields (multi-input like address/name), set `storage.type: 'compound'` and add sub-input generation logic in `field-manager.js` (`generateSubInputs()`). ### Adding a New Validation Rule @@ -271,6 +279,18 @@ For compound fields (multi-input like address/name), set `storage.type: 'compoun 2. Add the chainable method in `config/validation-chain.js` 3. Use it in validators via `validate('fieldName').newRule()` +## Test-Driven Development (required) + +All development here is **test-first** — features, bug fixes, refactors, behavior changes. The cycle is non-negotiable: + +1. **RED** — write one failing test that pins the intended behavior, and run it to watch it fail *for the right reason*. No production code before this. +2. **GREEN** — write the minimal code to make it pass; keep the rest of the suite green. +3. **REFACTOR** — clean up with the tests staying green. + +A test that passes the first time you run it proves nothing — if you can't point to the RED run, it isn't TDD. Extract logic into a testable unit instead of burying it inline where it can't be exercised (e.g. `feedUnavailable` and `collectAbilityNames` were extracted so their behavior is covered, RED-then-GREEN). Bug fixes start with a failing test that reproduces the bug. + +Pure-function/unit tests use `node:test` and live in `test/` (run via `npm run test:node`); see Testing. Never wire a fix into the codebase ahead of its failing test. + ## Development ### Setup @@ -283,30 +303,75 @@ npm run dev # Dev with auto-reload npm run inspect # Debug with MCP Inspector ``` -### Required Environment +### Required Environment (Plane A — Gravity Forms) ``` -GRAVITY_FORMS_CONSUMER_KEY=ck_... # From WP Admin > Forms > Settings > REST API -GRAVITY_FORMS_CONSUMER_SECRET=cs_... # Same location GRAVITY_FORMS_BASE_URL=https://... # WordPress site URL (no trailing slash) +# Recommended — WordPress application password (Users > Profile): +GRAVITY_FORMS_CONSUMER_KEY=wp_username +GRAVITY_FORMS_CONSUMER_SECRET="xxxx xxxx xxxx xxxx xxxx xxxx" +# Or a Gravity Forms API key (Forms > Settings > REST API), e.g. read-only: +# GRAVITY_FORMS_CONSUMER_KEY=ck_... +# GRAVITY_FORMS_CONSUMER_SECRET=cs_... +# Either way: check "Enable access to the API" on Forms > Settings > REST API once. +``` + +Shorthand aliases `GF_CONSUMER_KEY`, `GF_CONSUMER_SECRET`, `GF_URL` are also supported (resolved in `test-config.js`). + +### GravityKit Environment (Plane B — abilities) + +``` +# Optional — only needed for gv_* tools. Falls back to GRAVITY_FORMS_* when unset. +GRAVITYKIT_WP_URL=https://... # WordPress site URL (usually same as GF) +GRAVITYKIT_WP_USERNAME=wp_username +GRAVITYKIT_WP_APP_PASSWORD="xxxx xxxx xxxx xxxx xxxx xxxx" +``` + +`WordPressClient` resolves the base URL from `GRAVITYKIT_WP_URL` or `GRAVITY_FORMS_BASE_URL`, and credentials from `GRAVITYKIT_WP_*` or the `GRAVITY_FORMS_CONSUMER_KEY`/`SECRET` fallback. On most single-site setups the GF credentials already double as the WP app password, so no extra config is needed. + +### Optional Environment + +``` +# GRAVITY_FORMS_AUTH_METHOD=basic # Override auto-selection only (see Gotcha #3) +# GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=false # Basic to a REMOTE plain-HTTP host +GRAVITY_FORMS_ALLOW_DELETE=false # Must be 'true' to enable delete operations +GRAVITY_FORMS_TIMEOUT=30000 # Request timeout in ms +GRAVITY_FORMS_MAX_RETRIES=3 # Max retry attempts +GRAVITY_FORMS_DEBUG=false # Enable debug logging (stderr) +GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=false # Allow self-signed certs (local dev only) ``` +**Note:** `GRAVITY_FORMS_RETRY_DELAY`, `GRAVITY_FORMS_RATE_LIMIT`, and `GRAVITY_FORMS_RATE_WINDOW` appear in older docs but are NOT implemented in source code. + +### Test Environment + +``` +GRAVITY_FORMS_TEST_BASE_URL= # Test site URL +GRAVITY_FORMS_TEST_CONSUMER_KEY= # Test site API key +GRAVITY_FORMS_TEST_CONSUMER_SECRET= # Test site API secret +GRAVITY_FORMS_TEST_AUTH_METHOD= # Override auth method for test site +GRAVITY_FORMS_TEST_TIMEOUT= # Override timeout for test site +GRAVITYKIT_MCP_TEST_MODE=true # Enable test mode (remaps TEST_* vars) +``` + +Shorthand aliases: `TEST_GF_URL`, `TEST_GF_CONSUMER_KEY`, `TEST_GF_CONSUMER_SECRET`, `TEST_WP_USER`, `TEST_WP_PASSWORD`. Legacy: `GRAVITYMCP_TEST_MODE` and `GRAVITY_FORMS_TEST_URL` are also supported. Test mode also activates when `NODE_ENV=test`. + ### Testing ```bash npm run test:unit # Unit tests via custom runner +npm run test:node # node:test unit suites (field ops, helpers, ability-catalog) npm run test:auth # Authentication tests npm run test:forms # Forms endpoint tests npm run test:entries # Entries endpoint tests npm run test:feeds # Feeds endpoint tests npm run test:tools # Tool registration validation +npm run test:views # GravityView inspector/validator tests npm run test:all # Run everything sequentially npm test # Integration tests (requires live API) ``` -Tests use a custom runner (`src/tests/run.js`), not Jest/Mocha. Test helpers in `src/tests/helpers.js` provide mock data generators (`generateMockForm`, `generateMockEntry`, `generateMockFeed`). - -For integration tests, set `GRAVITY_FORMS_TEST_*` env vars pointing to a test WordPress site. Test forms are prefixed with `TEST_` and auto-cleaned via `TestFormManager`. +Tests use a custom runner (`test/run.js`), not Jest/Mocha. Test helpers in `test/helpers.js` provide mock data generators (`generateMockForm`, `generateMockEntry`, `generateMockFeed`). For integration tests, set `GRAVITY_FORMS_TEST_*` env vars pointing to a test WordPress site; test forms are prefixed with `TEST_` and auto-cleaned via `TestFormManager`. ### Building @@ -314,29 +379,40 @@ No build step — pure ESM JavaScript, runs directly with `node src/index.js`. R ## Gotchas -1. **Fields are form properties, not separate endpoints.** The Gravity Forms REST API has no direct field CRUD endpoints. All field operations require fetching the entire form, modifying the fields array, then PUT-ing the whole form back. This is why `FieldManager` exists as a layer on top of `GravityFormsClient`. — `field-manager.js:31-56` +1. **Fields are form properties, not separate endpoints.** The Gravity Forms REST API has no direct field CRUD endpoints. All field operations fetch the entire form, modify the fields array, then PUT the whole form back. This is why `FieldManager` exists as a layer on top of `GravityFormsClient`. + +2. **Stdout is reserved for JSON-RPC.** In MCP mode, ALL logging must go to stderr. `console.log` corrupts the transport. Use `logger.info/error/warn`. + +3. **Auth method is credential-aware.** `AuthManager` picks the transport from the credential shape: app-password creds use Basic over HTTPS or local URLs; `ck_`/`cs_` key pairs use Basic over HTTPS and OAuth 1.0a over plain HTTP (Gravity Forms only checks key-pair Basic auth when `is_ssl()`). An explicit `GRAVITY_FORMS_AUTH_METHOD` is always honored — including `basic` over remote HTTP, so don't set it in `.env` "just in case". Remote-HTTP Basic without an explicit method needs `GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true`. -2. **Stdout is reserved for JSON-RPC.** In MCP mode, ALL logging must go to stderr. Using `console.log` will corrupt the JSON-RPC transport. The `logger.js` utility handles this, but any new code must use `logger.info/error/warn` instead of `console.log`. — `utils/logger.js:14-32` +4. **Update operations fetch-then-merge.** `updateForm`, `updateEntry`, and `updateFeed` GET the existing resource, merge, then PUT — two HTTP calls per update. If the resource changes between GET and PUT, the intermediate change is overwritten. -3. **Auth fallback is silent.** If Basic Auth fails because the site uses HTTP (not HTTPS), `AuthManager` silently falls back to OAuth 1.0a. Only warns in non-test mode. This can cause confusing auth failures if OAuth credentials aren't properly configured. — `config/auth.js:250-267` +5. **Field ID generation uses max+1.** If field ID 10 is deleted, the next field gets ID 11, not 10. IDs are never reused within a form. -4. **Update operations fetch-then-merge.** `updateForm`, `updateEntry`, and `updateFeed` all GET the existing resource first, merge updates, then PUT. This prevents data loss but means two HTTP calls per update. If the resource is modified between GET and PUT, the intermediate change is overwritten. — `gravity-forms-client.js:262-278` +6. **Compound field sub-input IDs use dot notation.** Address field 5 has sub-inputs `5.1` (street), `5.2` (line 2), etc. These IDs are strings, not numbers. Entry data uses these dot-notation keys. -5. **Field ID generation uses max+1.** If a field with ID 10 is deleted, the next field gets ID 11, not 10. IDs are never reused within a form. — `field-manager.js:173-182` +7. **Delete operations are disabled by default.** `GRAVITY_FORMS_ALLOW_DELETE=true` must be set explicitly, or `deleteForm`/`deleteEntry`/`deleteFeed` throw. Intentional safety. -6. **Compound field sub-input IDs use dot notation.** Address field 5 has sub-inputs `5.1` (street), `5.2` (line 2), etc. These IDs are strings, not numbers. Entry data uses these dot-notation keys. — `field-manager.js:206-267` +8. **`mcp.json` may be stale.** The runtime source of truth is `GF_TOOL_DEFINITIONS` + the `ListToolsRequestSchema` handler in `src/index.js`, `fieldOperationTools` in `field-operations/index.js` (26 `gf_*` tools), the built-in `gk_reload_abilities`, and the dynamic `gv_*` tools from the abilities loader. -7. **Delete operations are disabled by default.** `GRAVITY_FORMS_ALLOW_DELETE=true` must be explicitly set. Without it, `deleteForm`, `deleteEntry`, and `deleteFeed` throw immediately. This is intentional safety. — `gravity-forms-client.js:88, 292-294` +9. **Self-signed certs for local dev.** Set `GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true` to bypass certificate validation for local WordPress (Laravel Valet, Local WP, etc.). Never in production. -8. **The `mcp.json` manifest lists 24 tools, but there are actually 28.** The 4 field operation tools (`gf_add_field`, `gf_update_field`, `gf_delete_field`, `gf_list_field_types`) were added after the manifest was written. The `ListToolsRequestSchema` handler in `index.js` is the source of truth. — `mcp.json` vs `src/index.js:517` +10. **Validation has legacy and new patterns.** A `BaseValidator` legacy layer wraps the newer `ValidationChain` and domain validators. Both paths are active. New code should use the chain system in `validation-chain.js`. -9. **Self-signed certs for local dev.** Set `MCP_ALLOW_SELF_SIGNED_CERTS=true` to bypass certificate validation for local WordPress environments (Laravel Valet, Local WP, etc.). Never enable in production. — `gravity-forms-client.js:31-33` +11. **`gf_list_field_types` defaults to summary mode.** Returns only `type`, `label`, `category`. Pass `detail=true` for full metadata; add `include_variants=true` for variants. Prevents dumping thousands of tokens for all 45 field types. -10. **Validation has legacy and new patterns.** The validation system has a `BaseValidator` legacy layer wrapping newer `ValidationChain` and domain-specific validators. Both paths are active. New code should use the chain system in `validation-chain.js`. — `config/validation.js:21-260` +12. **Test mode resolves env vars at client construction.** When `GRAVITYKIT_MCP_TEST_MODE=true` (or legacy `GRAVITYMCP_TEST_MODE=true`), `testConfig.resolveEnv()` remaps `GRAVITY_FORMS_TEST_*` → `GRAVITY_FORMS_*`. The rest of the client and AuthManager work unchanged. -11. **`gf_list_field_types` defaults to summary mode.** Returns only `type`, `label`, `category` per field type. Pass `detail=true` for full metadata (supports, storage, validation). Pass `include_variants=true` with `detail=true` for variant data. This prevents accidentally dumping thousands of tokens for all 44 field types. — `field-operations/index.js:142-211` +13. **`gv_*` tools load asynchronously and self-heal.** The abilities catalog is fetched in the background after startup, so `gv_*` tools may be absent for a moment (the server emits a `tools/listChanged` once they arrive). If a catalog fetch fails, it retries after a cooldown or immediately on `gk_reload_abilities`. The `src/gravityview/` Inspector client is a test/demo harness only — runtime `gv_*` come from the abilities loader. -12. **Test mode resolves env vars at client construction.** When `GRAVITYKIT_MCP_TEST_MODE=true` (or legacy `GRAVITYMCP_TEST_MODE=true`), `testConfig.resolveEnv()` remaps `GRAVITY_FORMS_TEST_BASE_URL` → `GRAVITY_FORMS_BASE_URL` (and consumer key/secret). The rest of the client and AuthManager work unchanged. — `config/test-config.js:60-95`, `gravity-forms-client.js:16` +## Packaging + +What ships to npm is governed solely by the **`files` allowlist** in `package.json` — there is intentionally **no `.npmignore`** (with a `files` field present npm ignores it, so keeping one is misleading). Allowlist, not denylist: a new file ships only if it matches `files`. + +- **Ships:** `src/` (runtime), `mcp.json`, `.env.example`, `README.md`, `LICENSE`, `CLAUDE.md`, `AGENTS.md`. +- **Excluded by omission:** `test/` (tests are top-level, not under `src/`), `scripts/` (dev tooling), `.github/`, `package-lock.json`. +- **`npm run lint:package`** runs [publint](https://publint.dev) to validate package correctness; **`npm run lint:docs`** runs the offline doc-freshness guard (`scripts/check-docs.mjs`). **`prepublishOnly`** runs the offline test suites + both linters, so a broken, mis-packaged, or stale-documented build can't be published. It deliberately omits the live integration test (`npm test`) to avoid hitting a real site during publish. +- **Verify before publishing:** `npm pack --dry-run` lists exactly what will ship. ## Releasing @@ -344,17 +420,21 @@ No build step — pure ESM JavaScript, runs directly with `node src/index.js`. R 1. **Update `CHANGELOG.md`** — add a new `## [X.Y.Z] - YYYY-MM-DD` section with all changes since the last release. Follow [Keep a Changelog](https://keepachangelog.com/) format (Added, Changed, Fixed, Removed). 2. **Bump `version` in `package.json`** -3. **Update version in `CLAUDE.md`** (Project Identity → Package line) +3. **Update version in `AGENTS.md`** (Project Identity → Package line) 4. **Add link** at bottom of `CHANGELOG.md`: `[X.Y.Z]: https://github.com/GravityKit/MCP/releases/tag/vX.Y.Z` 5. **Commit**: `git commit -m "chore(release): bump version to X.Y.Z"` 6. **Tag**: `git tag vX.Y.Z` 7. **Push**: `git push origin main --tags` -Skipping any step (especially CHANGELOG) will leave the release history incomplete for future developers and AI agents. +Skipping any step (especially CHANGELOG) leaves the release history incomplete. + +**Before tagging:** +- Run **`npm run lint:docs`** — the offline doc-freshness guard (repo-map coverage, tool/field counts, no line citations). `prepublishOnly` runs it too. +- Run **`npm run verify:tool-names` against a live site** — the `gv_*` tools are generated from the installed GravityView/Foundation Abilities catalog, so a catalog rename can silently leave the server `instructions` string, README, or the demo referencing tools that no longer exist. The script cross-checks every `gf_`/`gv_` name in prose against what the server registers and exits non-zero on a mismatch. Needs WordPress credentials (see GravityKit Environment). Dev-only — not shipped in the npm package. ## Related Resources -- **CLAUDE.md** — Concise project identity and critical rules +- **CLAUDE.md** — Claude Code entry point; re-exports this file via `@AGENTS.md` - **README.md** — User-facing setup and usage guide - **.env.example** — Complete environment variable reference - **mcp.json** — MCP manifest (tool catalog, auth requirements) diff --git a/CLAUDE.md b/CLAUDE.md index 07856ff..43c994c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,50 +1 @@ -You MUST fully ingest @AGENTS.md first. - -# GravityKit MCP Server - -## Project Identity - -- **Package:** `@gravitykit/mcp` v2.1.0 -- **Type:** Node.js MCP server (ESM) -- **Purpose:** Full Gravity Forms REST API v2 coverage via 28 MCP tools -- **Repo:** https://github.com/GravityKit/MCP - -## Key Commands - -```bash -npm run dev # Dev with auto-reload -npm run inspect # MCP Inspector debugging -npm run check-env # Validate environment -npm run test:all # Run all test suites -npm test # Integration tests (live API) -``` - -## Environment - -Required env vars (see `.env.example` for full list): -- `GRAVITY_FORMS_CONSUMER_KEY` — from WP Admin > Forms > Settings > REST API -- `GRAVITY_FORMS_CONSUMER_SECRET` -- `GRAVITY_FORMS_BASE_URL` — WordPress site URL, no trailing slash - -## Critical Rules - -1. **Never use `console.log` in MCP mode** — stdout is JSON-RPC. Use `logger.info/error/warn` from `utils/logger.js` -2. **Always use `.js` extension** in imports (ESM requirement) -3. **Delete operations require `GRAVITY_FORMS_ALLOW_DELETE=true`** env var -4. **Fields are form properties** — no direct field endpoints; modify via form PUT -5. **Update operations fetch-then-merge** — always GET existing data first to avoid data loss -6. **Minimize response tokens** — no pretty-print (`JSON.stringify(result)` not `null, 2`), no redundant `message` strings, no echo-back of input IDs, no `created`/`updated` booleans. Return only essential data. -7. **Keep tool descriptions terse** — every token in tool schemas is sent on every `tools/list` call -8. **Compact mode strips null, empty strings, and entry meta** — `stripEmpty()` in `utils/compact.js` runs on all responses. Entry tools also strip plugin-added meta keys via `stripEntryMeta()`, keeping only core properties and field values. `false` is preserved. Pass `compact=false` for full raw data. -9. **Test mode uses dev site** — when `GRAVITYKIT_MCP_TEST_MODE=true` (or legacy `GRAVITYMCP_TEST_MODE=true`), the client auto-resolves `GRAVITY_FORMS_TEST_*` env vars to connect to the test site instead of production. Resolution logic lives in `testConfig.resolveEnv()` in `config/test-config.js`. - -## Release Checklist - -When tagging a new version, you MUST complete ALL of these steps: - -1. Update `CHANGELOG.md` with all changes since the last release (follow Keep a Changelog format) -2. Bump `version` in `package.json` -3. Update the version in this file (`CLAUDE.md` → Project Identity → Package) -4. Commit with message `chore(release): bump version to X.Y.Z` -5. Tag: `git tag vX.Y.Z` -6. Push: `git push origin main --tags` +@AGENTS.md diff --git a/README.md b/README.md index 4c714bd..47400ee 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,12 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit ## Features -- **Comprehensive API Coverage**: Gravity Forms API endpoints +- **Full Gravity Forms Coverage**: 26 tools across forms, entries, feeds, notifications, submissions, and field management - **Smart Field Management**: Intelligent field operations with dependency tracking - **Advanced Search**: Complex filtering and searching capabilities for entries - **Form Submissions**: Full submission workflow with validation - **Add-on Integration**: Manage feeds for MailChimp, Stripe, PayPal, and more +- **GravityKit Products**: dynamic tools auto-generated from the site's Foundation abilities catalog — each add-on under its own prefix (GravityView's View authoring is first, using `gv_*`) - **Type-Safe**: Comprehensive validation for all operations - **Battle-Tested**: Extensive test suite with real-world scenarios @@ -21,61 +22,51 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit ### Prerequisites - Node.js 18+ - WordPress with Gravity Forms 2.5+ -- HTTPS-enabled WordPress site (required for authentication) +- WordPress site over HTTPS (recommended) — local `http://` dev sites (localhost, `*.test`, `*.local`) work too ### Installation -1. **Clone the repository** - ```bash - git clone https://github.com/GravityKit/MCP.git - cd MCP - npm install - ``` - -2. **Set up environment** - ```bash - cp .env.example .env - ``` +No clone or `npm install` needed — `npx` runs the published package on demand. (To run from a local checkout for development, see [Contributing](#contributing).) -3. **Configure credentials** in `.env`: - ```env - GRAVITY_FORMS_CONSUMER_KEY=your_key_here - GRAVITY_FORMS_CONSUMER_SECRET=your_secret_here - GRAVITY_FORMS_BASE_URL=https://yoursite.com - ``` +1. **Enable the Gravity Forms REST API** (one-time, required for any credential type): + - Go to **Forms → Settings → REST API** and check **Enable access to the API** — Gravity Forms doesn't register its REST routes without it. - **For local development** (Laravel Valet, MAMP, etc.): - ```env - # Add this line if using self-signed certificates - MCP_ALLOW_SELF_SIGNED_CERTS=true - ``` +2. **Create credentials** in WordPress (pick one): -4. **Generate API credentials** in WordPress: - - Go to **Forms → Settings → REST API** - - Click **Add Key** - - Save the Consumer Key and Secret + **Application password (recommended):** + - Go to **Users → Profile → Application Passwords** + - Name it (e.g. `GravityKit MCP`) and click **Add New Application Password** + - Copy the generated password. Your username + this password are your credentials, and access follows your WordPress capabilities. On sites running GravityKit Foundation, the same credential also powers the GravityKit product tools. -5. **Add to Claude Desktop** + **Gravity Forms API key (for scoped access, e.g. a read-only key):** + - On the same **Forms → Settings → REST API** screen, click **Add Key** + - Choose the user and permission level, then save the Consumer Key (`ck_…`) and Secret (`cs_…`) - Edit `~/Library/Application Support/Claude/claude_desktop_config.json`: +3. **Add to your MCP client.** For Claude Desktop, edit `~/Library/Application Support/Claude/claude_desktop_config.json`: ```json { "mcpServers": { "gravitykit-mcp": { - "command": "node", - "args": ["/path/to/MCP/src/index.js"], + "command": "npx", + "args": ["-y", "@gravitykit/mcp"], "env": { - "GRAVITY_FORMS_CONSUMER_KEY": "your_key", - "GRAVITY_FORMS_CONSUMER_SECRET": "your_secret", - "GRAVITY_FORMS_BASE_URL": "https://yoursite.com" + "GRAVITY_FORMS_BASE_URL": "https://yoursite.com", + "GRAVITY_FORMS_CONSUMER_KEY": "your_wp_username", + "GRAVITY_FORMS_CONSUMER_SECRET": "xxxx xxxx xxxx xxxx xxxx xxxx" } } } } ``` + Then restart Claude Desktop — `npx` fetches and runs `@gravitykit/mcp` on demand. Notes: + - Prefer a Gravity Forms key pair? Use `"GRAVITY_FORMS_CONSUMER_KEY": "ck_…"` and `"GRAVITY_FORMS_CONSUMER_SECRET": "cs_…"`. + - Local dev with self-signed certs: add `"GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS": "true"` to the `env` block. + - Pin a version with `@gravitykit/mcp@x.y.z` if you don't want `npx` tracking latest. ## Available Tools +Two planes: **Gravity Forms** (`gf_*`) — 26 static tools, listed whenever Gravity Forms credentials are valid — and **GravityKit** — dynamic tools generated from the Foundation catalog when it's active, where each add-on registers tools under its own prefix (GravityView uses `gv_*`). The `gk_reload_abilities` tool reloads the GravityKit catalog. The two planes are independent: a Gravity-Forms-only site lists just `gf_*`; a GravityKit site without Gravity Forms REST keys still lists its product tools. + ### Forms (6 tools) - `gf_list_forms` - List forms with filtering and pagination - `gf_get_form` - Get complete form configuration @@ -101,15 +92,25 @@ Built by [GravityKit](https://www.gravitykit.com) for the Gravity Forms communit - `gf_submit_form_data` - Submit forms with full processing - `gf_validate_submission` - Validate without submitting -### Add-ons (7 tools) +### Add-ons (6 tools) - `gf_list_feeds` - List all add-on feeds - `gf_get_feed` - Get specific feed configuration -- `gf_list_form_feeds` - List feeds for a specific form - `gf_create_feed` - Create new add-on feeds - `gf_update_feed` - Update existing feeds - `gf_patch_feed` - Partially update feed properties - `gf_delete_feed` - Delete add-on feeds +### Notifications (1 tool) +- `gf_send_notifications` - Send a form's notifications for an entry + +### Utilities (2 tools) +- `gf_get_field_filters` - List the available field filters for a form +- `gf_get_results` - Get aggregated results for Quiz/Poll/Survey forms + +### GravityKit Products (dynamic) + +When [GravityKit Foundation](https://www.gravitykit.com) is active on the connected site, additional tools are generated at runtime from its Abilities catalog — each GravityKit add-on under its own server-assigned prefix, so the exact set depends on the installed products and versions. **GravityView** is supported today, using the `gv_*` prefix: View lifecycle (`gv_view_create`, `gv_view_config_apply`, `gv_view_delete`), field/widget/search/grid editing, and discovery (`gv_layouts_list`, `gv_field_type_schema_get`, …). Use the `gv_*_list` discovery tools to see what's available on your site, and `gk_reload_abilities` to reload the catalog after activating or updating GravityKit products. + ## Usage Examples ### Search Entries @@ -150,16 +151,42 @@ await mcp.call('gf_submit_form_data', { ## Configuration +Set these as environment variables — in your MCP client's `env` block (the `npx` setup above) or in a `.env` file when running from a local clone. + ### Required Environment Variables -- `GRAVITY_FORMS_CONSUMER_KEY` - API consumer key -- `GRAVITY_FORMS_CONSUMER_SECRET` - API consumer secret +- `GRAVITY_FORMS_CONSUMER_KEY` - WordPress username (app-password setup) or GF consumer key (`ck_…`) +- `GRAVITY_FORMS_CONSUMER_SECRET` - Application password or GF consumer secret (`cs_…`) - `GRAVITY_FORMS_BASE_URL` - WordPress site URL ### Optional Settings +- `GRAVITY_FORMS_AUTH_METHOD` - Override auto-selection: `basic` or `oauth`/`oauth1` (normally leave unset) +- `GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=false` - Allow Basic auth to a REMOTE plain-HTTP host (credentials visible to the network) - `GRAVITY_FORMS_ALLOW_DELETE=false` - Enable delete operations - `GRAVITY_FORMS_TIMEOUT=30000` - Request timeout (ms) +- `GRAVITY_FORMS_MAX_RETRIES=3` - Max retry attempts for failed requests - `GRAVITY_FORMS_DEBUG=false` - Enable debug logging -- `MCP_ALLOW_SELF_SIGNED_CERTS=false` - Allow self-signed SSL certificates (local dev only) +- `GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=false` - Allow self-signed SSL certificates (local dev only) + +### GravityKit Product Tools + +The GravityKit product tools reach the same site over the WordPress REST Abilities API, so on a single install **no extra configuration is needed** — they reuse your `GRAVITY_FORMS_*` credentials (a WordPress username + application password). Override only if the WordPress root differs from the Gravity Forms URL, or to use a separate credential: + +- `GRAVITYKIT_WP_URL` - WordPress site URL (defaults to `GRAVITY_FORMS_BASE_URL`) +- `GRAVITYKIT_WP_USERNAME` - WordPress username (defaults to `GRAVITY_FORMS_CONSUMER_KEY`) +- `GRAVITYKIT_WP_APP_PASSWORD` - Application password (defaults to `GRAVITY_FORMS_CONSUMER_SECRET`) + +These tools appear only when GravityKit Foundation is active on the connected site. They authenticate with a WordPress application password over Basic auth, so — like the Gravity Forms plane — they refuse a remote plain-HTTP URL unless `GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true` (HTTPS and local URLs are always fine). + +### Authentication Flow + +The client picks the right transport from the shape of your credentials — you normally don't configure anything: + +- **Application password** (username + app password) → **Basic Authentication** over HTTPS or any local URL (localhost, `*.test`, `*.local`). WordPress core authenticates the user; Gravity Forms enforces their capabilities. +- **Gravity Forms key pair** (`ck_…`/`cs_…`) over HTTPS → **Basic Authentication**. +- **Gravity Forms key pair** over plain HTTP → **OAuth 1.0a**, automatically — Gravity Forms only accepts key-pair Basic auth over HTTPS, so OAuth is the only transport that works there. +- **Remote plain-HTTP host** → OAuth for key pairs; Basic requires the explicit `GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true` opt-in and logs a warning. + +Set `GRAVITY_FORMS_AUTH_METHOD` only to override the auto-selection. Whatever the transport, access is limited to the WordPress user's capabilities (or the API key's permission level). ## Test Environment Configuration @@ -242,15 +269,16 @@ The server includes multiple safety mechanisms to prevent accidental production ## Testing ```bash -# Run all tests +# Run everything (offline suites + live integration) npm run test:all -# Run specific test suites -npm run test:forms -npm run test:entries -npm run test:field-operations +# Offline suites +npm run test:unit # custom-runner unit tests +npm run test:node # node:test units (field ops, helpers, ability catalog, …) +npm run test:views # GravityView inspector / validator +npm run test:forms # per-endpoint suites: also test:entries, test:feeds, test:submissions, … -# Run with live API (requires credentials) +# Live integration (requires test credentials) npm test ``` @@ -270,12 +298,13 @@ npm test ### Local Development with Self-Signed Certificates -If you're using a local development environment (Laravel Valet, MAMP, Local WP, etc.) with self-signed SSL certificates, you may encounter authentication errors. To fix this: +If you're using a local development environment (Laravel Valet, MAMP, Local WP, etc.) with self-signed SSL certificates, you may encounter authentication errors. To fix this, set: -Add to your `.env` file: -```env -MCP_ALLOW_SELF_SIGNED_CERTS=true ``` +GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true +``` + +in your MCP client's `env` block (`"GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS": "true"`), or in `.env` when running from a local clone. **⚠️ Security Warning**: Only disable SSL certificate verification for local development environments. Never use this setting in production! @@ -283,7 +312,7 @@ MCP_ALLOW_SELF_SIGNED_CERTS=true - Confirm API keys are correct - Verify user has appropriate Gravity Forms capabilities - Check Forms → Settings → REST API for key status -- For local development, ensure `MCP_ALLOW_SELF_SIGNED_CERTS=true` is set if using self-signed certificates +- For local development, ensure `GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true` is set if using self-signed certificates ### Debug Mode Enable detailed logging: diff --git a/demo-abilities.mjs b/demo-abilities.mjs new file mode 100644 index 0000000..cb88171 --- /dev/null +++ b/demo-abilities.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +/** + * Abilities API end-to-end demo. + * + * Walks the new GravityView Abilities surface from cold start to a + * round-trip create + apply + render — same path the MCP and the + * Design Studio React app now use in production. + * + * Run from the repo root: + * node demo-abilities.mjs + * + * Needs WordPress creds in the environment (or .env): GRAVITYKIT_WP_URL + + * GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD, or the GRAVITY_FORMS_* + * equivalents. Set GRAVITYKIT_DEMO_FORM_ID to bind the View to an existing + * form; otherwise the demo mints a throwaway form and cleans it up. + */ + +import 'dotenv/config'; +import { WordPressClient } from './src/wp-client.js'; +import { loadAbilitiesAsTools, methodForAbility } from './src/abilities/loader.js'; +import GravityFormsClient from './src/gravity-forms-client.js'; + +const RESET = '\x1b[0m'; +const DIM = '\x1b[2m'; +const CYAN = '\x1b[36m'; +const GREEN = '\x1b[32m'; +const YELLOW = '\x1b[33m'; +const MAGENTA = '\x1b[35m'; +const BOLD = '\x1b[1m'; + +function header(s) { + console.log(`\n${BOLD}${CYAN}━━ ${s} ━━${RESET}`); +} +function step(n, s) { + console.log(`\n${BOLD}${MAGENTA}[${n}]${RESET} ${BOLD}${s}${RESET}`); +} +function muted(s) { + console.log(`${DIM}${s}${RESET}`); +} +function value(label, v) { + const json = typeof v === 'string' ? v : JSON.stringify(v, null, 2); + console.log(` ${YELLOW}${label}${RESET}: ${json}`); +} +function ok(s) { + console.log(` ${GREEN}✓${RESET} ${s}`); +} + +const client = new WordPressClient(process.env); + +// ────────────────────────────────────────────────────────────────── +header('1. Discover the catalog (single network call)'); +// ────────────────────────────────────────────────────────────────── + +step('1a', 'Fetch /wp-json/wp-abilities/v1/abilities'); +const { handlers, count } = await loadAbilitiesAsTools(client); +ok(`${count} abilities discovered under the gk-gravityview/ namespace`); + +step('1b', 'Categorize them — what can the agent do?'); +const catalogResp = await client.httpClient.request({ + method: 'GET', + baseURL: client.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', +}); +const ours = catalogResp.data.filter(a => a.name?.startsWith('gk-gravityview/')); +const byCat = {}; +for (const a of ours) (byCat[a.category] ||= []).push(a); +for (const cat of Object.keys(byCat).sort()) { + console.log(` ${CYAN}${cat}${RESET} (${byCat[cat].length})`); + byCat[cat].slice(0, 3).forEach(a => muted(` • ${a.name}`)); + if (byCat[cat].length > 3) muted(` … +${byCat[cat].length - 3} more`); +} + +step('1c', 'Show one ability\'s full self-description'); +const sample = ours.find(a => a.name === 'gk-gravityview/layouts-list'); +console.log(` ${BOLD}${sample.name}${RESET}`); +muted(` ${sample.description.slice(0, 140)}…`); +value('annotations', sample.meta?.annotations); +value('output schema', sample.output_schema); + +// ────────────────────────────────────────────────────────────────── +header('2. HTTP method auto-routing (annotations drive the wire)'); +// ────────────────────────────────────────────────────────────────── + +const examples = [ + ours.find(a => a.name === 'gk-gravityview/layouts-list'), // readonly + idempotent → GET + ours.find(a => a.name === 'gk-gravityview/view-create'), // write → POST + ours.find(a => a.name === 'gk-gravityview/view-field-remove'), // destructive + idempotent → DELETE +]; +for (const a of examples) { + const m = methodForAbility(a.meta?.annotations || {}); + const ann = a.meta?.annotations || {}; + console.log(` ${BOLD}${a.name.padEnd(40)}${RESET} → ${GREEN}${m}${RESET} ${DIM}(readonly=${!!ann.readonly} destructive=${!!ann.destructive} idempotent=${!!ann.idempotent})${RESET}`); +} + +// ────────────────────────────────────────────────────────────────── +header('3. Run a readonly ability (zero input)'); +// ────────────────────────────────────────────────────────────────── + +step('3a', 'gv_layouts_list → list installed layout engines'); +const layouts = await handlers.gv_layouts_list({}); +ok(`${layouts.layouts.length} layouts returned`); +layouts.layouts.slice(0, 4).forEach(l => { + console.log(` ${YELLOW}${l.id.padEnd(32)}${RESET} ${l.label}${l.has_grid ? ' ' + GREEN + '[grid]' + RESET : ''}`); +}); + +// ────────────────────────────────────────────────────────────────── +header('4. Run a readonly ability with input (bracketed query params)'); +// ────────────────────────────────────────────────────────────────── + +step('4a', 'gv_field_type_schema_get { field_type: "email" }'); +const emailSchema = await handlers.gv_field_type_schema_get({ field_type: 'email' }); +ok(`${emailSchema.schema.length} settings declared for the email field type`); +emailSchema.schema.slice(0, 5).forEach(s => muted(` • ${s.slug.padEnd(28)} ${s.type.padEnd(12)} ${s.label || ''}`)); + +// ────────────────────────────────────────────────────────────────── +header('5. End-to-end round-trip: create → apply → read'); +// ────────────────────────────────────────────────────────────────── + +step('5a', 'gv_view_create — mint a fresh draft'); +// Bind to GRAVITYKIT_DEMO_FORM_ID if provided, else mint a throwaway form. +let formId = Number(process.env.GRAVITYKIT_DEMO_FORM_ID || 0); +let tempFormClient = null; +if (!formId) { + tempFormClient = new GravityFormsClient({ ...process.env, GRAVITY_FORMS_ALLOW_DELETE: 'true' }); + await tempFormClient.initialize(); + const f = await tempFormClient.createForm({ + title: 'Abilities API demo form', + fields: [ + { id: 1, type: 'text', label: 'Speaker' }, + { id: 2, type: 'email', label: 'Email' }, + ], + }); + formId = Number(f.form?.id ?? f.id); + ok(`minted throwaway form #${formId}`); +} +const created = await handlers.gv_view_create({ + title: `Abilities API demo · ${new Date().toISOString().slice(11, 19)}`, + form_id: formId, + template_id: 'default_table', + status: 'draft', +}); +ok(`view #${created.view_id} created (version ${created.version})`); +value('admin URL', created.admin_url || `[edit in WP admin via post id ${created.view_id}]`); + +step('5b', 'gv_view_config_apply — add a column with optimistic concurrency'); +const applied = await handlers.gv_view_config_apply({ + id: created.view_id, + fields: { 'directory_table-columns': [{ field_id: '1', slot: 'demo_speaker', custom_label: 'Speaker' }] }, + mode: 'merge', + ifMatch: `"${created.version}"`, +}); +ok(`apply landed → version bumped to ${applied.version}`); +value('applied envelope', applied.applied); + +step('5c', 'gv_view_config_get — read it back'); +const config = await handlers.gv_view_config_get({ id: created.view_id }); +const slot = config.fields['directory_table-columns']?.demo_speaker; +ok(`field present at directory_table-columns.demo_speaker`); +value('stored slot', slot); + +// ────────────────────────────────────────────────────────────────── +header('6. Stale ifMatch → server returns 412 (concurrency in action)'); +// ────────────────────────────────────────────────────────────────── + +step('6a', 'Apply with the OLD version (now stale after 5b)'); +let conflict; +try { + await handlers.gv_view_config_apply({ + id: created.view_id, + fields: { 'directory_table-columns': [{ field_id: '1', slot: 'should_fail', custom_label: 'X' }] }, + mode: 'merge', + ifMatch: `"${created.version}"`, // pre-5b version, deliberately stale + }); +} catch (err) { + conflict = err; +} +if (conflict?.response?.status === 412) { + ok('412 Precondition Failed — server refused the stale write'); + value('error code', conflict.response.data?.code); +} else { + console.log(' (no 412 — race may have served us; concurrency check still firing if you re-run)'); +} + +// ────────────────────────────────────────────────────────────────── +header('7. Direct REST probe (no MCP, no client wrapper)'); +// ────────────────────────────────────────────────────────────────── + +step('7a', 'curl-equivalent GET on a readonly ability'); +const direct = await client.httpClient.request({ + method: 'GET', + baseURL: client.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/search-zones-list/run', +}); +ok(`HTTP ${direct.status} /wp-abilities/v1/abilities/gk-gravityview/search-zones-list/run`); +value('body', direct.data); + +step('7b', 'How an external client (curl, Postman, the React app) calls this'); +console.log(` ${DIM}curl -u user:pass -X POST \\${RESET}`); +console.log(` ${DIM} https://example.com/wp-json/wp-abilities/v1/abilities/gk-gravityview/view-config-apply/run \\${RESET}`); +console.log(` ${DIM} -H 'Content-Type: application/json' \\${RESET}`); +console.log(` ${DIM} -d '{"input":{"id":${created.view_id},"fields":{...}}}'${RESET}`); + +// ────────────────────────────────────────────────────────────────── +header('8. Clean up what the demo created'); +// ────────────────────────────────────────────────────────────────── + +step('8a', 'gv_view_delete — remove the demo View'); +await handlers.gv_view_delete({ id: created.view_id }) + .then(() => ok(`View #${created.view_id} deleted`)) + .catch(err => muted(` view cleanup skipped: ${err.response?.data?.code || err.message}`)); + +if (tempFormClient) { + step('8b', 'Delete the throwaway form'); + await tempFormClient.deleteForm({ id: formId }) + .then(() => ok(`Form #${formId} deleted`)) + .catch(err => muted(` form cleanup skipped: ${err.message}`)); +} + +console.log(`\n${BOLD}${GREEN}Done.${RESET}\n`); diff --git a/mcp.json b/mcp.json index 8c35a4d..e18b812 100644 --- a/mcp.json +++ b/mcp.json @@ -6,7 +6,8 @@ "license": "MIT", "capabilities": { "tools": 26, - "resources": 0 + "resources": 0, + "note": "22 tools in src/index.js + 4 field operation tools in src/field-operations/index.js" }, "tools": [ { @@ -158,13 +159,12 @@ "GRAVITY_FORMS_BASE_URL" ], "optional": [ + "GRAVITY_FORMS_AUTH_METHOD", "GRAVITY_FORMS_ALLOW_DELETE", "GRAVITY_FORMS_TIMEOUT", "GRAVITY_FORMS_MAX_RETRIES", - "GRAVITY_FORMS_RETRY_DELAY", "GRAVITY_FORMS_DEBUG", - "GRAVITY_FORMS_RATE_LIMIT", - "GRAVITY_FORMS_RATE_WINDOW" + "GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS" ] }, "features": { diff --git a/package-lock.json b/package-lock.json index f7d054b..47d0c09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,9 @@ "bin": { "gkmcp": "src/index.js" }, + "devDependencies": { + "publint": "^0.3.21" + }, "engines": { "node": ">=18.0.0" } @@ -72,6 +75,19 @@ } } }, + "node_modules/@publint/pack": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@publint/pack/-/pack-0.1.4.tgz", + "integrity": "sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -866,6 +882,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -923,6 +949,13 @@ "wrappy": "1" } }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "dev": true, + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -951,6 +984,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, "node_modules/pkce-challenge": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.0.tgz", @@ -979,6 +1019,28 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/publint": { + "version": "0.3.21", + "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.21.tgz", + "integrity": "sha512-OqejcnMV6E9zel2oCrUOJEiiFkGiAAni0A6ibfQNh1k9Gu5z4F+Yso8lllam7AzmV6Do0vp7u3UpZNRBwuXaHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@publint/pack": "^0.1.4", + "package-manager-detector": "^1.6.0", + "picocolors": "^1.1.1", + "sade": "^1.8.1" + }, + "bin": { + "publint": "src/cli.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://bjornlu.com/sponsor" + } + }, "node_modules/qs": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", @@ -1043,6 +1105,19 @@ "node": ">= 18" } }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", diff --git a/package.json b/package.json index 187e841..82554b9 100644 --- a/package.json +++ b/package.json @@ -13,19 +13,25 @@ "inspect": "npx @modelcontextprotocol/inspector node src/index.js", "check-env": "node scripts/check-env.js", "setup-test-data": "node scripts/setup-test-data.js", - "test": "node src/tests/integration.test.js", - "test:unit": "node src/tests/run.js", - "test:auth": "node src/tests/authentication.test.js", - "test:forms": "node src/tests/forms.test.js", - "test:entries": "node src/tests/entries.test.js", - "test:feeds": "node src/tests/feeds.test.js", - "test:submissions": "node src/tests/submissions.test.js", - "test:validation": "node src/tests/validation.test.js", - "test:field-validation": "node src/tests/field-validation.test.js", - "test:tools": "node src/tests/server-tools.test.js", - "test:compact": "node src/tests/compact.test.js", - "test:all": "npm run test:unit && npm run test:auth && npm run test:forms && npm run test:entries && npm run test:feeds && npm run test:submissions && npm run test:validation && npm run test:field-validation && npm run test:tools && npm test", - "test:coverage": "echo 'Running all tests with coverage analysis' && npm run test:all" + "verify:tool-names": "node scripts/verify-tool-names.mjs", + "test": "node test/integration.test.js", + "test:unit": "node test/run.js", + "test:node": "node --test test/field-manager.test.js test/field-registry.test.js test/field-dependencies.test.js test/field-positioner.test.js test/helpers.test.js test/ability-catalog.test.js test/user-agent.test.js test/wp-client.test.js test/server-runtime.test.js", + "test:auth": "node test/authentication.test.js", + "test:forms": "node test/forms.test.js", + "test:entries": "node test/entries.test.js", + "test:feeds": "node test/feeds.test.js", + "test:submissions": "node test/submissions.test.js", + "test:validation": "node test/validation.test.js", + "test:field-validation": "node test/field-validation.test.js", + "test:tools": "node test/server-tools.test.js", + "test:compact": "node test/compact.test.js", + "test:views": "node test/views.test.js", + "test:all": "npm run test:unit && npm run test:node && npm run test:auth && npm run test:forms && npm run test:entries && npm run test:feeds && npm run test:submissions && npm run test:validation && npm run test:field-validation && npm run test:tools && npm run test:views && npm test", + "test:coverage": "echo 'Running all tests with coverage analysis' && npm run test:all", + "lint:package": "publint", + "lint:docs": "node scripts/check-docs.mjs", + "prepublishOnly": "npm run test:unit && npm run test:node && npm run test:field-validation && npm run test:views && npm run lint:package && npm run lint:docs" }, "keywords": [ "mcp", @@ -49,12 +55,12 @@ }, "files": [ "src/", - "scripts/", - "LICENSE", - "README.md", - ".env.example", "mcp.json", - "CLAUDE.md" + ".env.example", + "README.md", + "LICENSE", + "CLAUDE.md", + "AGENTS.md" ], "repository": { "type": "git", @@ -93,5 +99,8 @@ "Integration testing", "Security validation" ] + }, + "devDependencies": { + "publint": "^0.3.21" } } diff --git a/scripts/check-docs.mjs b/scripts/check-docs.mjs new file mode 100644 index 0000000..81a92d6 --- /dev/null +++ b/scripts/check-docs.mjs @@ -0,0 +1,71 @@ +#!/usr/bin/env node +/** + * Offline doc-freshness guard for AGENTS.md (the single canonical doc). + * + * Catches the drift classes this project has actually hit: a new src/ dir + * nobody documented, stale tool/field counts, brittle file:line citations, + * and a renamed built-in. Pure static analysis — no network, safe for + * prepublishOnly. Exit 0 = fresh, 1 = drift. + * + * Run via `npm run lint:docs`. + */ +import { readFileSync } from 'node:fs'; +import { execSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { fieldRegistry } from '../src/field-definitions/field-registry.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); +const agents = read('AGENTS.md'); +const problems = []; + +// 1. Repo map / docs mention every immediate child of src/ that isn't +// git-ignored. `git ls-files --cached --others --exclude-standard` lets git +// apply .gitignore for us (no hand-rolled parser): it lists on-disk files +// minus ignored ones, so junk like .DS_Store drops out because it's ignored, +// new not-yet-committed files are still caught, and tracked dotfiles count. +try { + const srcChildren = [...new Set( + execSync('git ls-files --cached --others --exclude-standard src', { cwd: ROOT, encoding: 'utf8' }) + .split('\n').filter(Boolean) + .map((p) => p.replace(/^src\//, '').split('/')[0]) + )]; + for (const child of srcChildren) { + if (!agents.includes(child)) problems.push(`src/ entry never mentioned in AGENTS.md: ${child}`); + } +} catch { + console.warn(' (repo-map coverage check skipped — git not available)'); +} + +// 2. "N field types" claims match the registry. +const fieldCount = Object.keys(fieldRegistry).length; +const fieldClaims = [...agents.matchAll(/(\d+)\s+(?:Gravity Forms\s+)?field types/g)].map((m) => Number(m[1])); +if (!fieldClaims.length) problems.push('No "N field types" claim found in AGENTS.md'); +for (const n of fieldClaims) if (n !== fieldCount) problems.push(`Field-type count drift: AGENTS.md says ${n}, registry has ${fieldCount}`); + +// 3. "N Gravity Forms tools" claims match the registered gf_* tools. +const gfRe = /name:\s*'(gf_[a-z0-9_]+)'/g; +const gfTools = new Set([ + ...[...read('src/index.js').matchAll(gfRe)].map((m) => m[1]), + ...[...read('src/field-operations/index.js').matchAll(gfRe)].map((m) => m[1]), +]); +const toolClaims = [...agents.matchAll(/(\d+)\s+(?:[\w-]+\s+)?Gravity Forms tools/g)].map((m) => Number(m[1])); +if (!toolClaims.length) problems.push('No "N Gravity Forms tools" claim found in AGENTS.md'); +for (const n of toolClaims) if (n !== gfTools.size) problems.push(`GF tool-count drift: AGENTS.md says ${n}, code registers ${gfTools.size}`); + +// 4. No brittle file:line citations (policy: cite symbols, not lines). +const cites = [...agents.matchAll(/[\w./-]+\.(?:js|mjs|json|php):\d+(?:-\d+)?|`:\d+(?:-\d+)?`/g)].map((m) => m[0]); +if (cites.length) problems.push(`${cites.length} file:line citation(s) — cite symbols, not lines: ${[...new Set(cites)].slice(0, 6).join(', ')}`); + +// 5. The GravityKit reload built-in is named gk_reload_abilities and registered. +if (agents.includes('gk_reload_abilities') && !read('src/index.js').includes("name: 'gk_reload_abilities'")) { + problems.push('AGENTS.md references gk_reload_abilities but src/index.js does not register a tool with that name'); +} + +if (problems.length) { + console.error('❌ AGENTS.md doc-freshness check failed:'); + for (const p of problems) console.error(' • ' + p); + process.exit(1); +} +console.log(`✅ AGENTS.md doc-freshness OK — ${gfTools.size} gf_* tools, ${fieldCount} field types, repo map covers src/, no line citations`); diff --git a/scripts/lib/ability-catalog.mjs b/scripts/lib/ability-catalog.mjs new file mode 100644 index 0000000..d230831 --- /dev/null +++ b/scripts/lib/ability-catalog.mjs @@ -0,0 +1,31 @@ +/** + * Helpers for reading the WordPress Abilities catalog (dev tooling). + */ + +/** + * Collect every ability name with the given prefix from the WP Abilities + * endpoint. Defaults to the `gk-` GravityKit prefix so it covers every + * product namespace (gk-gravityview, gk-multiple-forms, …), not just + * GravityView. The endpoint paginates (default per_page 50), so all pages + * must be walked or names beyond the first page are missed. + * + * @param {object} wpClient - WordPressClient (has baseUrl + httpClient.request) + * @param {{prefix?: string, perPage?: number}} [opts] + * @returns {Promise>} + */ +export async function collectAbilityNames(wpClient, { prefix = 'gk-', perPage = 100 } = {}) { + const names = new Set(); + for (let page = 1, totalPages = 1; page <= totalPages; page += 1) { + const resp = await wpClient.httpClient.request({ + method: 'GET', + baseURL: wpClient.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', + params: { per_page: perPage, page }, + }); + for (const a of resp.data) { + if (a.name?.startsWith(prefix)) names.add(a.name); + } + totalPages = Number(resp.headers?.['x-wp-totalpages']) || 1; + } + return names; +} diff --git a/scripts/stress-abilities.mjs b/scripts/stress-abilities.mjs new file mode 100644 index 0000000..3cf629b --- /dev/null +++ b/scripts/stress-abilities.mjs @@ -0,0 +1,322 @@ +#!/usr/bin/env node +/** + * Stress + compatibility test for the abilities loader against the + * Foundation 3.0.0 catalog contract (GravityKit/Foundation#158): + * + * - products-filter naming: short declared prefixes (gv_*) AND + * full-product-slug fallback names in the same catalog + * - mcp_tool_name stamped into ability meta (WP core fallback path) + * - paginated Foundation catalog (page/per_page + X-WP-TotalPages) + * - collision guard: duplicates and reserved built-in names + * - schema normalization: object / array-properties / descriptor-array / + * null input_schema shapes + * - execution wire shapes: GET bracketed params, POST {input}, DELETE + * + * Pure synthetic — no live site needed. Exits non-zero on any failure. + * + * Usage: node scripts/stress-abilities.mjs + */ + +import assert from 'node:assert/strict'; +import { performance } from 'node:perf_hooks'; +import { + loadAbilitiesAsTools, + normalizeInputSchema, + methodForAbility, + FOUNDATION_CATALOG_ROUTE, + CORE_ABILITIES_ROUTE, +} from '../src/abilities/loader.js'; + +// ---------------------------------------------------------------- fixtures + +const SCHEMA_VARIANTS = [ + // Proper JSON Schema object. + (i) => ({ + type: 'object', + properties: { id: { type: 'integer' }, [`field_${i}`]: { type: 'string' } }, + required: ['id'], + additionalProperties: false, + }), + // PHP-serialised empty assoc array: properties as []. + () => ({ type: 'object', properties: [] }), + // Top-level array of parameter descriptors. + (i) => ([ + { name: 'view_id', type: 'integer', required: true }, + { slug: `arg_${i}`, type: 'string' }, + 'garbage-entry', + ]), + // Missing entirely. + () => null, +]; + +const ANNOTATION_VARIANTS = [ + { readonly: true }, + { destructive: true, idempotent: true }, + { destructive: true }, + {}, +]; + +/** One Foundation catalog item (Manager::to_rest_item() shape). */ +function foundationItem(i, overrides = {}) { + const product = i % 2 === 0 ? 'gravityview' : 'gravityboard'; // gv_ declared vs full-slug fallback + const prefix = i % 2 === 0 ? 'gv' : 'gravityboard'; + return { + name: `gk-${product}/op-${i}`, + label: `Op ${i}`, + description: `Synthetic ability ${i}.`, + category: `gk-${product}-stress`, + input_schema: SCHEMA_VARIANTS[i % SCHEMA_VARIANTS.length](i), + annotations: ANNOTATION_VARIANTS[i % ANNOTATION_VARIANTS.length], + enabled: true, + gk_product: product, + gk_scope: 'stress', + mcp_tool_name: `${prefix}_op_${i}`, + ...overrides, + }; +} + +/** One WP core catalog ability (meta-shaped, post-Foundation-stamping). */ +function coreAbility(i, overrides = {}) { + return { + name: `gk-gravityview/core-op-${i}`, + label: `Core op ${i}`, + description: `Core synthetic ${i}.`, + input_schema: SCHEMA_VARIANTS[i % SCHEMA_VARIANTS.length](i), + meta: { + gk_registered_by: 'gravitykit', + gk_product: 'gravityview', + mcp_tool_name: `gv_core_op_${i}`, + annotations: ANNOTATION_VARIANTS[i % ANNOTATION_VARIANTS.length], + }, + ...overrides, + }; +} + +function buildFoundationCatalog() { + const items = []; + for (let i = 0; i < 1140; i++) items.push(foundationItem(i)); + + // 20 disabled (defensive skip — the live catalog omits these by default). + for (let i = 0; i < 20; i++) items.push(foundationItem(5000 + i, { enabled: false })); + + // 20 without mcp_tool_name (server owns naming — skipped with warning). + for (let i = 0; i < 20; i++) items.push(foundationItem(6000 + i, { mcp_tool_name: '' })); + + // 10 names outside the gk- contract. + for (let i = 0; i < 10; i++) items.push(foundationItem(7000 + i, { name: `acme/op-${i}` })); + + // 10 duplicate tool names (collide with items 0..9 — first wins). + for (let i = 0; i < 10; i++) { + items.push(foundationItem(8000 + i, { mcp_tool_name: foundationItem(i).mcp_tool_name })); + } + + // 5 colliding with a reserved built-in name. + for (let i = 0; i < 5; i++) items.push(foundationItem(9000 + i, { mcp_tool_name: 'gf_list_forms' })); + + return items; +} + +// ------------------------------------------------------------- mock client + +function paginate(items, perPage, page) { + const totalPages = Math.max(1, Math.ceil(items.length / perPage)); + return { + data: items.slice((page - 1) * perPage, page * perPage), + headers: { 'x-wp-totalpages': String(totalPages) }, + }; +} + +/** + * @param {object} scenario + * foundation: items array | 'throw' + * core: abilities array | 'throw' + */ +function makeClient(scenario) { + const log = { foundationRequests: 0, coreRequests: 0, runs: [] }; + return { + log, + baseUrl: 'https://stress.test', + httpClient: { + async request(config) { + if (config.url === FOUNDATION_CATALOG_ROUTE) { + log.foundationRequests++; + if (scenario.foundation === 'throw') throw new Error('403 synthetic'); + return paginate(scenario.foundation, config.params.per_page, config.params.page); + } + if (config.url === CORE_ABILITIES_ROUTE) { + log.coreRequests++; + if (scenario.core === 'throw') throw new Error('404 synthetic'); + return { data: scenario.core, headers: {} }; + } + if (config.url.includes('/run')) { + log.runs.push(config); + return { data: { ok: true } }; + } + throw new Error(`Unexpected request: ${config.url}`); + }, + }, + }; +} + +// ----------------------------------------------------------------- helpers + +const results = []; +function check(label, fn) { + try { + fn(); + results.push(['PASS', label]); + } catch (err) { + results.push(['FAIL', `${label} — ${err.message}`]); + } +} + +function isPlainObject(v) { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +// ---------------------------------------------------------------- scenarios + +const catalog = buildFoundationCatalog(); +const reservedNames = new Set(['gf_list_forms', 'gf_get_form']); + +// A. Foundation path: filtering, collisions, pagination, schema validity. +{ + const client = makeClient({ foundation: catalog, core: [] }); + const tools = await loadAbilitiesAsTools(client, { reservedNames }); + + check('A1: source is foundation-catalog', () => assert.equal(tools.source, 'foundation-catalog')); + check('A2: exactly the 1,140 valid abilities become tools', () => assert.equal(tools.count, 1140)); + check('A3: handlers map matches definitions', () => + assert.equal(Object.keys(tools.handlers).length, tools.definitions.length)); + check('A4: pagination fetched all 13 pages', () => assert.equal(client.log.foundationRequests, 13)); + check('A5: reserved gf_list_forms never shadowed', () => + assert.equal(tools.definitions.filter((d) => d.name === 'gf_list_forms').length, 0)); + check('A6: duplicate tool names resolved first-wins (no doubles)', () => { + const names = tools.definitions.map((d) => d.name); + assert.equal(new Set(names).size, names.length); + }); + check('A7: full-slug fallback names (gravityboard_*) coexist with gv_*', () => { + assert.ok(tools.definitions.some((d) => d.name.startsWith('gravityboard_'))); + assert.ok(tools.definitions.some((d) => d.name.startsWith('gv_'))); + }); + check('A8: every inputSchema is MCP-valid (object type + plain-object properties)', () => { + for (const d of tools.definitions) { + assert.equal(d.inputSchema.type, 'object', d.name); + assert.ok(isPlainObject(d.inputSchema.properties), `${d.name} properties`); + } + }); + check('A9: descriptor-array schemas gained derived required list', () => { + const sample = tools.definitions.find((d) => d.inputSchema.properties.view_id); + assert.ok(sample, 'no descriptor-array tool found'); + assert.deepEqual(sample.inputSchema.required, ['view_id']); + }); + + // B. Execution wire shapes through real handlers. + const byName = Object.fromEntries(tools.definitions.map((d) => [d.name, d])); + const readonlyName = catalog.find((c) => c.annotations.readonly && byName[c.mcp_tool_name])?.mcp_tool_name; + const deleteName = catalog.find((c) => c.annotations.destructive && c.annotations.idempotent && byName[c.mcp_tool_name])?.mcp_tool_name; + const postName = catalog.find((c) => !c.annotations.readonly && !c.annotations.idempotent && byName[c.mcp_tool_name])?.mcp_tool_name; + + await tools.handlers[readonlyName]({ view: { id: 7, fields: ['a', 'b'] } }); + await tools.handlers[postName]({ title: 'Stress' }); + await tools.handlers[deleteName]({ id: 9 }); + + const [getRun, postRun, deleteRun] = client.log.runs; + check('B1: readonly handler issues GET with bracketed nested params', () => { + assert.equal(getRun.method, 'GET'); + assert.equal(getRun.params['input[view][id]'], 7); + assert.equal(getRun.params['input[view][fields][0]'], 'a'); + }); + check('B2: default handler issues POST with {input} body', () => { + assert.equal(postRun.method, 'POST'); + assert.deepEqual(postRun.data, { input: { title: 'Stress' } }); + }); + check('B3: destructive+idempotent handler issues DELETE', () => + assert.equal(deleteRun.method, 'DELETE')); + + // Throughput: sequential + concurrent handler execution. + const seqStart = performance.now(); + for (let i = 0; i < 5000; i++) await tools.handlers[postName]({ i }); + const seqMs = performance.now() - seqStart; + + const conStart = performance.now(); + await Promise.all(Array.from({ length: 2000 }, (_, i) => tools.handlers[readonlyName]({ i }))); + const conMs = performance.now() - conStart; + + results.push(['INFO', `B4: 5,000 sequential handler calls in ${seqMs.toFixed(0)}ms (${Math.round(5000 / (seqMs / 1000)).toLocaleString()}/s)`]); + results.push(['INFO', `B5: 2,000 concurrent handler calls in ${conMs.toFixed(0)}ms`]); +} + +// C. Repeated full loads (timing + stability). +{ + const ITERATIONS = 25; + const times = []; + const rssBefore = process.memoryUsage().rss; + for (let i = 0; i < ITERATIONS; i++) { + const client = makeClient({ foundation: catalog, core: [] }); + const start = performance.now(); + const tools = await loadAbilitiesAsTools(client, { reservedNames }); + times.push(performance.now() - start); + assert.equal(tools.count, 1140); + } + const rssAfter = process.memoryUsage().rss; + const avg = times.reduce((a, b) => a + b, 0) / times.length; + results.push(['INFO', + `C1: ${ITERATIONS} full loads of 1,205-item catalog — avg ${avg.toFixed(1)}ms, ` + + `min ${Math.min(...times).toFixed(1)}ms, max ${Math.max(...times).toFixed(1)}ms, ` + + `RSS +${((rssAfter - rssBefore) / 1024 / 1024).toFixed(1)}MB`]); + check('C2: load stays under 250ms avg for 1,205 items', () => assert.ok(avg < 250, `${avg.toFixed(1)}ms`)); +} + +// D. WP core fallback (Foundation 403) reads stamped meta.mcp_tool_name. +{ + const core = []; + for (let i = 0; i < 600; i++) core.push(coreAbility(i)); + for (let i = 0; i < 50; i++) core.push(coreAbility(9000 + i, { meta: { some_other_plugin: true } })); + for (let i = 0; i < 10; i++) { + core.push(coreAbility(9500 + i, { meta: { gk_registered_by: 'gravitykit' } })); // no mcp_tool_name + } + + const client = makeClient({ foundation: 'throw', core }); + const tools = await loadAbilitiesAsTools(client, { reservedNames }); + + check('D1: falls back to wp-core source', () => assert.equal(tools.source, 'wp-core')); + check('D2: foreign + unnamed abilities filtered; 600 stamped survive', () => assert.equal(tools.count, 600)); + check('D3: core-path names come from stamped meta.mcp_tool_name', () => + assert.ok(tools.definitions.every((d) => d.name.startsWith('gv_core_op_')))); +} + +// E. Empty world: both catalogs unusable → throws (self-heal contract). +{ + const client = makeClient({ foundation: [], core: [] }); + let threw = false; + try { + await loadAbilitiesAsTools(client, { reservedNames }); + } catch { + threw = true; + } + check('E1: empty Foundation catalog + empty core throws for self-heal retry', () => assert.ok(threw)); +} + +// F. Unit edges already covered elsewhere, asserted here as a canary. +check('F1: methodForAbility contract', () => { + assert.equal(methodForAbility({ readonly: true }), 'GET'); + assert.equal(methodForAbility({ destructive: true, idempotent: true }), 'DELETE'); + assert.equal(methodForAbility({ destructive: true }), 'POST'); + assert.equal(methodForAbility(), 'POST'); +}); +check('F2: normalizeInputSchema never returns array properties', () => { + for (const variant of [null, [], [{ name: 'x' }], { type: 'object', properties: [] }, 'junk', 42]) { + const out = normalizeInputSchema(variant); + assert.equal(out.type, 'object'); + assert.ok(isPlainObject(out.properties)); + } +}); + +// ------------------------------------------------------------------ report + +const failed = results.filter(([s]) => s === 'FAIL'); +console.log('\n=== abilities loader stress test ==='); +for (const [status, label] of results) console.log(`${status.padEnd(5)} ${label}`); +console.log(`\n${results.filter(([s]) => s === 'PASS').length} passed, ${failed.length} failed`); +process.exit(failed.length ? 1 : 0); diff --git a/scripts/verify-tool-names.mjs b/scripts/verify-tool-names.mjs new file mode 100644 index 0000000..ca25a25 --- /dev/null +++ b/scripts/verify-tool-names.mjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** + * Verify that every gf_ / gv_ tool name referenced in prose (server + * instructions, docs, the demo) matches a tool the server actually + * registers. The gv_* surface is generated at runtime from the installed + * GravityView/Foundation Abilities catalog, so a catalog rename can silently + * leave the instructions string or docs pointing at tools that no longer + * exist — exactly the drift that broke demo-abilities.mjs. + * + * Authoritative set: + * - gf_* (static): the `name:` props in src/index.js (GF_TOOL_DEFINITIONS) + * and src/field-operations/index.js (fieldOperationTools) + * - gv_* (dynamic): loaded live from the connected site's catalog + * - gk-/ abilities (any GravityKit product namespace): the live + * catalog (for the demo's ability-name references) + * + * Requires a live WordPress connection (same env as the server): + * GRAVITYKIT_WP_URL + GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD, + * or the GRAVITY_FORMS_* equivalents (see .env.example / AGENTS.md). + * + * Usage: node scripts/verify-tool-names.mjs (or: npm run verify:tool-names) + * Exit: 0 = all references match · 1 = mismatches found or catalog unreachable + */ + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { WordPressClient } from '../src/wp-client.js'; +import { loadAbilitiesAsTools } from '../src/abilities/loader.js'; +import { collectAbilityNames } from './lib/ability-catalog.mjs'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const read = (rel) => readFileSync(join(ROOT, rel), 'utf8'); + +// Tokens that look like tool names in prose but intentionally are not — keep +// this list short and explain each one so it stays honest. +const IGNORE = new Map([ + ['gf_new_tool', 'AGENTS.md "Adding a New Tool" example placeholder'], + ['gv_revision_', 'entry-meta key prefix gv_revision_* (compact-mode docs), not a tool'], +]); + +// --- Authoritative set: exactly what the server registers --- +const grabNames = (rel, re) => [...read(rel).matchAll(re)].map((m) => m[1]); +const gfStatic = new Set([ + ...grabNames('src/index.js', /name:\s*'(gf_[a-z0-9_]+)'/g), + ...grabNames('src/field-operations/index.js', /name:\s*'(gf_[a-z0-9_]+)'/g), +]); + +const wp = new WordPressClient(process.env); +let gvDynamic, abilityNames; +try { + const { definitions } = await loadAbilitiesAsTools(wp); + gvDynamic = new Set(definitions.map((d) => d.name)); + // Walks every page of the paginated WP Abilities catalog (see ability-catalog.mjs). + abilityNames = await collectAbilityNames(wp); +} catch (err) { + console.error(`✗ Could not load the live abilities catalog from ${wp.baseUrl}`); + console.error(` ${err.message}`); + console.error(' Set WP credentials (see AGENTS.md) and point at a running site, then retry.'); + process.exit(1); +} + +const authToolNames = new Set([...gfStatic, ...gvDynamic]); +console.log(`Authoritative: ${gfStatic.size} gf_* + ${gvDynamic.size} gv_* = ${authToolNames.size} tools; ${abilityNames.size} gk-*/* abilities\n`); + +// --- Referenced names per surface --- +// TOOL_RE is intentionally narrow: gf_ (Gravity Forms) and gv_ (GravityView) +// are the only product prefixes that currently surface real tools. Matching +// every `g…_` token would false-positive on prose (e.g. gravityformsaddon_…), +// so extend this set deliberately when a new product registers an mcp_prefix. +const TOOL_RE = /\b(g[fv]_[a-z0-9_]+)\b/g; +// Any GravityKit product ability namespace (gk-gravityview/, gk-multiple-forms/, …). +const ABIL_RE = /\bgk-[a-z0-9-]+\/[a-z0-9-]+/g; + +// The server `instructions` string is what the agent reads — check that line +// specifically rather than the whole file (which also *defines* the tools). +const instrLine = read('src/index.js').split('\n').find((l) => l.includes('instructions:')) || ''; + +const surfaces = [ + ['src/index.js (instructions string)', instrLine, TOOL_RE], + ['demo-abilities.mjs (tool handlers)', read('demo-abilities.mjs'), TOOL_RE], + ['demo-abilities.mjs (ability names)', read('demo-abilities.mjs'), ABIL_RE], + ['AGENTS.md', read('AGENTS.md'), TOOL_RE], + ['README.md', read('README.md'), TOOL_RE], + ['mcp.json', read('mcp.json'), TOOL_RE], + // CLAUDE.md re-exports AGENTS.md (@AGENTS.md) — already covered above. +]; + +let problems = 0; +let ignored = 0; +for (const [label, text, re] of surfaces) { + const isAbil = re === ABIL_RE; + const valid = isAbil ? abilityNames : authToolNames; + const ref = [...new Set([...text.matchAll(re)].map((m) => m[0]))].sort(); + const bad = ref.filter((n) => !valid.has(n) && !IGNORE.has(n)); + ignored += ref.filter((n) => IGNORE.has(n)).length; + console.log(`${label}: ${ref.length} referenced, ${bad.length} unknown`); + if (bad.length) { + problems += bad.length; + bad.forEach((n) => console.log(` ✗ ${n}`)); + } +} + +if (ignored) { + console.log(`\nIgnored ${ignored} known non-tool token(s):`); + for (const [tok, why] of IGNORE) console.log(` • ${tok} — ${why}`); +} + +console.log(`\n${problems === 0 ? '✅ All referenced names match registered MCP tool/ability names' : `❌ ${problems} mismatch(es) — update the docs or the IGNORE list`}`); +process.exit(problems === 0 ? 0 : 1); diff --git a/src/abilities/loader.js b/src/abilities/loader.js new file mode 100644 index 0000000..e409088 --- /dev/null +++ b/src/abilities/loader.js @@ -0,0 +1,450 @@ +/** + * Auto-generate MCP tool definitions from the live WordPress + * Abilities API surface. + * + * Source preference chain (each step falls back to the next): + * + * 1. Foundation catalog — `/wp-json/gravitykit/v1/abilities`. + * The canonical contract: server-side GravityKit filtering + * (`gk_registered_by === 'gravitykit'`), server-owned tool naming + * (`mcp_tool_name`, from each product's required `mcp_prefix` declared + * on Foundation's `gk/foundation/abilities/products` filter, falling + * back to the full product slug), and disabled abilities already + * omitted. + * Any GravityKit product that registers abilities through Foundation + * appears here automatically — no client-side allow-list. + * 2. WP core catalog — `/wp-json/wp-abilities/v1/abilities`. + * For connections whose user can't pass the Foundation catalog's + * permission gate (default manage_options vs core's `read`). + * Filtered client-side on Foundation's stamped metadata: + * `meta.gk_registered_by === 'gravitykit'`. + * 3. When both catalogs are unreachable (older WP without the + * Abilities API, plugin disabled, network blip) this module throws; + * the caller leaves gv_* tools unregistered and retries on the next + * gv_* call (self-healing). + * + * Tool naming is owned by the SERVER on both paths: Foundation's + * `mcp_tool_name` (Manager::get_mcp_tool_name() — declared `mcp_prefix` + * or the full-product-slug fallback), stamped into ability meta so the + * WP core catalog carries it too. + * The client never invents names — abilities arriving without + * `mcp_tool_name` are skipped with a warning, so a naming gap is + * visible instead of silently diverging between connections. + * + * Handlers execute abilities through `/wp-abilities/v1/abilities/{name}/run` + * with the HTTP method derived from the ability's annotations + * (`readonly` → GET, `destructive`+`idempotent` → DELETE, otherwise POST). + */ + +import logger from '../utils/logger.js'; + +/** Foundation's GravityKit-only catalog route (Foundation >= 1.21). */ +export const FOUNDATION_CATALOG_ROUTE = '/wp-json/gravitykit/v1/abilities'; + +/** WP core's all-plugins abilities route (WP 6.9+ / abilities-api). */ +export const CORE_ABILITIES_ROUTE = '/wp-json/wp-abilities/v1/abilities'; + +/** Foundation's ability-name contract: gk-{product}/{action}. */ +const GK_NAME_PATTERN = /^gk-[a-z0-9-]+\//; + +/** + * Determine the HTTP method to use when executing an ability. + * Matches the Abilities API REST controller's contract: + * - readonly → GET + * - destructive + idempotent → DELETE + * - else POST + * + * @param {object} annotations Ability meta.annotations. + * @returns {'GET'|'POST'|'DELETE'} + */ +export function methodForAbility(annotations = {}) { + if (annotations.readonly) return 'GET'; + // Foundation's run controller only accepts DELETE for abilities that + // are BOTH destructive AND idempotent — matching WP-REST conventions + // for HTTP DELETE. Destructive-but-not-idempotent operations (e.g. + // view-delete with `force` defaulting to soft trash) must go through + // POST so their non-idempotent semantics are explicit on the wire. + if (annotations.destructive && annotations.idempotent) return 'DELETE'; + return 'POST'; +} + +/** + * Coerce an ability's `input_schema` payload into a JSON Schema object the + * MCP runtime can validate (`{ type: "object", properties: {...} }`). + * + * Two shapes from the WordPress Abilities API need normalising before they + * hit MCP's Zod validator: + * + * 1. `input_schema` is itself an array — happens when the PHP side returns + * a list of parameter descriptors instead of a schema object. We wrap + * it as `{ type: 'object', properties: {}, required: [] }`, + * pulling each entry's `name` / `slug` / `key` as the property key when + * present. Anonymous entries fall back to `arg`. + * 2. `input_schema.properties` is an array (almost always `[]` from + * PHP serialising an empty associative array as a JSON list). MCP + * expects `properties` to be a `Record` — we + * coerce empty arrays to `{}` and non-empty arrays via the same + * per-entry key derivation as case 1. + * + * Returns a fresh object — never mutates the input. + * + * @param {unknown} raw The `input_schema` value as received from the API. + * @returns {{ type: 'object', properties: object, required?: string[], additionalProperties?: boolean }} + */ +export function normalizeInputSchema(raw) { + // Missing / falsy → open object so the tool is still callable. + if (raw === null || raw === undefined || raw === false) { + return { type: 'object', properties: {}, additionalProperties: true }; + } + + // Shape 1: top-level array of parameter descriptors. + if (Array.isArray(raw)) { + const { properties, required } = arrayToProperties(raw); + const out = { type: 'object', properties }; + if (required.length) out.required = required; + return out; + } + + // Anything that isn't an object at this point is unusable — fall back + // to an open object rather than letting Zod blow up downstream. + if (typeof raw !== 'object') { + return { type: 'object', properties: {}, additionalProperties: true }; + } + + // Shape 2: object whose `properties` is an array (PHP-serialised empty + // assoc array, or a list of descriptors). Normalise it but keep every + // other key the upstream provided (e.g. `required`, `additionalProperties`, + // `description`, custom `$schema` extensions). + const out = { ...raw }; + if (out.type !== 'object') out.type = 'object'; + + if (Array.isArray(out.properties)) { + const { properties, required } = arrayToProperties(out.properties); + out.properties = properties; + if (required.length && !Array.isArray(out.required)) { + out.required = required; + } + } else if (out.properties === null || out.properties === undefined) { + out.properties = {}; + } else if (typeof out.properties !== 'object') { + out.properties = {}; + } + + return out; +} + +/** + * Convert a list of parameter descriptors into a `properties` map + + * `required` list. Each entry contributes one property; the key is + * derived from `name` / `slug` / `key` / `title` (in that order), or + * `arg` for anonymous entries. The descriptor is copied as the + * value, with the chosen identifier key stripped so it doesn't double + * as both the map key and a redundant schema field. An entry's + * `required: true` (or string "true") lifts the property into the + * outer `required` array — JSON Schema requires it there, not per-prop. + */ +function arrayToProperties(arr) { + const properties = {}; + const required = []; + arr.forEach((entry, i) => { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) { + // Non-object entries can't be expressed as a JSON Schema property; + // skip rather than fabricate a placeholder of unknown intent. + return; + } + const key = entry.name || entry.slug || entry.key || entry.title || `arg${i}`; + const { name: _n, slug: _s, key: _k, required: req, ...rest } = entry; + properties[key] = rest; + if (req === true || req === 'true') required.push(key); + }); + return { properties, required }; +} + +/** + * Fetch the abilities surface + build MCP tool definitions and handlers. + * + * Tries the Foundation catalog first (canonical naming + filtering), + * falls back to the WP core catalog. Throws only when BOTH are + * unreachable — the caller leaves gv_* tools unregistered and retries + * on a later call. + * + * @param {object} wpClient WordPressClient instance — uses its + * authenticated httpClient. + * @param {object} [options] + * @param {Set} [options.reservedNames] Tool names owned by the + * built-in (static) tool set — e.g. the released gf_* contract. + * Catalog abilities resolving to a reserved name are skipped with a + * warning so the dynamic pipeline can never shadow a shipped tool. + * @returns {Promise<{ definitions: object[], handlers: Record, count: number, source: 'foundation-catalog'|'wp-core' }>} + */ +export async function loadAbilitiesAsTools(wpClient, { reservedNames } = {}) { + try { + const items = await fetchFoundationCatalogItems(wpClient); + const entries = catalogItemsToEntries(items); + + if (entries.length > 0) { + return buildTools(wpClient, entries, 'foundation-catalog', reservedNames); + } + + logger.warn(`Foundation catalog at ${FOUNDATION_CATALOG_ROUTE} returned no usable abilities — falling back to WP core catalog`); + } catch (err) { + logger.warn(`Foundation catalog unavailable (${err.message}) — falling back to WP core catalog at ${CORE_ABILITIES_ROUTE}`); + } + + const entries = await fetchCoreEntries(wpClient); + return buildTools(wpClient, entries, 'wp-core', reservedNames); +} + +/** + * Fetch every page of the Foundation GravityKit catalog. + * + * Pagination per the Foundation contract: `page`/`per_page` params, + * `X-WP-TotalPages` response header. MAX_PAGES is a runaway guard, not + * a coverage cap — at 100 items/page it allows 2,000 abilities. + * + * @param {object} wpClient WordPressClient instance. + * @returns {Promise} Catalog items (Manager::to_rest_item() shape). + */ +async function fetchFoundationCatalogItems(wpClient) { + const PER_PAGE = 100; + const MAX_PAGES = 20; + const items = []; + + let page = 1; + let totalPages = 1; + + do { + // Explicit baseURL per request keeps this correct even when a + // subclass mounts a namespaced httpClient (same auth + TLS). + const response = await wpClient.httpClient.request({ + method: 'GET', + baseURL: wpClient.baseUrl, + url: FOUNDATION_CATALOG_ROUTE, + params: { per_page: PER_PAGE, page }, + }); + + if (!Array.isArray(response.data)) { + throw new Error('Unexpected Foundation catalog shape — expected array.'); + } + + items.push(...response.data); + + const headerTotal = Number(response.headers?.['x-wp-totalpages']); + totalPages = Number.isFinite(headerTotal) && headerTotal > 0 ? Math.min(headerTotal, MAX_PAGES) : 1; + page += 1; + } while (page <= totalPages); + + return items; +} + +/** + * Map Foundation catalog items (Manager::to_rest_item() shape) to the + * internal tool-entry shape. The catalog is already GravityKit-only and + * omits disabled abilities by default; the name-pattern and `enabled` + * checks here are defensive only. Items without `mcp_tool_name` are + * skipped — the server owns naming, the client never derives. + * + * @param {object[]} items Foundation catalog items. + * @returns {Array<{abilityName: string, toolName: string, description: string, rawInputSchema: unknown, annotations: object}>} + */ +function catalogItemsToEntries(items) { + const entries = []; + + for (const item of items) { + if (typeof item?.name !== 'string' || !GK_NAME_PATTERN.test(item.name)) continue; + if (item.enabled === false) continue; + if (typeof item.mcp_tool_name !== 'string' || item.mcp_tool_name === '') { + logger.warn(`Ability ${item.name} has no mcp_tool_name — skipped (the server owns tool naming)`); + continue; + } + + entries.push({ + abilityName: item.name, + toolName: item.mcp_tool_name, + description: item.description || item.label || item.name, + rawInputSchema: item.input_schema, + annotations: item.annotations && typeof item.annotations === 'object' ? item.annotations : {}, + }); + } + + return entries; +} + +/** + * Fetch the WP core abilities catalog and filter to GravityKit abilities. + * + * Filters on Foundation's stamped metadata + * (`meta.gk_registered_by === 'gravitykit'`) — the documented + * cross-product contract ("filter on these keys rather than parsing + * names"). Naming requires `meta.mcp_tool_name`; abilities without it + * are skipped with a warning (the server owns naming). + * + * Throws when no usable abilities are found so the caller's state stays + * null (not sticky-empty) and the per-call self-heal keeps retrying. + * + * @param {object} wpClient WordPressClient instance. + * @returns {Promise>} + */ +async function fetchCoreEntries(wpClient) { + const { data } = await wpClient.httpClient.request({ + method: 'GET', + baseURL: wpClient.baseUrl, + url: CORE_ABILITIES_ROUTE, + }); + + if (!Array.isArray(data)) { + throw new Error('Unexpected Abilities API catalog shape — expected array.'); + } + + const entries = []; + + for (const ability of data) { + if (typeof ability?.name !== 'string') continue; + + const meta = ability.meta && typeof ability.meta === 'object' ? ability.meta : {}; + if (meta.gk_registered_by !== 'gravitykit') continue; + + if (typeof meta.mcp_tool_name !== 'string' || meta.mcp_tool_name === '') { + logger.warn(`Ability ${ability.name} has no meta.mcp_tool_name — skipped (the server owns tool naming)`); + continue; + } + + entries.push({ + abilityName: ability.name, + toolName: meta.mcp_tool_name, + description: ability.description || ability.label || ability.name, + rawInputSchema: ability.input_schema, + annotations: meta.annotations && typeof meta.annotations === 'object' ? meta.annotations : {}, + }); + } + + if (entries.length === 0) { + throw new Error('No usable GravityKit abilities in the WP core catalog (missing gk_registered_by stamp or mcp_tool_name).'); + } + + return entries; +} + +/** + * Build MCP tool definitions + handlers from normalized entries. + * + * Collision guard: with naming delegated to the server and filtering no + * longer namespace-bound, two abilities could map to one tool name. The + * first wins; later collisions are logged and skipped — never silently + * shadowed. + * + * @param {object} wpClient WordPressClient instance. + * @param {Array} entries Normalized tool entries. + * @param {string} source Which catalog produced the entries. + * @param {Set} [reservedNames] Names owned by the built-in tool set. + * @returns {{ definitions: object[], handlers: Record, count: number, source: string }} + */ +function buildTools(wpClient, entries, source, reservedNames) { + const definitions = []; + const handlers = {}; + const claimedBy = new Map(); + + if (reservedNames) { + for (const name of reservedNames) { + claimedBy.set(name, 'a built-in tool'); + } + } + + for (const entry of entries) { + const existing = claimedBy.get(entry.toolName); + if (existing) { + logger.warn(`Tool-name collision: "${entry.toolName}" from ${entry.abilityName} clashes with ${existing} — skipping ${entry.abilityName}`); + continue; + } + claimedBy.set(entry.toolName, entry.abilityName); + + // MCP tool definition. `normalizeInputSchema()` guarantees the + // shape MCP's Zod validator expects: + // `{ type: 'object', properties: >, … }`. + // Without it, abilities whose PHP serialisation produced an array + // (top-level or under `properties`) fail `tools/list` validation — + // see the helper's docblock for the two shapes we coerce. + definitions.push({ + name: entry.toolName, + description: entry.description, + inputSchema: normalizeInputSchema(entry.rawInputSchema), + }); + + // Closure captures the ability name + method so the dispatcher + // doesn't need to re-resolve them at call time. Destructive + // gating lives server-side: each ability's permission_callback + // (e.g. delete_post for view-delete) plus Foundation's + // per-ability enable/disable toggles. + const abilityName = entry.abilityName; + const method = methodForAbility(entry.annotations); + handlers[entry.toolName] = async (params) => executeAbility(wpClient, abilityName, method, params || {}); + } + + return { definitions, handlers, count: definitions.length, source }; +} + +/** + * Execute one ability via `/wp-abilities/v1/abilities/{name}/run`. + * + * Encoding rules per the Abilities API spec: + * - GET / DELETE: input rides on bracketed query params + * - POST: input rides in the JSON body as `{input: ...}` + * + * Errors propagate verbatim from the server so the MCP runtime can + * surface them (the abilities-api's `WP_Error` codes — `ability_invalid_input`, + * `ability_invalid_permissions`, `rest_ability_invalid_method`, etc. — + * already carry enough detail for an agent to self-correct). + */ +/** + * Recursively expand a nested input object into bracket-notation + * query params: `input[key]=val`, `input[key][nested]=val`, etc. + * Mirrors how WordPress REST rebuilds an object from query strings, + * which is the wire shape readonly abilities expect. + */ +function walkInputToBracketedParams(value, key, out) { + if (value === undefined || value === null) return; + if (Array.isArray(value)) { + value.forEach((item, i) => walkInputToBracketedParams(item, `${key}[${i}]`, out)); + return; + } + if (typeof value === 'object') { + for (const [k, v] of Object.entries(value)) { + walkInputToBracketedParams(v, `${key}[${k}]`, out); + } + return; + } + out[key] = value; +} + +async function executeAbility(wpClient, abilityName, method, input) { + // Explicit baseURL so the URL resolves at the WP root regardless + // of how the client instance is namespaced. + const baseURL = wpClient.baseUrl; + const url = `/wp-json/wp-abilities/v1/abilities/${abilityName}/run`; + + if (method === 'GET' || method === 'DELETE') { + // WordPress REST takes bracketed query params for object-typed + // args, NOT a JSON-stringified `?input=` value (the controller + // hands the raw string straight to the schema validator, which + // then complains "input is not of type object"). Recursively + // expand the input into `input[key][nested]=value` so WP + // rehydrates the nested object structure. + const config = { method, baseURL, url }; + if (input && Object.keys(input).length > 0) { + const params = {}; + walkInputToBracketedParams(input, 'input', params); + config.params = params; + } + const { data } = await wpClient.httpClient.request(config); + return data; + } + + // POST. The Abilities API wraps input under an `input` key in the body. + const { data } = await wpClient.httpClient.request({ + method, + baseURL, + url, + data: input && Object.keys(input).length > 0 ? { input } : {}, + }); + return data; +} diff --git a/src/config/auth.js b/src/config/auth.js index e40f958..cfa5d7e 100644 --- a/src/config/auth.js +++ b/src/config/auth.js @@ -9,19 +9,41 @@ import crypto from 'crypto'; import logger from '../utils/logger.js'; +/** + * True for URLs whose traffic never leaves the machine / LAN dev box: + * localhost, *.localhost, 127.0.0.0/8, [::1], and the conventional dev + * TLDs *.test and *.local. Same idea WordPress core applies when it + * allows application passwords without HTTPS in local environments. + */ +export function isLocalUrl(baseUrl) { + try { + const { hostname } = new URL(baseUrl); + return hostname === 'localhost' + || hostname === '[::1]' + || hostname === '::1' + || hostname.endsWith('.localhost') + || hostname.startsWith('127.') + || hostname.endsWith('.test') + || hostname.endsWith('.local'); + } catch { + return false; + } +} + /** * Basic Authentication Handler (PRIMARY METHOD) - * Simple and secure authentication using Consumer Key/Secret over HTTPS - * Recommended for Gravity Forms v2 REST API + * Simple authentication using Consumer Key/Secret. Requires HTTPS for + * remote hosts; allowed over plain HTTP for local URLs or when the + * caller explicitly opts in (credentials ride base64-encoded on every + * request, so an untrusted network must not see them in the clear). */ export class BasicAuthHandler { - constructor(consumerKey, consumerSecret, baseUrl) { + constructor(consumerKey, consumerSecret, baseUrl, { allowHttp = false } = {}) { this.consumerKey = consumerKey; this.consumerSecret = consumerSecret; this.baseUrl = baseUrl; - // Validate HTTPS for Basic Auth security - if (!this.baseUrl.startsWith('https://')) { + if (!this.baseUrl.startsWith('https://') && !allowHttp) { throw new Error('Basic Authentication requires HTTPS connection for security'); } } @@ -69,6 +91,44 @@ export class BasicAuthHandler { } } +/** + * Strict RFC 3986 percent-encoding (OAuth 1.0a requires it; WordPress + * verifies signatures with PHP's rawurlencode, which also encodes + * !'()* — encodeURIComponent alone leaves them bare and the + * signatures diverge). + */ +export function rfc3986Encode(value) { + return encodeURIComponent(value).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase()); +} + +/** + * Flatten nested params into the bracket-index pairs PHP parses them + * back into: { include: [3, 5] } → [['include[0]','3'], ['include[1]','5']], + * { paging: { page_size: 2 } } → [['paging[page_size]','2']]. + * + * Both the OAuth signature base AND the wire serializer use this, so + * what we sign is byte-for-byte what Gravity Forms' server-side + * signature check reconstructs from $_GET. (The released 2.1.1 bug: + * the signature stringified arrays as "3" while axios sent include[]=3 + * — every OAuth GET with array params failed with invalid signature.) + */ +export function flattenParams(params, prefix = '') { + const pairs = []; + if (params === null || params === undefined) return pairs; + + for (const [key, value] of Object.entries(params)) { + if (value === null || value === undefined) continue; + const name = prefix ? `${prefix}[${key}]` : key; + + if (Array.isArray(value) || (typeof value === 'object')) { + pairs.push(...flattenParams(value, name)); + } else { + pairs.push([name, String(value)]); + } + } + return pairs; +} + /** * OAuth 1.0a Authentication Handler (SECONDARY METHOD) * More complex but provides additional security features @@ -91,31 +151,34 @@ export class OAuth1Handler { throw new Error('Invalid OAuth parameters: method, url, timestamp, and nonce are required'); } - // Combine all parameters - const allParams = { - ...params, - oauth_consumer_key: this.consumerKey, - oauth_timestamp: timestamp, - oauth_nonce: nonce, - oauth_signature_method: 'HMAC-SHA1', - oauth_version: '1.0' - }; + // Flatten request params to the same bracket-index pairs PHP will + // parse from the query string, then add the oauth_* protocol params. + const pairs = [ + ...flattenParams(params), + ['oauth_consumer_key', this.consumerKey], + ['oauth_timestamp', timestamp], + ['oauth_nonce', nonce], + ['oauth_signature_method', 'HMAC-SHA1'], + ['oauth_version', '1.0'], + ]; - // Create parameter string - const paramString = Object.keys(allParams) - .sort() - .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(allParams[key])}`) + // RFC 5849 §3.4.1.3.2: encode first, then sort by encoded name + // (ties broken by encoded value), then join. + const paramString = pairs + .map(([key, value]) => [rfc3986Encode(key), rfc3986Encode(value)]) + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)) + .map((pair) => pair.join('=')) .join('&'); // Create signature base string const baseString = [ method.toUpperCase(), - encodeURIComponent(url), - encodeURIComponent(paramString) + rfc3986Encode(url), + rfc3986Encode(paramString) ].join('&'); // Create signing key - const signingKey = `${encodeURIComponent(this.consumerSecret)}&`; + const signingKey = `${rfc3986Encode(this.consumerSecret)}&`; // Generate signature const signature = crypto @@ -237,14 +300,51 @@ export class AuthManager { GRAVITY_FORMS_BASE_URL ); } else { - if (this.config.GRAVITY_FORMS_DEBUG === 'true') { - logger.info('🔐 Using Basic Authentication (Recommended for Gravity Forms v2)'); + const isHttps = GRAVITY_FORMS_BASE_URL.startsWith('https://'); + const explicitBasic = this.config.GRAVITY_FORMS_AUTH_METHOD?.toLowerCase() === 'basic'; + // GF's own key pairs are always ck_/cs_-prefixed (GFWebAPI + // rand_hash generator). The GF server only CHECKS key-pair Basic + // auth over SSL (class-gf-rest-authentication.php: if (is_ssl())), + // so on plain HTTP a key pair must sign with OAuth 1.0a — Basic + // would silently authenticate as nobody. WordPress application + // passwords (username + app password) authenticate through WP + // core instead and DO work over Basic on local HTTP. + const isGfKeyPair = /^ck_/.test(GRAVITY_FORMS_CONSUMER_KEY || '') + && /^cs_/.test(GRAVITY_FORMS_CONSUMER_SECRET || ''); + + if (!isHttps && isGfKeyPair && !explicitBasic) { + if (this.config.GRAVITY_FORMS_DEBUG === 'true') { + logger.info('🔐 Consumer key pair over HTTP — using OAuth 1.0a (Gravity Forms only accepts key-pair Basic auth over HTTPS)'); + } + this.authHandler = new OAuth1Handler( + GRAVITY_FORMS_CONSUMER_KEY, + GRAVITY_FORMS_CONSUMER_SECRET, + GRAVITY_FORMS_BASE_URL + ); + } else { + if (this.config.GRAVITY_FORMS_DEBUG === 'true') { + logger.info('🔐 Using Basic Authentication (Recommended for Gravity Forms v2)'); + } + // Basic over plain HTTP is allowed when the traffic can't + // leave the machine (localhost / *.test / *.local), when the + // user explicitly chose basic, or via the env opt-in — the + // silent default on a remote http:// URL still falls back to + // OAuth below. + const allowHttp = isLocalUrl(GRAVITY_FORMS_BASE_URL) + || explicitBasic + || this.config.GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH === 'true'; + + if (allowHttp && !isHttps && !isLocalUrl(GRAVITY_FORMS_BASE_URL)) { + logger.warn('⚠️ Basic Authentication over plain HTTP to a remote host — credentials are visible to the network. Use HTTPS when possible.'); + } + + this.authHandler = new BasicAuthHandler( + GRAVITY_FORMS_CONSUMER_KEY, + GRAVITY_FORMS_CONSUMER_SECRET, + GRAVITY_FORMS_BASE_URL, + { allowHttp } + ); } - this.authHandler = new BasicAuthHandler( - GRAVITY_FORMS_CONSUMER_KEY, - GRAVITY_FORMS_CONSUMER_SECRET, - GRAVITY_FORMS_BASE_URL - ); } } catch (error) { // Fallback to OAuth if Basic Auth fails (e.g., HTTP instead of HTTPS) diff --git a/src/field-operations/field-manager.js b/src/field-operations/field-manager.js index 707bb15..a82c541 100644 --- a/src/field-operations/field-manager.js +++ b/src/field-operations/field-manager.js @@ -3,6 +3,8 @@ * Handles field CRUD operations within REST API v2 constraints */ +import { createHash } from 'crypto'; + export class FieldManager { constructor(apiClient, fieldRegistry, validator) { this.api = apiClient; @@ -40,6 +42,9 @@ export class FieldManager { if (fieldDef.storage?.type === 'compound') { field.inputs = this.generateSubInputs(field, fieldDef); } + + // 5b. Normalize layout grid properties (layoutGroupId, layoutGridColumnSpan) + this.normalizeLayoutProperties(field, formId); // 6. Calculate insertion position (page-aware) const insertIndex = this.positionEngine?.calculatePosition( @@ -91,6 +96,7 @@ export class FieldManager { ...updates, id: originalField.id // Preserve ID }; + this.normalizeLayoutProperties(form.fields[fieldIndex], formId); // Replace form via direct PUT (no re-fetch — we already have the full state) const result = await this.api.replaceForm(formId, form); @@ -200,6 +206,46 @@ export class FieldManager { }; } + /** + * Normalize layout grid properties to the editor's storage format. + * + * Mirrors the server-side normalization Gravity Forms ships in its + * abilities API (GF_Abilities_Handler_Forms::normalize_layout_group_ids): + * the editor stores layoutGroupId as an 8-char lowercase hex string, but + * agents naturally write friendly names like "row1" or "name-row". + * Friendly names hash to a stable 8-char hex per form, so the same name + * passed to later calls lands the field in the same row (GF salts per + * request because it normalizes a whole form at once; we normalize one + * field per call, so determinism is what makes row-sharing work). + * + * layoutGridColumnSpan is clamped to the editor's 1-12 grid; non-numeric + * values are dropped so the editor assigns its own span. + * + * Mutates and returns the field. + */ + normalizeLayoutProperties(field, formId) { + if (typeof field.layoutGridColumnSpan !== 'undefined') { + const raw = field.layoutGridColumnSpan; + // Accept only true integers / integer strings — Number() (not parseInt) + // so "6.5" and "6wide" become NaN instead of being truncated to 6, and + // empty/whitespace strings are rejected rather than coerced to 0. + const numeric = typeof raw === 'number' || (typeof raw === 'string' && raw.trim() !== ''); + const span = numeric ? Number(raw) : NaN; + if (Number.isInteger(span)) { + field.layoutGridColumnSpan = Math.min(12, Math.max(1, span)); + } else { + delete field.layoutGridColumnSpan; + } + } + + const groupId = field.layoutGroupId; + if (typeof groupId === 'string' && groupId !== '' && !/^[0-9a-f]{8}$/.test(groupId)) { + field.layoutGroupId = createHash('md5').update(`${formId}:${groupId}`).digest('hex').slice(0, 8); + } + + return field; + } + /** * Generate compound sub-inputs (address.1, name.3, etc.) */ diff --git a/src/gravity-forms-client.js b/src/gravity-forms-client.js index b28ffd7..901fb17 100644 --- a/src/gravity-forms-client.js +++ b/src/gravity-forms-client.js @@ -6,13 +6,14 @@ import axios from 'axios'; import https from 'https'; -import { AuthManager, validateRestApiAccess } from './config/auth.js'; +import { AuthManager, validateRestApiAccess, flattenParams, rfc3986Encode } from './config/auth.js'; import { ValidationFactory } from './config/validation.js'; import logger from './utils/logger.js'; import { sanitizeUrl, sanitizeHeaders } from './utils/sanitize.js'; import { generateCompoundInputs } from './field-definitions/field-registry.js'; import { testConfig } from './config/test-config.js'; import { resourceMutex } from './utils/mutex.js'; +import { USER_AGENT } from './version.js'; export class GravityFormsClient { constructor(config) { @@ -25,13 +26,24 @@ export class GravityFormsClient { baseURL: this.baseURL, timeout: parseInt(config.GRAVITY_FORMS_TIMEOUT) || 30000, headers: { - 'User-Agent': 'GravityKit-MCP/2.1.0', + 'User-Agent': USER_AGENT, 'Accept': 'application/json' }, + // Serialize query params as explicit bracket-index pairs + // (include[0]=3, paging[page_size]=2) — the exact pairs + // flattenParams() feeds the OAuth signature, so the signed + // string and the wire string can never diverge. PHP parses + // either bracket style identically, so Basic-auth requests + // are unaffected. + paramsSerializer: { + serialize: (params) => flattenParams(params) + .map(([key, value]) => `${rfc3986Encode(key)}=${rfc3986Encode(value)}`) + .join('&'), + }, // Allow self-signed certificates for local development - // Set MCP_ALLOW_SELF_SIGNED_CERTS=true in .env for local dev environments + // Set GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS=true in .env for local dev environments httpsAgent: new https.Agent({ - rejectUnauthorized: config.MCP_ALLOW_SELF_SIGNED_CERTS !== 'true' + rejectUnauthorized: (config.GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS || config.MCP_ALLOW_SELF_SIGNED_CERTS) !== 'true' }) }); @@ -642,8 +654,15 @@ export class GravityFormsClient { return this.validateAndCall('gf_list_feeds', params, async (validated) => { const response = await this.httpClient.get('/feeds', { params: validated }); + // Gravity Forms signals "zero feeds" as a serialized WP_Error with + // HTTP 200 (not_found = none match; missing_table = no feed add-on has + // ever run). Normalize any HTTP-200 WP_Error to [] so callers always + // get an array; real failures arrive as non-200 and throw before here. + const data = response.data; + const isEmptyWpError = data && !Array.isArray(data) && !!data.errors; + return { - feeds: response.data + feeds: isEmptyWpError ? [] : data }; }); } diff --git a/src/gravityview/inspector-client.js b/src/gravityview/inspector-client.js new file mode 100644 index 0000000..ae35c0c --- /dev/null +++ b/src/gravityview/inspector-client.js @@ -0,0 +1,604 @@ +/** + * GravityView Inspector REST API Client. + * + * Wraps the `/wp-json/gravityview/v1/...` endpoints exposed by the + * GravityView plugin's Inspector route family (see + * `src/REST/InspectorRoute.php` in the GravityView codebase). Those + * routes are registered ONLY when `DOING_GRAVITYVIEW_TESTS` is defined + * server-side — this client is the integration-test and demo harness, + * not a runtime dependency. Runtime gv_* tools come from the abilities + * loader (`src/abilities/loader.js`) riding the base WordPressClient. + * + * Authentication, base-URL resolution, TLS, and timeouts come from + * WordPressClient (`src/wp-client.js`); this subclass mounts the + * gravityview/v1 namespace on top. + * + * Concurrency: every config write supports `If-Match: ""` + * for optimistic-concurrency. Reads return the version in the body + * AND in an ETag response header. The client keeps a per-view-id + * version cache so callers can do `client.applyConfig(id, payload, + * { ifMatch: 'auto' })` without juggling ETags by hand. + */ + +import logger from '../utils/logger.js'; +import { WordPressClient } from '../wp-client.js'; + +export class GravityViewInspectorClient extends WordPressClient { + constructor(config) { + super(config); + + this.restNamespace = '/wp-json/gravityview/v1'; + this.httpClient = this.createHttpClient(`${this.baseUrl}${this.restNamespace}`); + + // version cache keyed by view id — populated by every read so + // callers can opt into automatic If-Match without a manual GET. + this.versionCache = new Map(); + } + + /** + * Ping the layouts endpoint to verify credentials + connectivity. + * Cheap (no view id required, server returns the registered layout + * engines which is a short list). + */ + async testConnection() { + try { + const response = await this.httpClient.get('/layouts'); + return { + success: true, + layoutCount: Array.isArray(response.data?.layouts) ? response.data.layouts.length : 0, + baseUrl: `${this.baseUrl}${this.restNamespace}`, + }; + } catch (error) { + return { + success: false, + status: error.response?.status, + error: error.response?.data?.message || error.message, + }; + } + } + + // =================================================================== + // Discovery (no view id needed) + // =================================================================== + + async listLayouts() { + const { data } = await this.httpClient.get('/layouts'); + return data; + } + + /** + * GET /templates/{template_id}/settings-schema — discover every + * setting available for a given template. Returns the same flat + * `[{slug, type, label, value, options, group}, ...]` shape used + * by field-type schemas, so a single client renderer covers both. + * + * Settings from add-ons that bridge their silo meta keys (e.g. + * DataTables under prefix `datatables.*`) appear with dotted + * slugs. Writing a dotted-slug key back through PATCH /apply or + * /template-settings routes the value to the right meta key + * automatically. + */ + async getTemplateSettingsSchema({ template_id } = {}) { + if (!template_id || typeof template_id !== 'string') { + throw new Error('template_id (string) is required.'); + } + const { data } = await this.httpClient.get(`/templates/${encodeURIComponent(template_id)}/settings-schema`); + return data; + } + + async listWidgets() { + const { data } = await this.httpClient.get('/widgets'); + return data; + } + + async listGridRowTypes() { + const { data } = await this.httpClient.get('/grid/row-types'); + return data; + } + + async listWidgetZones() { + const { data } = await this.httpClient.get('/widget-zones'); + return data; + } + + async listSearchZones() { + const { data } = await this.httpClient.get('/search-zones'); + return data; + } + + /** + * Canonical search-field input slugs. Used by the MCP validator + * (and assertSearchInputType pre-flight) to reject typos. + * + * Now delegates to the gk-gravityview/search-input-types-list + * ability — the legacy `/gravityview/v1/search-fields/input-types` + * route is gone post-Phase-5. + */ + async listSearchFieldInputTypes() { + const { data } = await this.httpClient.request({ + method: 'GET', + baseURL: this.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/search-input-types-list/run', + }); + return data; + } + + async listForms() { + const { data } = await this.httpClient.get('/forms'); + return data; + } + + async getFieldTypeSchema({ field_type, template_id, context, input_type, form_id } = {}) { + if (!field_type) throw new Error('field_type is required.'); + // Delegate to the gk-gravityview/field-type-schema-get ability. + // Bracketed query params are how WP REST rebuilds an object from + // a query string — `?input[field_type]=text&input[template_id]=...`. + const params = {}; + for (const [k, v] of Object.entries({ field_type, template_id, context, input_type, form_id })) { + if (v !== undefined) params[`input[${k}]`] = v; + } + const { data } = await this.httpClient.request({ + method: 'GET', + baseURL: this.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/field-type-schema-get/run', + params, + }); + return data; + } + + // =================================================================== + // Reads (per view) + // =================================================================== + + async getViewConfig({ id } = {}) { + requireViewId(id); + const response = await this.httpClient.get(`/views/${id}/config`); + this.cacheVersion(id, response); + return response.data; + } + + async getViewAreas({ id } = {}) { + requireViewId(id); + const { data } = await this.httpClient.get(`/views/${id}/areas`); + return data; + } + + async listAvailableFields({ id } = {}) { + requireViewId(id); + const { data } = await this.httpClient.request({ + method: 'GET', + baseURL: this.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities/gk-gravityview/available-fields-get/run', + params: { 'input[id]': id }, + }); + return data; + } + + async getViewFieldSchemas({ id } = {}) { + requireViewId(id); + const { data } = await this.httpClient.get(`/views/${id}/field-settings-schema`); + return data; + } + + async getFieldSettingsSchema({ id, area, slot } = {}) { + requireViewId(id); + requireAreaSlot(area, slot); + const { data } = await this.httpClient.get( + `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}/settings-schema` + ); + return data; + } + + async renderViewField({ id, area, slot, settings, staged_slot } = {}) { + requireViewId(id); + requireAreaSlot(area, slot); + // Server accepts both GET and POST. + // + // POST when the caller needs to ride staged state along WITHOUT + // persisting: + // - `settings` → overrides on an EXISTING saved slot. + // - `staged_slot` → synthesizes a brand-new (unsaved) slot + // from `{ field_id, label?, ...settings }`. + // Required when the URL `slot` doesn't yet + // exist in storage — without it the server + // 404s on read_slot(). + if ( + (settings && typeof settings === 'object') || + (staged_slot && typeof staged_slot === 'object') + ) { + const body = {}; + if (settings && typeof settings === 'object') body.settings = settings; + if (staged_slot && typeof staged_slot === 'object') body.staged_slot = staged_slot; + const { data } = await this.httpClient.post( + `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}/render`, + body + ); + return data; + } + const { data } = await this.httpClient.get( + `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}/render` + ); + return data; + } + + // =================================================================== + // Create + // =================================================================== + + async createView({ + title, form_id, template_id, template_ids, status, + template_settings, search_criteria, fields, widgets, mode, + } = {}) { + if (!title || typeof title !== 'string') throw new Error('title is required.'); + if (!Number.isInteger(form_id) || form_id <= 0) throw new Error('form_id (positive integer) is required.'); + const payload = stripUndefined({ + title, + form_id, + template_id, + template_ids, + status, + template_settings, + search_criteria, + fields, + widgets, + mode, + }); + const response = await this.httpClient.post('/views', payload); + this.cacheVersion(response.data?.view_id ?? response.data?.id, response); + return response.data; + } + + // =================================================================== + // Bulk apply + // =================================================================== + + async applyViewConfig({ id, template_id, template_ids, template_settings, search_criteria, fields, widgets, mode, ifMatch } = {}) { + requireViewId(id); + const payload = stripUndefined({ template_id, template_ids, template_settings, search_criteria, fields, widgets, mode }); + const response = await this.httpClient.post(`/views/${id}/config/_apply`, payload, this.ifMatchHeaders(id, ifMatch)); + this.cacheVersion(id, response); + return response.data; + } + + // =================================================================== + // Surgical writes — settings + template + // =================================================================== + + async setViewTemplate({ id, template_id, zone, policy, ifMatch } = {}) { + requireViewId(id); + if (!template_id) throw new Error('template_id is required.'); + const payload = stripUndefined({ template_id, zone, policy }); + const response = await this.httpClient.patch(`/views/${id}/template`, payload, this.ifMatchHeaders(id, ifMatch)); + this.cacheVersion(id, response); + return response.data; + } + + async patchViewSettings({ id, template_settings, ifMatch } = {}) { + requireViewId(id); + if (!template_settings || typeof template_settings !== 'object') { + throw new Error('template_settings (object) is required.'); + } + const response = await this.httpClient.patch(`/views/${id}/template-settings`, template_settings, this.ifMatchHeaders(id, ifMatch)); + this.cacheVersion(id, response); + return response.data; + } + + async patchViewSearchCriteria({ id, search_criteria, ifMatch } = {}) { + requireViewId(id); + if (!search_criteria || typeof search_criteria !== 'object') { + throw new Error('search_criteria (object) is required.'); + } + const response = await this.httpClient.patch(`/views/${id}/search-criteria`, search_criteria, this.ifMatchHeaders(id, ifMatch)); + this.cacheVersion(id, response); + return response.data; + } + + // =================================================================== + // Surgical writes — fields + // =================================================================== + + async addViewField({ id, area, field, ifMatch } = {}) { + requireViewId(id); + if (!area) throw new Error('area is required.'); + if (!field || typeof field !== 'object') throw new Error('field (object) is required.'); + if (!field.field_id) throw new Error('field.field_id is required.'); + const response = await this.httpClient.post( + `/views/${id}/fields/${encodeArea(area)}/_slots`, + field, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + async patchViewField({ id, area, slot, settings, ifMatch } = {}) { + requireViewId(id); + requireAreaSlot(area, slot); + if (!settings || typeof settings !== 'object') { + throw new Error('settings (object) is required.'); + } + const response = await this.httpClient.patch( + `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}`, + settings, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + async moveViewField({ id, from, to, position, ifMatch } = {}) { + requireViewId(id); + if (!from?.area || !from?.slot) throw new Error('from { area, slot } is required.'); + if (!to?.area) throw new Error('to { area } is required.'); + // `to` may carry before_slot / after_slot for ref-relative + // placement; the server resolves precedence (before > after > + // position). `position` accepts "start" | "end" | integer. + const payload = stripUndefined({ from, to, position }); + const response = await this.httpClient.post(`/views/${id}/fields/_move`, payload, this.ifMatchHeaders(id, ifMatch)); + this.cacheVersion(id, response); + return response.data; + } + + async removeViewField({ id, area, slot, ifMatch } = {}) { + requireViewId(id); + requireAreaSlot(area, slot); + const response = await this.httpClient.delete( + `/views/${id}/fields/${encodeArea(area)}/${encodeURIComponent(slot)}`, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + // =================================================================== + // Grid (Layout Builder) row CRUD + // =================================================================== + + /** + * Add a Layout Builder grid row. + * + * @param {object} params + * @param {number} params.id View id. + * @param {string} [params.type='100'] Row type — see lookup_grid_row_type + * on the server. Common values: "100", "50/50", "33/66", "66/33", + * "33/33/33", "25/25/25/25", "25/25/50", "25/50/25", "50/25/25". + * @param {string[]} [params.zones] Zones to materialise the row in. + * Defaults server-side to ["directory","single","edit"]. + */ + async addGridRow({ id, surface, type, zones, ifMatch } = {}) { + requireViewId(id); + const payload = stripUndefined({ surface, type, zones }); + const response = await this.httpClient.post( + `/views/${id}/grid/_rows`, + payload, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + /** + * Re-key every field in a row from the old type to the new type. When + * the new type has fewer columns, surplus fields collapse into the + * first column. + */ + async patchGridRow({ id, surface, row_uid, type, ifMatch } = {}) { + requireViewId(id); + if (!row_uid) throw new Error('row_uid is required.'); + if (!type) throw new Error('type is required.'); + const response = await this.httpClient.patch( + `/views/${id}/grid/_rows/${encodeURIComponent(row_uid)}`, + stripUndefined({ surface, type }), + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + /** Remove a grid row and every field placed in any of its areas. */ + async deleteGridRow({ id, surface, row_uid, ifMatch } = {}) { + requireViewId(id); + if (!row_uid) throw new Error('row_uid is required.'); + // axios.delete requires `data` inside the config to send a body. + const config = this.ifMatchHeaders(id, ifMatch) || {}; + if (surface) config.data = { surface }; + const response = await this.httpClient.delete( + `/views/${id}/grid/_rows/${encodeURIComponent(row_uid)}`, + config + ); + this.cacheVersion(id, response); + return response.data; + } + + // =================================================================== + // Search Bar internal slot CRUD (modern shape only) + // =================================================================== + + async addSearchField({ id, widget_area, widget_slot, position, field, slot, ifMatch } = {}) { + requireViewId(id); + if (!widget_area || !widget_slot) throw new Error('widget_area and widget_slot are required.'); + if (!position) throw new Error('position is required (e.g. "search-general_top::100::ROW_UID").'); + if (!field || typeof field !== 'object' || !field.id) { + throw new Error('field must be an object with at least an `id` (e.g. "search_all", "submit", or a GF field id).'); + } + await this.assertSearchInputType(field.input ?? field.input_type); + const response = await this.httpClient.post( + `/views/${id}/search-fields/_slots`, + stripUndefined({ widget_area, widget_slot, position, field, slot }), + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + /** + * Pre-flight check for `field.input` / `settings.input` values + * passed to addSearchField / patchSearchField. Fetches the + * canonical input-type list once per session and throws a clear + * error before the network round trip when the caller supplies + * an unknown slug. The server enforces the same allow-list as a + * safety net — this just gives the agent a faster, more useful + * error message ("Unknown search input 'datepiker' — did you + * mean 'date_range'?") than a generic 400. + * + * @param {string|undefined} input + * @returns {Promise} + */ + async assertSearchInputType(input) { + const value = String(input ?? '').trim(); + if (value === '') return; // no input → server defaults; nothing to validate + if (!this._searchInputTypes) { + try { + const data = this._searchInputTypesPromise + || (this._searchInputTypesPromise = this.listSearchFieldInputTypes()); + const resolved = await data; + this._searchInputTypes = new Set(Array.isArray(resolved?.input_types) ? resolved.input_types : []); + } catch (_) { + // Discovery failed (older plugin, network blip) — degrade + // to permissive; the server still rejects on write. + this._searchInputTypes = null; + this._searchInputTypesPromise = null; + return; + } + } + if (this._searchInputTypes && this._searchInputTypes.size > 0 && !this._searchInputTypes.has(value)) { + const known = [...this._searchInputTypes].sort().join(', '); + throw new Error(`Unknown search field input "${value}". Valid types: ${known}.`); + } + } + + async patchSearchField({ id, widget_area, widget_slot, position, search_slot, settings, ifMatch } = {}) { + requireViewId(id); + if (!widget_area || !widget_slot) throw new Error('widget_area and widget_slot are required.'); + if (!position) throw new Error('position is required.'); + if (!search_slot) throw new Error('search_slot is required.'); + if (!settings || typeof settings !== 'object') throw new Error('settings must be an object.'); + await this.assertSearchInputType(settings.input ?? settings.input_type); + const response = await this.httpClient.patch( + `/views/${id}/search-fields/${encodeURIComponent(search_slot)}`, + { widget_area, widget_slot, position, settings }, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + async removeSearchField({ id, widget_area, widget_slot, position, search_slot, ifMatch } = {}) { + requireViewId(id); + if (!widget_area || !widget_slot || !position || !search_slot) { + throw new Error('widget_area, widget_slot, position, and search_slot are required.'); + } + const config = this.ifMatchHeaders(id, ifMatch) || {}; + config.data = { widget_area, widget_slot, position }; + const response = await this.httpClient.delete( + `/views/${id}/search-fields/${encodeURIComponent(search_slot)}`, + config + ); + this.cacheVersion(id, response); + return response.data; + } + + // =================================================================== + // Surgical writes — widgets + // =================================================================== + + async addViewWidget({ id, area, widget, ifMatch } = {}) { + requireViewId(id); + if (!area) throw new Error('area is required.'); + if (!widget || typeof widget !== 'object') throw new Error('widget (object) is required.'); + if (!widget.field_id) throw new Error('widget.field_id is required (use the widget id, e.g. "search_bar").'); + const response = await this.httpClient.post( + `/views/${id}/widgets/${encodeURIComponent(area)}/_slots`, + widget, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + async patchViewWidget({ id, area, slot, settings, ifMatch } = {}) { + requireViewId(id); + requireAreaSlot(area, slot); + if (!settings || typeof settings !== 'object') { + throw new Error('settings (object) is required.'); + } + const response = await this.httpClient.patch( + `/views/${id}/widgets/${encodeURIComponent(area)}/${encodeURIComponent(slot)}`, + settings, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + async removeViewWidget({ id, area, slot, ifMatch } = {}) { + requireViewId(id); + requireAreaSlot(area, slot); + const response = await this.httpClient.delete( + `/views/${id}/widgets/${encodeURIComponent(area)}/${encodeURIComponent(slot)}`, + this.ifMatchHeaders(id, ifMatch) + ); + this.cacheVersion(id, response); + return response.data; + } + + // =================================================================== + // Internals + // =================================================================== + + cacheVersion(viewId, response) { + if (!viewId) return; + const tag = response?.headers?.etag || response?.headers?.ETag; + let version; + if (typeof tag === 'string') { + version = tag.replace(/^"(.*)"$/, '$1'); + } else if (response?.data?.version) { + version = String(response.data.version); + } + if (version) { + this.versionCache.set(Number(viewId), version); + } + } + + ifMatchHeaders(viewId, ifMatch) { + if (!ifMatch) return undefined; + const value = ifMatch === 'auto' ? this.versionCache.get(Number(viewId)) : ifMatch; + if (!value) { + logger.warn(`If-Match: 'auto' requested but no cached version for view ${viewId} — skipping precondition.`); + return undefined; + } + const quoted = /^".*"$/.test(value) ? value : `"${value}"`; + return { headers: { 'If-Match': quoted } }; + } +} + +function requireViewId(id) { + if (!Number.isInteger(id) || id <= 0) { + throw new Error('id (positive integer view id) is required.'); + } +} + +function requireAreaSlot(area, slot) { + if (!area) throw new Error('area is required.'); + if (!slot) throw new Error('slot is required.'); +} + +function encodeArea(area) { + // Layout-builder areas embed `::` separators (which the server's + // route regex covers either as `::` or `%3A%3A`) AND `/` glyphs + // inside row-type names (`50/50`, `33/33/33`, `25/25/25/25`). The + // `/` MUST be percent-encoded — leaving it literal makes WordPress + // treat it as a path separator and the route stops matching the + // intended segment, fatal-404ing /render and similar endpoints. + return encodeURIComponent(String(area)).replace(/%3A%3A/g, '::'); +} + +function stripUndefined(obj) { + const out = {}; + for (const [k, v] of Object.entries(obj || {})) { + if (v !== undefined) out[k] = v; + } + return out; +} + +export default GravityViewInspectorClient; diff --git a/src/gravityview/view-validator.js b/src/gravityview/view-validator.js new file mode 100644 index 0000000..ff76bd0 --- /dev/null +++ b/src/gravityview/view-validator.js @@ -0,0 +1,297 @@ +/** + * Client-side validator for the inspector REST surface. + * + * The server is the source of truth (every write runs through + * `apply_collection` and per-setting sanitisers in InspectorRoute), + * but failing fast on the obvious structural mistakes saves a round + * trip and gives the agent a more useful error message — "field_id + * is required on every entry in fields[]" beats a 400 with + * `gv_rest_invalid_field`. + * + * Validation tiers: + * - structural (free): required keys, types, enums, mode + * - schema-aware (one fetch): per-field-type setting validation + * against the live `/field-types/{type}/schema` response + * + * Schema-aware checks are opt-in (`{ deep: true }`) because they + * cost a network round trip per unique field type referenced. + */ + +const VALID_MODES = ['replace', 'merge']; + +export class ViewValidator { + constructor(client) { + this.client = client; + // Cache field-type schemas across calls so a payload touching + // ten `text` fields only fetches the schema once. + this.schemaCache = new Map(); + } + + /** + * Structural validation of an apply payload. Throws on the first + * issue with a message the AI agent can use to correct the call. + */ + validateApplyPayload(payload = {}) { + if (typeof payload !== 'object' || payload === null) { + throw new Error('apply payload must be an object.'); + } + + if (payload.mode !== undefined && !VALID_MODES.includes(payload.mode)) { + throw new Error(`mode must be one of: ${VALID_MODES.join(', ')}`); + } + + if (payload.fields !== undefined) { + this.validateAreaTree('fields', payload.fields); + } + if (payload.widgets !== undefined) { + this.validateAreaTree('widgets', payload.widgets); + } + + if (payload.template_id !== undefined && typeof payload.template_id !== 'string') { + throw new Error('template_id must be a string.'); + } + + if (payload.template_settings !== undefined && (typeof payload.template_settings !== 'object' || payload.template_settings === null)) { + throw new Error('template_settings must be an object.'); + } + + if (payload.search_criteria !== undefined && (typeof payload.search_criteria !== 'object' || payload.search_criteria === null)) { + throw new Error('search_criteria must be an object.'); + } + } + + /** + * Validate the create-View payload before it leaves the client. + * The server enforces the same checks but returning early avoids + * a 400 round trip for typos. + */ + validateCreatePayload(payload = {}) { + if (!payload.title || typeof payload.title !== 'string') { + throw new Error('title (non-empty string) is required.'); + } + if (!Number.isInteger(payload.form_id) || payload.form_id <= 0) { + throw new Error('form_id (positive integer) is required.'); + } + if (payload.template_id !== undefined && typeof payload.template_id !== 'string') { + throw new Error('template_id must be a string.'); + } + // Reuse the apply-payload structural checks for the seed bits. + this.validateApplyPayload({ + template_id: payload.template_id, + template_settings: payload.template_settings, + search_criteria: payload.search_criteria, + fields: payload.fields, + widgets: payload.widgets, + mode: payload.mode, + }); + } + + validateAreaTree(label, tree) { + if (typeof tree !== 'object' || tree === null) { + throw new Error(`${label} must be an object keyed by area key.`); + } + for (const [area, items] of Object.entries(tree)) { + if (typeof area !== 'string' || area === '') { + throw new Error(`${label} contains a non-string area key.`); + } + if (!Array.isArray(items)) { + throw new Error(`${label}["${area}"] must be an array of slot objects.`); + } + items.forEach((item, idx) => { + if (!item || typeof item !== 'object') { + throw new Error(`${label}["${area}"][${idx}] must be an object.`); + } + // field_id must be a finite number or a non-empty (non-whitespace) + // string — it's later coerced via String(item.field_id). This rejects + // false, objects, arrays, and whitespace-only values. + const fid = item.field_id; + const validFieldId = (typeof fid === 'number' && Number.isFinite(fid)) || (typeof fid === 'string' && fid.trim() !== ''); + if (!validFieldId) { + throw new Error(`${label}["${area}"][${idx}] is missing required key "field_id".`); + } + if (item.slot !== undefined && typeof item.slot !== 'string') { + throw new Error(`${label}["${area}"][${idx}].slot must be a string when present.`); + } + }); + } + } + + /** + * Schema-aware validation. Walks every field AND every widget in + * the payload, fetches the matching field-type schema (cached), + * and confirms the settings keys are recognised. + * + * Widgets dispatch to widget-specific schemas (post the InspectorRoute + * fix that detects registered widgets). Fields whose `field_id` is a + * numeric/composite GF id are skipped — those settings live in the + * View-specific bulk-schema endpoint, not the field-type registry. + * + * Pass `{ template_id, context }` so the schema fetch matches the + * setting set the inspector actually persists for that combination. + */ + async validateAgainstSchemas({ id, fields = {}, widgets = {}, template_id, context }) { + const allEntries = [ + ...Object.values(fields).flatMap((arr) => arr.map((item) => ({ kind: 'field', item }))), + ...Object.values(widgets).flatMap((arr) => arr.map((item) => ({ kind: 'widget', item }))), + ]; + + // Numeric field ids (form-bound fields) need the input type to + // resolve their full schema — `email` adds emailmailto/subject/ + // body, `address` adds show_map_link, `fileupload` adds + // link_to_file/image_*, etc. Look those up once per view via + // gv_list_available_fields so the validator catches typos on + // input-type-specific settings without false-positives. When no + // view id is supplied (e.g. gv_create_view before the View + // exists), the lookup is skipped and numeric ids fall through + // to a permissive base-only check. + const inputTypeByFieldId = id ? await this.getInputTypeMap(id) : new Map(); + + for (const { kind, item } of allEntries) { + // Widgets identify themselves through field_id too — InspectorRoute + // detects when the type maps to a registered widget and returns + // the widget's settings schema (e.g. search_bar → search_layout, + // search_fields, …). Skipping schema validation for widgets would + // miss the most common authoring mistake (typoed setting key). + const fieldId = String(item.field_id ?? '').trim(); + const isNum = /^\d+(\.\d+)?$/.test(fieldId); + const typeSlug = isNum ? 'field' : fieldId; + if (!typeSlug) continue; + + // For numeric ids, the actual GF input type drives which + // overlay applies. Fall back to no input_type when we can't + // look it up — yields the BASE schema only, which still + // catches typos like `custom_lable` and `cusotm_class`. + const inputType = isNum ? (inputTypeByFieldId.get(fieldId) || undefined) : undefined; + + const schema = await this.getSchema(typeSlug, template_id, context, inputType); + if (!schema || !Array.isArray(schema.schema)) continue; + + const validKeys = new Set(schema.schema.map((entry) => entry.slug)); + // `field_id`, `slot`, `label`, `id`, `custom_label`, `custom_class` + // are always accepted by the server alongside whatever the schema + // lists — they're meta-keys persisted independently of per-type + // settings. + const reservedKeys = new Set([ + 'field_id', + 'slot', + 'label', + 'id', + 'custom_label', + 'custom_class', + ]); + + for (const key of Object.keys(item)) { + if (reservedKeys.has(key)) continue; + if (!validKeys.has(key)) { + const samples = [...validKeys].slice(0, 12).join(', '); + const more = validKeys.size > 12 ? ', …' : ''; + const inputHint = inputType ? ` (input_type=${inputType})` : ''; + throw new Error( + `${kind} "${item.field_id}" has unknown setting "${key}". Schema for ${schema.kind || 'type'} "${typeSlug}"${inputHint} lists: ${samples}${more}` + ); + } + } + } + } + + /** + * Build a map of `field_id → input_type` for all GF form fields + * available to this View. Cached per view id so repeat calls in + * the same MCP session don't refetch. + * + * @param {number} viewId + * @returns {Promise>} + */ + async getInputTypeMap(viewId) { + if (!this._inputTypeMaps) this._inputTypeMaps = new Map(); + if (this._inputTypeMaps.has(viewId)) return this._inputTypeMaps.get(viewId); + + let map = new Map(); + try { + const data = await this.client.listAvailableFields({ id: viewId }); + const formFields = Array.isArray(data?.form_fields) ? data.form_fields : []; + for (const field of formFields) { + const fid = String(field.id ?? '').trim(); + const it = String(field.input_type ?? field.type ?? '').trim(); + if (fid && it) map.set(fid, it); + } + } catch (err) { + // Available-fields fetch failed (network, perms, missing form) + // — degrade to permissive (base-only) schema validation. The + // server still validates on apply. + } + this._inputTypeMaps.set(viewId, map); + return map; + } + + /** + * When the payload places fields into Layout Builder area keys + * (compound `{prefix}-{areaid}::{type}::{row_uid}` form), confirm + * the referenced row_uids exist in the View's current grid. Without + * this check, a typoed row_uid silently lands the field somewhere + * the inspector can't render. + * + * Cheap: one `gv_get_view_areas` call regardless of payload size. + * No-op when none of the area keys look like Layout Builder keys. + */ + async validateLayoutBuilderAreas({ id, fields = {}, widgets = {} }) { + // gv_get_view_areas returns FIELD-zone areas only (directory / + // single / edit). Widget area keys live on a separate surface + // (header_* / footer_*) and are validated server-side against the + // widget tree, so skip them here to avoid false-positive rejects. + const lbAreas = Object.keys(fields).filter((key) => key.includes('::')); + if (!id || lbAreas.length === 0) return; + + let known; + try { + const areas = await this.client.getViewAreas({ id }); + const zoneMap = areas?.zones || {}; + known = new Set(); + for (const zone of Object.keys(zoneMap)) { + for (const row of zoneMap[zone] || []) { + for (const area of row.areas || []) { + if (area.areaid) { + known.add(`${zone}_${area.areaid}`); + } + } + } + } + } catch (err) { + // Areas fetch failed (network, perms, missing template) — degrade + // to no-op rather than blocking the write. + return; + } + + for (const areaKey of lbAreas) { + if (!known.has(areaKey)) { + const sample = [...known].slice(0, 4).join(', '); + throw new Error( + `Area "${areaKey}" doesn't exist in this View's grid. Use gv_get_view_areas to discover valid areas, or gv_create_grid_row to add a new row first. Known areas include: ${sample}…` + ); + } + } + } + + async getSchema(fieldType, template_id, context, input_type) { + const key = `${fieldType}|${template_id || ''}|${context || ''}|${input_type || ''}`; + if (this.schemaCache.has(key)) return this.schemaCache.get(key); + try { + const data = await this.client.getFieldTypeSchema({ + field_type: fieldType, + template_id, + context, + input_type, + }); + this.schemaCache.set(key, data); + return data; + } catch (error) { + // Schema discovery failures shouldn't block writes — degrade + // to structural-only validation. Cache the failure so we don't + // re-fetch on every field with the same type. + this.schemaCache.set(key, null); + return null; + } + } +} + +export default ViewValidator; diff --git a/src/index.js b/src/index.js index 3a5ff6e..dfcbb2a 100644 --- a/src/index.js +++ b/src/index.js @@ -22,6 +22,9 @@ import FieldAwareValidator from './config/field-validation.js'; import logger from './utils/logger.js'; import { sanitize } from './utils/sanitize.js'; import { stripEmpty, stripEntryMetaFromResponse } from './utils/compact.js'; +import { WordPressClient } from './wp-client.js'; +import { loadAbilitiesAsTools } from './abilities/loader.js'; +import { runPlaneInit, buildToolList, classifyAbilityCall } from './server-runtime.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -40,9 +43,9 @@ const server = new Server( }, { capabilities: { - tools: {} + tools: { listChanged: true } }, - instructions: 'GravityKit MCP server for Gravity Forms. Checkbox/multiselect arrays auto-normalized: pass ["val1","val2"] and values are matched to correct sub-inputs. Text labels also work. Multiselect limitation: values containing commas get split by GF REST API. Responses strip null/empty by default; pass compact=false for full raw data.' + instructions: 'GravityKit MCP server. Two tool families: gf_* for Gravity Forms (forms, entries, feeds, notifications, fields — works on any Gravity Forms site) and product tools (gv_* for GravityView Views, plus other GravityKit products) generated from the site\'s GravityKit Foundation abilities catalog — these appear only when Foundation is active on the connected site; call gk_reload_abilities to (re)load the catalog if gv_* tools are missing or stale.\n\nGravityView authoring flow: 1) gv_view_create to create a draft (defaults to gravityview-layout-builder, supports per-zone template_ids). 2) Use gv_grid_row_add (surface=fields|widgets) to materialise rows in the layout. 3) Use gv_view_config_apply for bulk one-shot writes, or gv_view_field_add / gv_view_field_patch / gv_view_field_move for surgical edits. 4) For Search Bar internal layout, use gv_search_field_add / gv_search_field_patch / gv_search_field_remove — modern keyed-by-position storage (search_fields_section). Existing legacy search_bar widgets auto-migrate to modern on first save through this API.\n\nDiscovery: gv_layouts_list (Layout Builder, DIY, Table, List, DataTables, Map — with is_grid_aware flag), gv_widgets_list, gv_grid_row_types_list, gv_widget_zones_list (header/footer), gv_search_zones_list (search-general/search-advanced), gv_available_fields_get. Schema: gv_field_type_schema_get works for fields, widgets, AND search_field types (search_all, submit, search_mode, etc.) — kind in the response says which.\n\nMove semantics: gv_view_field_move accepts to.before_slot / to.after_slot for ref-relative placement (preferred) and position="start"|"end"|integer for symbolic. Concurrency: pass ifMatch="auto" to use the client-cached version. Compact: responses strip null/empty by default — pass compact=false for raw.\n\nGravity Forms specifics: checkbox/multiselect arrays auto-normalized; multiselect values with commas get split by GF REST API; gf_submit_form_data runs the full pipeline (validation/notifications/feeds), gf_create_entry is raw import.' } ); @@ -50,20 +53,69 @@ const server = new Server( let gravityFormsClient = null; let fieldOperations = null; let fieldValidator = null; +let wpClient = null; +// Auto-generated from the WordPress Abilities API (Foundation catalog +// first, WP core fallback). Populated by initializeClient(). This is +// the ONLY source of gv_* tools — when no catalog is reachable (older +// WP, plugin off), these stay null, gv_* tools are absent from +// tools/list, and every gv_* call retries the load (self-healing). +let abilityToolDefinitions = null; +let abilityToolHandlers = null; +// In-flight catalog fetch. Single-flight: concurrent callers share the +// same promise. On rejection it's cleared so a later call retries — +// covers transient cert / network / WP-not-yet-booted failures without +// requiring an MCP process restart. Retries are bounded by a cooldown +// so Foundation-less sites (gf_* only) don't pay two failed requests +// on every tools/list forever; gk_reload_abilities bypasses it. +let abilitiesLoadPromise = null; +let abilitiesFailedAt = 0; +const ABILITIES_RETRY_COOLDOWN_MS = 60_000; /** - * Initialize Gravity Forms client + * Initialize the two independent capability planes. + * + * Plane A — Gravity Forms: static gf_* tools over GF REST v2. Works on + * any Gravity Forms site; requires only GRAVITY_FORMS_* credentials. + * + * Plane B — GravityKit abilities: dynamic tools from the Foundation + * catalog (all GravityKit products) with WP-core fallback. Requires a + * WordPress app password (or the GF credential fallback) and lights up + * only when Foundation is active on the site. + * + * Each plane initializes independently and degrades independently — + * a GF-only site gets the full gf_* surface with no abilities, and a + * GravityKit site without GF REST keys still gets abilities. Failed + * planes retry on later calls, bounded by a cooldown. + * + * @throws when NEITHER plane has usable credentials. */ +const INIT_RETRY_COOLDOWN_MS = 60_000; +let gfPlaneFailedAt = 0; +let wpPlaneFailedAt = 0; + async function initializeClient() { + // WP plane starts first (synchronous) so the GF REST probe can't gate it; + // runPlaneInit throws only when neither plane has usable credentials. + await runPlaneInit({ + initGravityFormsPlane: initializeGravityFormsPlane, + initWordPressPlane: initializeWordPressPlane, + }); + return true; +} + +async function initializeGravityFormsPlane() { + if (gravityFormsClient) return true; + if (Date.now() - gfPlaneFailedAt < INIT_RETRY_COOLDOWN_MS) return false; + try { - gravityFormsClient = new GravityFormsClient(process.env); - const validation = await gravityFormsClient.initialize(); + const client = new GravityFormsClient(process.env); + const validation = await client.initialize(); if (!validation.available) { - throw new Error(`Failed to initialize Gravity Forms client: ${validation.error}`); + throw new Error(validation.error); } - // Initialize field operations infrastructure + gravityFormsClient = client; fieldValidator = new FieldAwareValidator(); fieldOperations = createFieldOperations( gravityFormsClient, @@ -71,12 +123,97 @@ async function initializeClient() { fieldValidator ); - logger.info('✅ GravityKit MCP initialized successfully'); - logger.info('✅ Field operations infrastructure initialized'); + logger.info('✅ Gravity Forms client initialized — gf_* tools available'); return true; - } catch (error) { - logger.error(`❌ Failed to initialize: ${error.message}`); - throw error; + } catch (gfError) { + gfPlaneFailedAt = Date.now(); + logger.warn(`⚠️ Gravity Forms client unavailable: ${gfError.message} — gf_* tools disabled (will retry)`); + return false; + } +} + +function initializeWordPressPlane() { + if (wpClient) return true; + if (Date.now() - wpPlaneFailedAt < INIT_RETRY_COOLDOWN_MS) return false; + + try { + // WordPress client — the authenticated transport to the WP root + // (Foundation catalog + WP core Abilities API). Credentials and + // base URL are resolved independently of the GF REST endpoint, + // with fallback to GRAVITY_FORMS_* so single-WP-install setups + // don't need to mint two separate credentials. + wpClient = new WordPressClient(process.env); + logger.info('✅ WordPress client initialized — loading GravityKit abilities'); + + // Fire-and-forget: kick off the abilities catalog fetch in the + // background so MCP startup is fast. ListTools awaits up to 2s + // for it; per-call self-heal and gk_reload_abilities retry later. + ensureAbilitiesLoaded(); + return true; + } catch (wpError) { + wpPlaneFailedAt = Date.now(); + wpClient = null; + logger.warn(`⚠️ WordPress client unavailable: ${wpError.message} — abilities tools disabled (will retry)`); + return false; + } +} + +/** + * Idempotent + self-healing loader for the WordPress Abilities API + * catalog. Single-flight (concurrent callers share a promise), with + * a per-call optional timeout (used by ListTools so a slow / down WP + * doesn't hang the tool list at startup). On rejection the cached + * promise is cleared so the NEXT call retries — sleep/wake, cert + * mid-fix, valet still booting all self-heal on the next gv_* call. + * + * Side effects on success: + * - populates `abilityToolDefinitions` + `abilityToolHandlers` + * - emits `notifications/tools/list_changed` so MCP clients refetch + * + * @param {Object} [opts] + * @param {boolean} [opts.force] Discard cached state and reload. + * @param {number} [opts.timeoutMs] Cap the await; the load itself + * keeps running in the background + * after the timeout fires. + */ +async function ensureAbilitiesLoaded({ force = false, timeoutMs } = {}) { + if (!wpClient) return; + if (force) { + abilityToolDefinitions = null; + abilityToolHandlers = null; + abilitiesLoadPromise = null; + abilitiesFailedAt = 0; + } + if (abilityToolDefinitions) return; + if (!abilitiesLoadPromise && Date.now() - abilitiesFailedAt < ABILITIES_RETRY_COOLDOWN_MS) return; + if (!abilitiesLoadPromise) { + abilitiesLoadPromise = loadAbilitiesAsTools(wpClient, { reservedNames: RESERVED_TOOL_NAMES }) + .then(({ definitions, handlers, count, source }) => { + abilityToolDefinitions = definitions; + abilityToolHandlers = handlers; + const sourceLabel = source === 'foundation-catalog' ? 'gravitykit/v1 catalog' : '/wp-abilities/v1'; + logger.info(`✅ Loaded ${count} GravityKit abilities from ${sourceLabel}`); + // Tell connected MCP clients to refetch the tool list so the + // freshly loaded ability tools and their schemas land in the + // client's cached catalogue. + server.sendToolListChanged().catch((err) => { + logger.warn(`tools/list_changed notification failed: ${err.message}`); + }); + }) + .catch((err) => { + logger.warn(`⚠️ Abilities API catalog unavailable: ${err.message} — abilities tools unavailable until a catalog is reachable (next retry after cooldown, or gk_reload_abilities)`); + abilitiesFailedAt = Date.now(); + abilitiesLoadPromise = null; // clear so a later call retries + throw err; + }); + } + if (timeoutMs) { + await Promise.race([ + abilitiesLoadPromise.catch(() => {}), + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); + } else { + await abilitiesLoadPromise.catch(() => {}); } } @@ -130,418 +267,491 @@ function wrapHandler(handler, params = {}) { }; } +/** + * Variant of wrapHandler for gv_* tools. Differs in two ways: + * - Checks wpClient (not gravityFormsClient). + * - Surfaces the inspector REST envelope (`{ code, message, data }`) + * so the agent sees `gv_rest_invalid_template` etc. instead of a + * generic "Request failed with status code 400". The inspector's + * errors are designed for AI consumption — preserve them. + */ +function wrapViewHandler(handler, params = {}) { + return async () => { + if (!wpClient) { + return createErrorResponse('GravityView client not initialized'); + } + try { + const result = await handler(); + const output = params.compact !== false ? stripEmpty(result) : result; + return { + content: [{ type: 'text', text: JSON.stringify(output) }], + }; + } catch (error) { + // Axios errors carry response.data — when the server speaks + // the inspector REST envelope, that's the most useful payload. + const restBody = error?.response?.data; + const status = error?.response?.status; + const message = restBody?.message || error.message; + const details = restBody + ? { status, code: restBody.code, data: restBody.data } + : undefined; + logger.error(`gv_* tool error: ${message}${status ? ` (HTTP ${status})` : ''}`); + return createErrorResponse(message, details); + } + }; +} + // ================================= // FORMS MANAGEMENT TOOLS (6) // ================================= -server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - // Forms Management (6 tools) - { - name: 'gf_list_forms', - description: 'List all forms with optional search and pagination.', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - include: { - type: 'array', - items: { type: 'number' }, - description: 'Form IDs to include' - }, - compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } - } - } - }, - { - name: 'gf_get_form', - description: 'Get a form by ID with full field configuration.', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Form ID' }, - compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } - }, - required: ['id'] - } +// Static Gravity Forms tool definitions (Plane A — works on any GF site, +// no Foundation required). These names are the released contract; the +// abilities loader treats them as reserved so a future catalog-served +// gk-gravity-forms ability can never shadow them. +const GF_TOOL_DEFINITIONS = [ + // Forms Management (6 tools) + { + name: 'gf_list_forms', + description: 'List all forms with optional search and pagination.', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + include: { + type: 'array', + items: { type: 'number' }, + description: 'Form IDs to include' + }, + compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } + } + } + }, + { + name: 'gf_get_form', + description: 'Get a form by ID with full field configuration.', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Form ID' }, + compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } }, - { - name: 'gf_create_form', - description: 'Create a new form', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - title: { type: 'string', description: 'Form title' }, - description: { type: 'string', description: 'Form description' }, - fields: { - type: 'array', - description: 'Array of field objects', - items: { type: 'object' } - }, - button: { type: 'object', description: 'Submit button settings' }, - confirmations: { type: 'object', description: 'Confirmation settings' }, - notifications: { type: 'object', description: 'Notification settings' }, - is_active: { type: 'boolean', description: 'Form active state' } - }, - required: ['title'] - } + required: ['id'] + } + }, + { + name: 'gf_create_form', + description: 'Create a new form', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + title: { type: 'string', description: 'Form title' }, + description: { type: 'string', description: 'Form description' }, + fields: { + type: 'array', + description: 'Array of field objects', + items: { type: 'object' } + }, + button: { type: 'object', description: 'Submit button settings' }, + confirmations: { type: 'object', description: 'Confirmation settings' }, + notifications: { type: 'object', description: 'Notification settings' }, + is_active: { type: 'boolean', description: 'Form active state' } }, - { - name: 'gf_update_form', - description: 'Update a form', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Form ID' }, - title: { type: 'string', description: 'Form title' }, - description: { type: 'string', description: 'Form description' }, - fields: { - type: 'array', - description: 'Array of field objects', - items: { type: 'object' } - }, - button: { type: 'object', description: 'Submit button settings' }, - confirmations: { type: 'object', description: 'Confirmation settings' }, - notifications: { type: 'object', description: 'Notification settings' }, - is_active: { type: 'boolean', description: 'Form active state' } - }, - required: ['id'] - } + required: ['title'] + } + }, + { + name: 'gf_update_form', + description: 'Update a form', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Form ID' }, + title: { type: 'string', description: 'Form title' }, + description: { type: 'string', description: 'Form description' }, + fields: { + type: 'array', + description: 'Array of field objects', + items: { type: 'object' } + }, + button: { type: 'object', description: 'Submit button settings' }, + confirmations: { type: 'object', description: 'Confirmation settings' }, + notifications: { type: 'object', description: 'Notification settings' }, + is_active: { type: 'boolean', description: 'Form active state' } }, - { - name: 'gf_delete_form', - description: 'Delete a form (requires ALLOW_DELETE=true)', - annotations: { destructiveHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Form ID' }, - force: { type: 'boolean', description: 'Permanent delete (vs trash)' } - }, - required: ['id'] - } + required: ['id'] + } + }, + { + name: 'gf_delete_form', + description: 'Delete a form (requires ALLOW_DELETE=true)', + annotations: { destructiveHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Form ID' }, + force: { type: 'boolean', description: 'Permanent delete (vs trash)' } }, - { - name: 'gf_validate_form', - description: 'Validate form data', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - form_id: { type: 'number', description: 'Form ID' } - }, - additionalProperties: true, - required: ['form_id'] - } + required: ['id'] + } + }, + { + name: 'gf_validate_form', + description: 'Validate form data', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_id: { type: 'number', description: 'Form ID' } }, + additionalProperties: true, + required: ['form_id'] + } + }, - // Entries Management (5 tools) - { - name: 'gf_list_entries', - description: 'List/search entries with filtering, sorting, and pagination.', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { + // Entries Management (5 tools) + { + name: 'gf_list_entries', + description: 'List/search entries with filtering, sorting, and pagination.', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_ids: { + type: 'array', + items: { type: 'number' }, + description: 'Filter by form IDs' + }, + include: { + type: 'array', + items: { type: 'number' }, + description: 'Entry IDs to include' + }, + exclude: { + type: 'array', + items: { type: 'number' }, + description: 'Entry IDs to exclude' + }, + status: { + type: 'string', + enum: ['active', 'spam', 'trash'], + description: 'Entry status' + }, + search: { type: 'object', properties: { - form_ids: { + field_filters: { type: 'array', - items: { type: 'number' }, - description: 'Filter by form IDs' - }, - include: { - type: 'array', - items: { type: 'number' }, - description: 'Entry IDs to include' - }, - exclude: { - type: 'array', - items: { type: 'number' }, - description: 'Entry IDs to exclude' - }, - status: { - type: 'string', - enum: ['active', 'spam', 'trash'], - description: 'Entry status' - }, - search: { - type: 'object', - properties: { - field_filters: { - type: 'array', - items: { - type: 'object', - properties: { - key: { type: 'string' }, - value: { type: 'string' }, - operator: { - type: 'string', - enum: ['=', 'IS', 'CONTAINS', 'IS NOT', 'ISNOT', '<>', 'LIKE', 'NOT IN', 'NOTIN', 'IN', '>', '<', '>=', '<='] - } - } + items: { + type: 'object', + properties: { + key: { type: 'string' }, + value: { type: 'string' }, + operator: { + type: 'string', + enum: ['=', 'IS', 'CONTAINS', 'IS NOT', 'ISNOT', '<>', 'LIKE', 'NOT IN', 'NOTIN', 'IN', '>', '<', '>=', '<='] } - }, - mode: { - type: 'string', - enum: ['any', 'all'], - description: 'Search mode' } } }, - sorting: { - type: 'object', - properties: { - key: { type: 'string' }, - direction: { - type: 'string', - enum: ['asc', 'desc', 'ASC', 'DESC'] - } - } - }, - paging: { - type: 'object', - properties: { - page_size: { type: 'number' }, - current_page: { type: 'number' } - } - }, - compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } - } - } - }, - { - name: 'gf_get_entry', - description: 'Get an entry by ID with field labels.', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Entry ID' }, - compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } - }, - required: ['id'] - } - }, - { - name: 'gf_create_entry', - description: 'Create an entry. Checkbox/multiselect arrays auto-normalized.', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - form_id: { type: 'number', description: 'Form ID' }, - created_by: { type: 'number', description: 'Creator user ID' }, - status: { + mode: { type: 'string', - enum: ['active', 'spam', 'trash'], - description: 'Entry status' - }, - date_created: { type: 'string', description: 'ISO date' } - }, - additionalProperties: true, - required: ['form_id'] - } - }, - { - name: 'gf_update_entry', - description: 'Update an entry. Checkbox/multiselect arrays auto-normalized; unmentioned fields preserved.', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { + enum: ['any', 'all'], + description: 'Search mode' + } + } + }, + sorting: { type: 'object', properties: { - id: { type: 'number', description: 'Entry ID' }, - status: { + key: { type: 'string' }, + direction: { type: 'string', - enum: ['active', 'spam', 'trash'], - description: 'Entry status' + enum: ['asc', 'desc', 'ASC', 'DESC'] } - }, - additionalProperties: true, - required: ['id'] - } - }, - { - name: 'gf_delete_entry', - description: 'Delete an entry (requires ALLOW_DELETE=true)', - annotations: { destructiveHint: true, openWorldHint: true }, - inputSchema: { + } + }, + paging: { type: 'object', properties: { - id: { type: 'number', description: 'Entry ID' }, - force: { type: 'boolean', description: 'Permanent delete (vs trash)' } - }, - required: ['id'] - } + page_size: { type: 'number' }, + current_page: { type: 'number' } + } + }, + compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } + } + } + }, + { + name: 'gf_get_entry', + description: 'Get an entry by ID with field labels.', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Entry ID' }, + compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } }, - - // Form Submissions (2 tools) - { - name: 'gf_submit_form_data', - description: 'Submit form data (triggers notifications, confirmations, payment)', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - form_id: { type: 'number', description: 'Form ID' }, - field_values: { type: 'object', description: 'Field values' } - }, - additionalProperties: true, - required: ['form_id'] - } + required: ['id'] + } + }, + { + name: 'gf_create_entry', + description: 'Create an entry. Checkbox/multiselect arrays auto-normalized.', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_id: { type: 'number', description: 'Form ID' }, + created_by: { type: 'number', description: 'Creator user ID' }, + status: { + type: 'string', + enum: ['active', 'spam', 'trash'], + description: 'Entry status' + }, + date_created: { type: 'string', description: 'ISO date' } }, - { - name: 'gf_validate_submission', - description: 'Validate submission without processing', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - form_id: { type: 'number', description: 'Form ID' } - }, - additionalProperties: true, - required: ['form_id'] + additionalProperties: true, + required: ['form_id'] + } + }, + { + name: 'gf_update_entry', + description: 'Update an entry. Checkbox/multiselect arrays auto-normalized; unmentioned fields preserved.', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Entry ID' }, + status: { + type: 'string', + enum: ['active', 'spam', 'trash'], + description: 'Entry status' } }, + additionalProperties: true, + required: ['id'] + } + }, + { + name: 'gf_delete_entry', + description: 'Delete an entry (requires ALLOW_DELETE=true)', + annotations: { destructiveHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Entry ID' }, + force: { type: 'boolean', description: 'Permanent delete (vs trash)' } + }, + required: ['id'] + } + }, - // Notifications (1 tool) - { - name: 'gf_send_notifications', - description: 'Send notifications for entry', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - entry_id: { type: 'number', description: 'Entry ID' }, - notification_ids: { - type: 'array', - items: { type: 'string' }, - description: 'Notification IDs to send' - } - }, - required: ['entry_id'] - } + // Form Submissions (2 tools) + { + name: 'gf_submit_form_data', + description: 'Submit form data (triggers notifications, confirmations, payment)', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_id: { type: 'number', description: 'Form ID' }, + field_values: { type: 'object', description: 'Field values' } }, + additionalProperties: true, + required: ['form_id'] + } + }, + { + name: 'gf_validate_submission', + description: 'Validate submission without processing', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_id: { type: 'number', description: 'Form ID' } + }, + additionalProperties: true, + required: ['form_id'] + } + }, - // Add-on Feeds (7 tools) - { - name: 'gf_list_feeds', - description: 'List feeds. Filter by form_id and/or addon slug.', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - addon: { type: 'string', description: 'Addon slug' }, - form_id: { type: 'number', description: 'Form ID' }, - compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } - } + // Notifications (1 tool) + { + name: 'gf_send_notifications', + description: 'Send notifications for entry', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + entry_id: { type: 'number', description: 'Entry ID' }, + notification_ids: { + type: 'array', + items: { type: 'string' }, + description: 'Notification IDs to send' } }, - { - name: 'gf_get_feed', - description: 'Get a feed by ID.', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Feed ID' }, - compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } - }, - required: ['id'] - } + required: ['entry_id'] + } + }, + + // Add-on Feeds (7 tools) + { + name: 'gf_list_feeds', + description: 'List feeds. Filter by form_id and/or addon slug.', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + addon: { type: 'string', description: 'Addon slug' }, + form_id: { type: 'number', description: 'Form ID' }, + compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } + } + } + }, + { + name: 'gf_get_feed', + description: 'Get a feed by ID.', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Feed ID' }, + compact: { type: 'boolean', description: 'Return raw uncompacted data', default: true } }, - // gf_list_form_feeds removed — gf_list_feeds with form_id does the same thing - // and also supports addon filtering. Kept listFormFeeds() client method for - // backwards compatibility but no longer exposed as a tool. - { - name: 'gf_create_feed', - description: 'Create a feed', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - addon_slug: { type: 'string', description: 'Add-on slug' }, - form_id: { type: 'number', description: 'Form ID' }, - is_active: { type: 'boolean', description: 'Feed active state' }, - meta: { type: 'object', description: 'Feed config' } - }, - required: ['addon_slug', 'form_id', 'meta'] - } + required: ['id'] + } + }, + // gf_list_form_feeds removed — gf_list_feeds with form_id does the same thing + // and also supports addon filtering. Kept listFormFeeds() client method for + // backwards compatibility but no longer exposed as a tool. + { + name: 'gf_create_feed', + description: 'Create a feed', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + addon_slug: { type: 'string', description: 'Add-on slug' }, + form_id: { type: 'number', description: 'Form ID' }, + is_active: { type: 'boolean', description: 'Feed active state' }, + meta: { type: 'object', description: 'Feed config' } }, - { - name: 'gf_update_feed', - description: 'Update a feed (full replace)', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Feed ID' }, - is_active: { type: 'boolean', description: 'Feed active state' }, - meta: { type: 'object', description: 'Feed config' } - }, - required: ['id'] - } + required: ['addon_slug', 'form_id', 'meta'] + } + }, + { + name: 'gf_update_feed', + description: 'Update a feed (full replace)', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Feed ID' }, + is_active: { type: 'boolean', description: 'Feed active state' }, + meta: { type: 'object', description: 'Feed config' } }, - { - name: 'gf_patch_feed', - description: 'Patch a feed (partial update)', - annotations: { idempotentHint: false, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Feed ID' }, - is_active: { type: 'boolean', description: 'Feed active state' }, - meta: { type: 'object', description: 'Feed config' } - }, - required: ['id'] - } + required: ['id'] + } + }, + { + name: 'gf_patch_feed', + description: 'Patch a feed (partial update)', + annotations: { idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Feed ID' }, + is_active: { type: 'boolean', description: 'Feed active state' }, + meta: { type: 'object', description: 'Feed config' } }, - { - name: 'gf_delete_feed', - description: 'Delete a feed (requires ALLOW_DELETE=true)', - annotations: { destructiveHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - id: { type: 'number', description: 'Feed ID' } - }, - required: ['id'] - } + required: ['id'] + } + }, + { + name: 'gf_delete_feed', + description: 'Delete a feed (requires ALLOW_DELETE=true)', + annotations: { destructiveHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + id: { type: 'number', description: 'Feed ID' } }, + required: ['id'] + } + }, - // Field Filters (1 tool) - { - name: 'gf_get_field_filters', - description: 'Get field filters for form', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - form_id: { type: 'number', description: 'Form ID' } - }, - required: ['form_id'] - } + // Field Filters (1 tool) + { + name: 'gf_get_field_filters', + description: 'Get field filters for form', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_id: { type: 'number', description: 'Form ID' } }, + required: ['form_id'] + } + }, - // Results (1 tool) - { - name: 'gf_get_results', - description: 'Get quiz/poll/survey results', - annotations: { readOnlyHint: true, openWorldHint: true }, - inputSchema: { - type: 'object', - properties: { - form_id: { type: 'number', description: 'Form ID' } - }, - required: ['form_id'] - } + // Results (1 tool) + { + name: 'gf_get_results', + description: 'Get quiz/poll/survey results', + annotations: { readOnlyHint: true, openWorldHint: true }, + inputSchema: { + type: 'object', + properties: { + form_id: { type: 'number', description: 'Form ID' } }, + required: ['form_id'] + } + }, +]; + +// Tool names the dynamic abilities pipeline must never claim. +const RESERVED_TOOL_NAMES = new Set([ + ...GF_TOOL_DEFINITIONS.map((tool) => tool.name), + ...fieldOperationTools.map((tool) => tool.name), + 'gk_reload_abilities', +]); - // Field Operations (4 tools) - Intelligent field management - ...fieldOperationTools - ] +server.setRequestHandler(ListToolsRequestSchema, async () => { + // ListTools is the FIRST request Claude fires after the MCP + // handshake, so we lazily initialise here too — otherwise the + // tool list goes out before initializeClient() has had a chance + // to construct the GravityView client + start the abilities load. + if (!gravityFormsClient || !wpClient) { + try { await initializeClient(); } catch (_) { /* serve whatever planes are up */ } + } + // Best-effort wait for the abilities catalog. 2s covers a warm + // cold-start on dev.test (~800ms) plus headroom; if WP is + // genuinely unreachable the list ships without gv_* tools and the + // next gv_* call (or gk_reload_abilities) retries. + await ensureAbilitiesLoaded({ timeoutMs: 2000 }); + + // Gravity Forms tools are advertised only when that plane is live, so a + // WP-only install never lists gf_* tools that can't run. gk_reload_abilities + // is always present (the manual escape hatch after fixing a WP/cert issue); + // ability tools appear once the background catalog load succeeds. + const gkReloadDef = { + name: 'gk_reload_abilities', + description: 'Force a re-fetch of the WordPress Abilities API catalog and refresh the GravityKit product tool list. Use after fixing a WP / network / cert issue that prevented the eager background load from succeeding at MCP startup.', + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + inputSchema: { type: 'object', properties: {}, additionalProperties: false } + }; + return { + tools: buildToolList({ + gfReady: !!gravityFormsClient, + gfToolDefs: GF_TOOL_DEFINITIONS, + fieldOpTools: fieldOperationTools, + abilityDefs: abilityToolDefinitions, + gkReloadDef, + }) }; }); @@ -553,8 +763,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => { server.setRequestHandler(CallToolRequestSchema, async (request) => { const { name, arguments: params } = request.params; - // Ensure client is initialized - if (!gravityFormsClient) { + // Ensure capability planes are initialized. Per-plane failures + // surface as per-tool error responses below; throw only when + // neither plane has usable credentials. + if (!gravityFormsClient || !wpClient) { await initializeClient(); } @@ -659,8 +871,55 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { return await fieldOperationHandlers.gf_list_field_types(params, fieldOperations); }, params)(); + // GravityView Inspector — every gv_* tool routes through the + // abilities-derived handler map. Single dispatch keeps the switch + // readable; the map is rebuilt whenever the abilities catalog is + // (re)fetched. default: - return createErrorResponse(`Unknown tool: ${name}`); + if (name === 'gk_reload_abilities') { + if (!wpClient) { + return createErrorResponse( + 'WordPress client not initialized. Set GRAVITYKIT_WP_URL + GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD in .env (or reuse the GRAVITY_FORMS_* credentials).' + ); + } + const before = abilityToolDefinitions?.length ?? 0; + await ensureAbilitiesLoaded({ force: true }); + const after = abilityToolDefinitions?.length ?? 0; + return { + content: [{ + type: 'text', + text: JSON.stringify({ + loaded: !!abilityToolDefinitions, + ability_tool_count: after, + previous_count: before, + note: abilityToolDefinitions + ? 'Catalog refreshed. Clients receive `notifications/tools/list_changed` automatically.' + : 'Catalog still unreachable — check WP logs / cert / credentials. Will retry on next gv_* tool call.', + }, null, 2), + }], + }; + } + // Any other name is a dynamic GravityKit ability tool (any product + // prefix — gv_, gc_, …) or genuinely unknown. Self-heal the catalog + // when the WP plane is up, then route by handler-map membership so + // every product's tools dispatch, not just GravityView's. + if (wpClient) { + await ensureAbilitiesLoaded(); + } + switch (classifyAbilityCall({ name, hasWpClient: !!wpClient, handlers: abilityToolHandlers })) { + case 'dispatch': + return wrapViewHandler(() => abilityToolHandlers[name](params), params)(); + case 'no-wp-client': + return createErrorResponse( + 'WordPress client not initialized. Set GRAVITYKIT_WP_URL + GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD in .env (or reuse GRAVITY_FORMS_BASE_URL / GRAVITY_FORMS_CONSUMER_KEY / GRAVITY_FORMS_CONSUMER_SECRET when the same WP install hosts both surfaces).' + ); + case 'catalog-unreachable': + return createErrorResponse( + 'GravityKit abilities catalog unreachable — no product tools are available. Fix WP connectivity / credentials, then call gk_reload_abilities to refresh.' + ); + default: + return createErrorResponse(`Unknown tool: ${name}`); + } } }); @@ -670,10 +929,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => { async function main() { try { - // Initialize client on startup - await initializeClient(); - - // Create stdio transport + // Create stdio transport — client initialization is deferred to first tool call + // so the MCP handshake completes instantly (live site validation can take 3+ seconds) const transport = new StdioServerTransport(); // Connect server to transport diff --git a/src/server-runtime.js b/src/server-runtime.js new file mode 100644 index 0000000..398fe5e --- /dev/null +++ b/src/server-runtime.js @@ -0,0 +1,48 @@ +/** + * Pure helpers for the MCP server runtime, extracted from index.js so the + * two-plane behavior is unit-testable. See test/server-runtime.test.js. + */ + +/** + * Initialize the two capability planes. The WordPress plane must not be gated + * on the (potentially slow) Gravity Forms REST probe. + * @returns {Promise<{gfOk: boolean, wpOk: boolean}>} + */ +export async function runPlaneInit({ initGravityFormsPlane, initWordPressPlane }) { + // WP plane first (synchronous, instant — it fire-and-forgets the abilities + // load) so a slow GF REST probe never gates it. Then await the GF probe. + const wpOk = initWordPressPlane(); + const gfOk = await initGravityFormsPlane(); + if (!gfOk && !wpOk) { + throw new Error('Neither Gravity Forms nor WordPress credentials are usable. Set GRAVITY_FORMS_* and/or GRAVITYKIT_WP_* in .env.'); + } + return { gfOk, wpOk }; +} + +/** + * Assemble the advertised tool list. Gravity Forms tools are only listed when + * that plane is live (otherwise they'd error on call). gk_reload_abilities is + * always present; ability tools appear once the catalog loads. + */ +export function buildToolList({ gfReady, gfToolDefs = [], fieldOpTools = [], abilityDefs = [], gkReloadDef }) { + return [ + ...(gfReady ? [...gfToolDefs, ...fieldOpTools] : []), + ...(abilityDefs ?? []), + gkReloadDef, + ]; +} + +/** + * Decide how to route a call that wasn't a static Gravity Forms tool or + * gk_reload_abilities: dispatch to the dynamic ability handler map, or one of + * the error states. + * @returns {'dispatch'|'no-wp-client'|'catalog-unreachable'|'unknown'} + */ +export function classifyAbilityCall({ name, hasWpClient, handlers }) { + // Route by handler-map membership — product-agnostic, so any GravityKit + // prefix (gv_, gc_, …) dispatches as long as the catalog registered it. + if (handlers && Object.prototype.hasOwnProperty.call(handlers, name)) return 'dispatch'; + if (!hasWpClient) return 'no-wp-client'; + if (!handlers) return 'catalog-unreachable'; + return 'unknown'; +} diff --git a/src/version.js b/src/version.js new file mode 100644 index 0000000..a2f71ac --- /dev/null +++ b/src/version.js @@ -0,0 +1,14 @@ +/** + * Single source of truth for the package version and User-Agent. + * Read from package.json so the clients never drift from the released version. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const pkg = JSON.parse( + readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8') +); + +export const VERSION = pkg.version; +export const USER_AGENT = `GravityKit-MCP/${VERSION}`; diff --git a/src/wp-client.js b/src/wp-client.js new file mode 100644 index 0000000..adaf366 --- /dev/null +++ b/src/wp-client.js @@ -0,0 +1,106 @@ +/** + * Authenticated WordPress transport for GravityKit MCP. + * + * Product-agnostic: this is the client the abilities loader rides to + * reach the Foundation catalog (`/wp-json/gravitykit/v1/...`), the WP + * core Abilities API (`/wp-json/wp-abilities/v1/...`), and any other + * WP-root REST surface. Product-specific clients (e.g. the GravityView + * Inspector test client) extend it and add their own namespace. + * + * Authentication: WordPress Application Password via HTTP Basic Auth. + * The same WP install usually hosts the GF REST surface too, so when + * GRAVITYKIT_WP_* credentials aren't set we fall back to + * GRAVITY_FORMS_CONSUMER_KEY / GRAVITY_FORMS_CONSUMER_SECRET (which in + * practice are usually a WP user + app password as well — most + * local-dev setups reuse them rather than minting two credentials). + */ + +import axios from 'axios'; +import https from 'https'; +import { USER_AGENT } from './version.js'; +import { isLocalUrl } from './config/auth.js'; + +export class WordPressClient { + constructor(config) { + this.config = config || {}; + + const baseUrl = this.resolveBaseUrl(); + if (!baseUrl) { + throw new Error('WordPress client requires GRAVITYKIT_WP_URL or GRAVITY_FORMS_BASE_URL.'); + } + if (!baseUrl.startsWith('https://') && !baseUrl.startsWith('http://')) { + throw new Error('WordPress base URL must start with http:// or https://'); + } + + this.baseUrl = baseUrl.replace(/\/$/, ''); + + // This client always sends Basic auth (app password). Refuse to do that + // over a remote plain-HTTP URL — the credentials would travel in the + // clear. Local dev hosts (localhost, *.test, *.local) are fine; an + // explicit GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true overrides. Mirrors + // the Gravity Forms plane's guard (config/auth.js). + const allowHttpBasic = isLocalUrl(this.baseUrl) + || this.config.GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH === 'true'; + if (this.baseUrl.startsWith('http://') && !allowHttpBasic) { + throw new Error('Refusing to send Basic auth over a remote plain-HTTP URL — credentials would be exposed. Use HTTPS, or set GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH=true to override.'); + } + + // Auth resolution order: canonical GRAVITYKIT_WP_* (prod-style) → + // WORDPRESS_LOCAL_DEV_TEST_* (the local dev.test admin creds; same + // values reused by any other MonoKit tool that hits the local + // install) → generic WP_USERNAME → GF MCP consumer key fallback. + // The descriptive local-dev names exist so this single admin + // credential isn't duplicated across every per-product env block. + const username = this.config.GRAVITYKIT_WP_USERNAME + || this.config.WORDPRESS_LOCAL_DEV_TEST_ADMIN_USER + || this.config.WP_USERNAME + || this.config.GRAVITY_FORMS_CONSUMER_KEY; + const password = this.config.GRAVITYKIT_WP_APP_PASSWORD + || this.config.WORDPRESS_LOCAL_DEV_TEST_ADMIN_PASSWORD + || this.config.WP_APP_PASSWORD + || this.config.GRAVITY_FORMS_CONSUMER_SECRET; + if (!username || !password) { + throw new Error('WordPress client requires credentials. Set GRAVITYKIT_WP_USERNAME + GRAVITYKIT_WP_APP_PASSWORD, or WORDPRESS_LOCAL_DEV_TEST_ADMIN_USER + _ADMIN_PASSWORD, or reuse GRAVITY_FORMS_CONSUMER_KEY/SECRET.'); + } + this.basicAuth = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64'); + + this.allowSelfSigned = (this.config.GRAVITY_FORMS_ALLOW_SELF_SIGNED_CERTS || this.config.MCP_ALLOW_SELF_SIGNED_CERTS) === 'true'; + this.timeoutMs = parseInt(this.config.GRAVITYKIT_TIMEOUT || this.config.GRAVITY_FORMS_TIMEOUT, 10) || 30000; + + // Rooted at the WP install. Subclasses may replace this with a + // namespaced instance via createHttpClient(); callers that need a + // different root per request (the abilities loader) pass an + // explicit `baseURL` in the request config, which wins either way. + this.httpClient = this.createHttpClient(this.baseUrl); + } + + resolveBaseUrl() { + return this.config.GRAVITYKIT_WP_URL + || this.config.WORDPRESS_LOCAL_DEV_TEST_URL + || this.config.GRAVITY_FORMS_BASE_URL + || ''; + } + + /** + * Build an axios instance carrying this client's auth, timeout, and + * TLS settings. Subclasses use it to mount namespaced clients. + * + * @param {string} baseURL Absolute base URL for the instance. + * @returns {import('axios').AxiosInstance} + */ + createHttpClient(baseURL) { + return axios.create({ + baseURL, + timeout: this.timeoutMs, + headers: { + 'User-Agent': USER_AGENT, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'Authorization': this.basicAuth, + }, + httpsAgent: new https.Agent({ rejectUnauthorized: !this.allowSelfSigned }), + }); + } +} + +export default WordPressClient; diff --git a/test/abilities-loader.test.js b/test/abilities-loader.test.js new file mode 100644 index 0000000..28208bf --- /dev/null +++ b/test/abilities-loader.test.js @@ -0,0 +1,514 @@ +/** + * Tests for the GravityView abilities-loader. + * + * Guards the MCP `tools/list` contract — every auto-generated tool MUST + * present an `inputSchema` of shape `{ type: 'object', properties: , … }` + * or Claude Code's MCP client rejects the entire catalog with a Zod + * validation error (this happened in the wild: tools 29–36 had an array + * `inputSchema`, tool 57 had `properties: []`). + */ + +import { TestRunner, TestAssert } from './helpers.js'; +import { + normalizeInputSchema, + loadAbilitiesAsTools, + methodForAbility, + FOUNDATION_CATALOG_ROUTE, + CORE_ABILITIES_ROUTE, +} from '../src/abilities/loader.js'; + +const suite = new TestRunner('Abilities Loader Tests'); + +/** + * The MCP-contract assertion: every generated tool's inputSchema must + * satisfy these invariants. Mirrors the shape `@modelcontextprotocol/sdk` + * validates with Zod under `ListToolsRequestSchema`. + */ +function assertValidMcpInputSchema(schema, label = 'inputSchema') { + TestAssert.isTrue( + schema !== null && typeof schema === 'object' && !Array.isArray(schema), + `${label}: must be a plain object, got ${Array.isArray(schema) ? 'array' : typeof schema}`, + ); + TestAssert.equal(schema.type, 'object', `${label}.type must be "object"`); + TestAssert.isTrue( + schema.properties !== null + && typeof schema.properties === 'object' + && !Array.isArray(schema.properties), + `${label}.properties must be a Record, got ${Array.isArray(schema.properties) ? 'array' : typeof schema.properties}`, + ); +} + +// --------------------------------------------------------------------------- +// normalizeInputSchema unit tests — cover every shape we've seen the WP +// Abilities API emit (or any shape PHP could plausibly emit). +// --------------------------------------------------------------------------- + +suite.test('normalizeInputSchema: passes a valid schema through unchanged', () => { + const valid = { + type: 'object', + properties: { id: { type: 'integer' }, name: { type: 'string' } }, + required: ['id'], + }; + const out = normalizeInputSchema(valid); + assertValidMcpInputSchema(out); + TestAssert.deepEqual(out.properties, valid.properties); + TestAssert.deepEqual(out.required, ['id']); +}); + +suite.test('normalizeInputSchema: wraps a top-level array (tools 29-36 bug)', () => { + // The bug Claude Code surfaced: abilities 29-36 emitted `input_schema` + // as a raw array, blowing MCP's `expected object, received array` Zod check. + const arrayShaped = [ + { name: 'view_id', type: 'integer', required: true, description: 'The View ID.' }, + { name: 'compact', type: 'boolean', description: 'Strip empty fields.' }, + ]; + const out = normalizeInputSchema(arrayShaped); + assertValidMcpInputSchema(out); + TestAssert.isTrue('view_id' in out.properties, 'view_id property derived from entry.name'); + TestAssert.isTrue('compact' in out.properties, 'compact property derived from entry.name'); + TestAssert.deepEqual(out.required, ['view_id'], 'required: true lifts to outer required array'); + // Ensure the descriptor's `name` was stripped from the value (now it's the key). + TestAssert.equal(out.properties.view_id.name, undefined); + TestAssert.equal(out.properties.view_id.type, 'integer'); +}); + +suite.test('normalizeInputSchema: coerces properties: [] (tool 57 bug)', () => { + // PHP serialises an empty associative array as JSON `[]`. When `properties` + // hits the MCP client like that, Zod fails with `expected record, received array`. + const objectWithArrayProps = { type: 'object', properties: [] }; + const out = normalizeInputSchema(objectWithArrayProps); + assertValidMcpInputSchema(out); + TestAssert.deepEqual(out.properties, {}); +}); + +suite.test('normalizeInputSchema: coerces properties: [descriptor, …]', () => { + // Non-empty array under `properties`, same descriptor format as the + // top-level-array case but nested. Treat it as a property descriptor list. + const schema = { + type: 'object', + properties: [ + { name: 'slot', type: 'integer' }, + { name: 'ref', type: 'string', required: true }, + ], + }; + const out = normalizeInputSchema(schema); + assertValidMcpInputSchema(out); + TestAssert.deepEqual(Object.keys(out.properties).sort(), ['ref', 'slot']); + TestAssert.deepEqual(out.required, ['ref']); +}); + +suite.test('normalizeInputSchema: missing input_schema → open object', () => { + for (const empty of [undefined, null, false]) { + const out = normalizeInputSchema(empty); + assertValidMcpInputSchema(out, `normalize(${empty})`); + TestAssert.deepEqual(out.properties, {}); + TestAssert.equal(out.additionalProperties, true); + } +}); + +suite.test('normalizeInputSchema: forces type:"object" when upstream omits it', () => { + // Some abilities ship `properties` but forget `type` — common in + // hand-written PHP schemas. + const out = normalizeInputSchema({ properties: { id: { type: 'integer' } } }); + assertValidMcpInputSchema(out); + TestAssert.equal(out.type, 'object'); +}); + +suite.test('normalizeInputSchema: preserves sibling keys (required, additionalProperties, …)', () => { + const out = normalizeInputSchema({ + type: 'object', + properties: { foo: { type: 'string' } }, + required: ['foo'], + additionalProperties: false, + description: 'A widget', + }); + TestAssert.deepEqual(out.required, ['foo']); + TestAssert.equal(out.additionalProperties, false); + TestAssert.equal(out.description, 'A widget'); +}); + +suite.test('normalizeInputSchema: anonymous array entries get arg keys', () => { + // Defensive: if a descriptor lacks name/slug/key/title, we shouldn't + // silently drop it — we synthesize a key so the agent can still bind it. + const out = normalizeInputSchema([{ type: 'string' }, { type: 'integer' }]); + assertValidMcpInputSchema(out); + TestAssert.deepEqual(Object.keys(out.properties).sort(), ['arg0', 'arg1']); +}); + +suite.test('normalizeInputSchema: never mutates its input', () => { + const input = { type: 'object', properties: [] }; + const before = JSON.stringify(input); + normalizeInputSchema(input); + TestAssert.equal(JSON.stringify(input), before, 'input was mutated'); +}); + +// --------------------------------------------------------------------------- +// Integration: drive loadAbilitiesAsTools with a synthetic catalog that +// reproduces the wire-format Zod failures, and confirm every generated +// tool now satisfies the MCP contract. +// --------------------------------------------------------------------------- + +/** + * Stub gvClient for the WP-core fallback path: the Foundation catalog + * 404s (older Foundation without gravitykit/v1), the core catalog + * serves `catalog`. Records every request config in `requests`. + */ +function buildStubGvClient(catalog) { + const requests = []; + return { + baseUrl: 'https://test.invalid', + requests, + httpClient: { + request: async (config) => { + requests.push(config); + if (config.url === FOUNDATION_CATALOG_ROUTE) { + const err = new Error('Request failed with status code 404'); + err.response = { status: 404 }; + throw err; + } + return { data: catalog, headers: {} }; + }, + }, + }; +} + +/** + * Stub gvClient whose Foundation catalog responds with the given pages + * (array of item-arrays; X-WP-TotalPages = pages.length). Core-catalog + * requests serve `coreCatalog`. Records every request config in + * `requests` so tests can assert handler execution wiring. + */ +function buildCatalogStubGvClient(pages, { coreCatalog = [] } = {}) { + const requests = []; + return { + baseUrl: 'https://test.invalid', + requests, + httpClient: { + request: async (config) => { + requests.push(config); + if (config.url === FOUNDATION_CATALOG_ROUTE) { + const page = config.params?.page || 1; + return { + data: pages[page - 1] || [], + headers: { 'x-wp-totalpages': String(pages.length) }, + }; + } + if (config.url === CORE_ABILITIES_ROUTE) { + return { data: coreCatalog, headers: {} }; + } + // Ability /run executions. + return { data: { ok: true }, headers: {} }; + }, + }, + }; +} + +/** + * Synthetic WP-core catalog covering the three failure modes + a healthy + * ability. GravityKit items carry the `gk_registered_by` stamp and the + * `mcp_tool_name` Foundation applies to every ability it registers — + * the core-path filter + naming contract. + */ +function syntheticCatalog() { + return [ + // Healthy reference — must round-trip untouched. + { + name: 'gk-gravityview/layouts-list', + description: 'List installed layouts', + input_schema: { type: 'object', properties: { compact: { type: 'boolean' } } }, + meta: { gk_registered_by: 'gravitykit', mcp_tool_name: 'gv_layouts_list', annotations: { readonly: true } }, + }, + // Bug shape #1 — input_schema is itself an array (tools 29-36). + { + name: 'gk-gravityview/view-field-add', + description: 'Add a field to a View', + input_schema: [ + { name: 'view_id', type: 'integer', required: true }, + { name: 'field_id', type: 'string', required: true }, + ], + meta: { gk_registered_by: 'gravitykit', mcp_tool_name: 'gv_view_field_add', annotations: {} }, + }, + // Bug shape #2 — properties is an array (tool 57). + { + name: 'gk-multiple-forms/list-joins', + description: 'List joins', + input_schema: { type: 'object', properties: [] }, + meta: { gk_registered_by: 'gravitykit', mcp_tool_name: 'gk_list_joins', annotations: { readonly: true } }, + }, + // Another plugin's ability — no Foundation stamp, must be filtered out. + { + name: 'core/unrelated-ability', + description: 'Should not be exposed', + input_schema: { type: 'object', properties: {} }, + meta: { annotations: {} }, + }, + ]; +} + +suite.test('core fallback: filters on Foundation\'s gk_registered_by stamp, unstamped abilities excluded', async () => { + const { definitions, count, source } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); + TestAssert.equal(source, 'wp-core', 'catalog 404 must route to the WP-core path'); + TestAssert.equal(count, 3, 'expected 3 stamped abilities, got ' + count); + TestAssert.equal(definitions.length, 3, 'definitions count must match'); + const names = definitions.map((d) => d.name).sort(); + TestAssert.deepEqual(names, ['gk_list_joins', 'gv_layouts_list', 'gv_view_field_add']); +}); + +suite.test('core fallback: cross-product abilities included; meta.mcp_tool_name beats gv_ derivation', async () => { + const catalog = [ + ...syntheticCatalog(), + { + // A different GravityKit product — included via the same stamp, + // named by the server, not the gv_ derivation. + name: 'gk-gravitycharts/charts-list', + description: 'List charts', + input_schema: { type: 'object', properties: {} }, + meta: { + gk_registered_by: 'gravitykit', + mcp_tool_name: 'gc_charts_list', + annotations: { readonly: true }, + }, + }, + ]; + const { definitions, source } = await loadAbilitiesAsTools(buildStubGvClient(catalog)); + TestAssert.equal(source, 'wp-core'); + const names = definitions.map((d) => d.name).sort(); + TestAssert.deepEqual(names, ['gc_charts_list', 'gk_list_joins', 'gv_layouts_list', 'gv_view_field_add']); +}); + +// --------------------------------------------------------------------------- +// Foundation catalog path — the canonical source. Items use the +// gravitykit/v1 Manager::to_rest_item() shape: top-level `annotations`, +// `enabled`, `mcp_tool_name`; already GravityKit-only server-side. +// --------------------------------------------------------------------------- + +function syntheticFoundationCatalog() { + return [ + { + name: 'gk-gravityview/views-list', + label: 'List Views', + description: 'List editable Views.', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + mcp_tool_name: 'gv_views_list', + }, + { + // Cross-product — the catalog path trusts the server's + // GravityKit-only filtering; no client-side product list. + name: 'gk-gravitycharts/charts-list', + label: 'List Charts', + description: 'List charts.', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + mcp_tool_name: 'gc_charts_list', + }, + { + // Defensive: the server omits disabled by default, but if one + // arrives flagged enabled:false it must be skipped. + name: 'gk-gravityview/view-status-set', + description: 'Disabled ability', + input_schema: { type: 'object', properties: {} }, + annotations: {}, + enabled: false, + mcp_tool_name: 'gv_view_status_set', + }, + { + // No mcp_tool_name → must be SKIPPED with a warning; the client + // never invents tool names. + name: 'gk-gravityview/layouts-list', + description: 'List layouts', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + }, + ]; +} + +suite.test('catalog path: server-owned naming; disabled and unnamed items skipped', async () => { + const stub = buildCatalogStubGvClient([syntheticFoundationCatalog()]); + const { definitions, count, source } = await loadAbilitiesAsTools(stub); + TestAssert.equal(source, 'foundation-catalog'); + const names = definitions.map((d) => d.name).sort(); + TestAssert.deepEqual(names, ['gc_charts_list', 'gv_views_list']); + TestAssert.equal(count, 2); +}); + +suite.test('catalog path: handlers execute via /wp-abilities/v1 run route with annotation-derived method', async () => { + const stub = buildCatalogStubGvClient([syntheticFoundationCatalog()]); + const { handlers } = await loadAbilitiesAsTools(stub); + await handlers.gc_charts_list({}); + const run = stub.requests.find((r) => typeof r.url === 'string' && r.url.includes('/run')); + TestAssert.isTrue(!!run, 'handler must hit the run endpoint'); + TestAssert.equal(run.url, '/wp-json/wp-abilities/v1/abilities/gk-gravitycharts/charts-list/run'); + TestAssert.equal(run.method, 'GET'); +}); + +suite.test('catalog path: paginates via X-WP-TotalPages', async () => { + const items = syntheticFoundationCatalog(); + const stub = buildCatalogStubGvClient([[items[0]], [items[1]]]); + const { count, source } = await loadAbilitiesAsTools(stub); + TestAssert.equal(source, 'foundation-catalog'); + TestAssert.equal(count, 2); +}); + +suite.test('catalog path: tool-name collision — first wins, later skipped, never shadowed', async () => { + const colliding = [ + { + name: 'gk-gravityview/views-list', + description: 'first claimant', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + mcp_tool_name: 'gv_views_list', + }, + { + name: 'gk-gravityboard/views-list', + description: 'colliding claimant', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + mcp_tool_name: 'gv_views_list', + }, + ]; + const stub = buildCatalogStubGvClient([colliding]); + const { definitions, handlers, count } = await loadAbilitiesAsTools(stub); + TestAssert.equal(count, 1, 'collision must not produce two tools'); + TestAssert.equal(definitions[0].description, 'first claimant'); + await handlers.gv_views_list({}); + const run = stub.requests.find((r) => typeof r.url === 'string' && r.url.includes('/run')); + TestAssert.equal(run.url, '/wp-json/wp-abilities/v1/abilities/gk-gravityview/views-list/run', 'handler must stay bound to the first claimant'); +}); + +suite.test('coexistence: Gravity Forms own abilities (feature-abilities-api) are never surfaced', async () => { + // Exact shape GF's branch registers (GF_Abilities_Registry::definition): + // gravityforms/* namespace, meta.mcp + annotations + show_in_rest:true, + // and NO gk_registered_by stamp. show_in_rest means these DO appear in + // the WP core catalog our fallback reads — the metadata filter is the + // only thing keeping them out. + const catalog = [ + ...syntheticCatalog(), + { + name: 'gravityforms/forms-list', + label: 'List Forms', + description: 'Lists Gravity Forms forms.', + input_schema: { type: 'object', properties: {} }, + meta: { + mcp: { public: true }, + annotations: { readonly: true, destructive: false, idempotent: true }, + show_in_rest: true, + }, + }, + { + // GF add-on convention uses a second slash. + name: 'gravityforms/myaddon/my-action', + description: 'Add-on ability.', + input_schema: { type: 'object', properties: {} }, + meta: { mcp: { public: true }, annotations: {}, show_in_rest: true }, + }, + ]; + const { definitions, source } = await loadAbilitiesAsTools(buildStubGvClient(catalog)); + TestAssert.equal(source, 'wp-core'); + const names = definitions.map((d) => d.name); + TestAssert.isTrue(!names.some((n) => n.includes('forms_list')), 'GF core abilities must not become tools'); + TestAssert.isTrue(!names.some((n) => n.includes('my_action')), 'GF add-on abilities must not become tools'); + TestAssert.equal(definitions.length, 3, 'only the gk_registered_by-stamped abilities surface'); +}); + +suite.test('reserved names: catalog tools can never shadow the built-in gf_* contract', async () => { + const colliding = [ + { + // Hypothetical future gk-gravity-forms ability whose server name + // collides with a released built-in tool — must be skipped. + name: 'gk-gravity-forms/forms-list-legacy', + description: 'Catalog claimant for a built-in name', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + mcp_tool_name: 'gf_list_forms', + }, + { + name: 'gk-gravityview/views-list', + description: 'Safe name', + input_schema: { type: 'object', properties: {} }, + annotations: { readonly: true }, + enabled: true, + mcp_tool_name: 'gv_views_list', + }, + ]; + const stub = buildCatalogStubGvClient([colliding]); + const { definitions, handlers, count } = await loadAbilitiesAsTools(stub, { + reservedNames: new Set(['gf_list_forms', 'gk_reload_abilities']), + }); + TestAssert.equal(count, 1, 'reserved-name claimant must be skipped'); + TestAssert.deepEqual(definitions.map((d) => d.name), ['gv_views_list']); + TestAssert.equal(handlers.gf_list_forms, undefined, 'no handler may bind to a reserved name'); +}); + +suite.test('empty catalog → falls back to WP core path', async () => { + const stub = buildCatalogStubGvClient([[]], { coreCatalog: syntheticCatalog() }); + const { source, count } = await loadAbilitiesAsTools(stub); + TestAssert.equal(source, 'wp-core'); + TestAssert.equal(count, 3); +}); + +suite.test('loadAbilitiesAsTools: EVERY generated tool has a valid MCP inputSchema', async () => { + // The contract check that would have caught the production regression. + const catalog = syntheticCatalog(); + const { definitions } = await loadAbilitiesAsTools(buildStubGvClient(catalog)); + for (const def of definitions) { + assertValidMcpInputSchema(def.inputSchema, `${def.name}.inputSchema`); + } +}); + +suite.test('loadAbilitiesAsTools: tools 29-36 repro — array input_schema is wrapped', async () => { + const { definitions } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); + const tool = definitions.find((d) => d.name === 'gv_view_field_add'); + TestAssert.isTrue(!!tool, 'gv_view_field_add must exist'); + assertValidMcpInputSchema(tool.inputSchema); + TestAssert.isTrue('view_id' in tool.inputSchema.properties); + TestAssert.isTrue('field_id' in tool.inputSchema.properties); + TestAssert.deepEqual(tool.inputSchema.required.sort(), ['field_id', 'view_id']); +}); + +suite.test('loadAbilitiesAsTools: tool 57 repro — properties:[] becomes properties:{}', async () => { + const { definitions } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); + const tool = definitions.find((d) => d.name === 'gk_list_joins'); + TestAssert.isTrue(!!tool, 'gk_list_joins must exist'); + assertValidMcpInputSchema(tool.inputSchema); + TestAssert.deepEqual(tool.inputSchema.properties, {}); +}); + +suite.test('loadAbilitiesAsTools: healthy schema passes through untouched', async () => { + const { definitions } = await loadAbilitiesAsTools(buildStubGvClient(syntheticCatalog())); + const tool = definitions.find((d) => d.name === 'gv_layouts_list'); + TestAssert.isTrue(!!tool); + TestAssert.deepEqual(tool.inputSchema.properties, { compact: { type: 'boolean' } }); +}); + +// --------------------------------------------------------------------------- +// Lightweight smoke for the existing helpers — these have no tests yet and +// regressions here would silently mis-route every gv_* call. +// --------------------------------------------------------------------------- + +suite.test('methodForAbility: readonly → GET, destructive+idempotent → DELETE, else POST', () => { + TestAssert.equal(methodForAbility({ readonly: true }), 'GET'); + TestAssert.equal(methodForAbility({ destructive: true, idempotent: true }), 'DELETE'); + // Destructive but NOT idempotent (e.g. view-delete with default soft trash) + // must POST — Foundation's run controller rejects DELETE on these with 405. + TestAssert.equal(methodForAbility({ destructive: true, idempotent: false }), 'POST'); + TestAssert.equal(methodForAbility({ destructive: true }), 'POST'); + TestAssert.equal(methodForAbility({}), 'POST'); + TestAssert.equal(methodForAbility(), 'POST'); +}); + +// Standalone runner +const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/.*\//, '')); +if (isMain) { + suite.run().then((results) => { + process.exit(results.failed > 0 ? 1 : 0); + }); +} + +export default suite; diff --git a/test/ability-catalog.test.js b/test/ability-catalog.test.js new file mode 100644 index 0000000..dbcd9c5 --- /dev/null +++ b/test/ability-catalog.test.js @@ -0,0 +1,52 @@ +/** + * Unit tests for collectAbilityNames (abilities-catalog pagination). + */ + +import test from 'node:test'; +import assert from 'node:assert'; +import { collectAbilityNames } from '../scripts/lib/ability-catalog.mjs'; + +// Mock WordPressClient: serves one array of abilities per page and reports +// the page count via the X-WP-TotalPages header, like the real endpoint. +function mockClient(pages) { + return { + baseUrl: 'http://example.test', + httpClient: { + request: async ({ params }) => ({ + data: pages[(params?.page ?? 1) - 1] ?? [], + headers: { 'x-wp-totalpages': String(pages.length) }, + }), + }, + }; +} + +test('collectAbilityNames: accumulates matching names across ALL pages', async () => { + const client = mockClient([ + [{ name: 'gk-gravityview/view-create' }, { name: 'core/get-site-info' }], + [{ name: 'gk-gravityview/views-scan' }], + ]); + const names = await collectAbilityNames(client); + assert.deepEqual([...names].sort(), ['gk-gravityview/view-create', 'gk-gravityview/views-scan']); +}); + +test('collectAbilityNames: filters by prefix', async () => { + const client = mockClient([[{ name: 'gk-gravityview/view-create' }, { name: 'core/get-site-info' }]]); + const names = await collectAbilityNames(client); + assert.deepEqual([...names], ['gk-gravityview/view-create']); +}); + +test('collectAbilityNames: single page when only one page exists', async () => { + const client = mockClient([[{ name: 'gk-gravityview/layouts-list' }]]); + const names = await collectAbilityNames(client); + assert.deepEqual([...names], ['gk-gravityview/layouts-list']); +}); + +test('collectAbilityNames: collects every GravityKit product namespace, not just gravityview', async () => { + const client = mockClient([[ + { name: 'gk-gravityview/layouts-list' }, + { name: 'gk-multiple-forms/list-joins' }, + { name: 'core/get-site-info' }, + ]]); + const names = await collectAbilityNames(client); + assert.deepEqual([...names].sort(), ['gk-gravityview/layouts-list', 'gk-multiple-forms/list-joins']); +}); diff --git a/src/tests/authentication.test.js b/test/authentication.test.js similarity index 84% rename from src/tests/authentication.test.js rename to test/authentication.test.js index 0ca2ecb..a26a663 100644 --- a/src/tests/authentication.test.js +++ b/test/authentication.test.js @@ -3,8 +3,8 @@ * Tests Basic Auth (primary) and OAuth 1.0a (secondary) authentication methods */ -import { AuthManager, BasicAuthHandler, OAuth1Handler, validateRestApiAccess } from '../config/auth.js'; -import { TestRunner, TestAssert, MockHttpClient, MockResponse, setupTestEnvironment } from './helpers.js'; +import { AuthManager, BasicAuthHandler, OAuth1Handler, validateRestApiAccess } from '../src/config/auth.js'; +import { TestRunner, TestAssert, MockHttpClient, MockResponse, setupTestEnvironment, isMainModule } from './helpers.js'; const suite = new TestRunner('Authentication Tests'); @@ -183,7 +183,21 @@ suite.test('AuthManager: Should use OAuth when specified', () => { TestAssert.isFalse(info.recommended); }); -suite.test('AuthManager: Should fallback to OAuth for HTTP URLs', () => { +suite.test('AuthManager: remote HTTP with default method falls back to OAuth', () => { + const config = { + ...testEnv, + GRAVITY_FORMS_BASE_URL: 'http://insecure.com' + }; + delete config.GRAVITY_FORMS_AUTH_METHOD; + + const manager = new AuthManager(config); + const info = manager.getAuthInfo(); + + TestAssert.equal(info.method, 'OAuth 1.0a'); + TestAssert.isFalse(info.secure); +}); + +suite.test('AuthManager: explicit basic is honored over remote HTTP', () => { const config = { ...testEnv, GRAVITY_FORMS_BASE_URL: 'http://insecure.com', @@ -193,10 +207,39 @@ suite.test('AuthManager: Should fallback to OAuth for HTTP URLs', () => { const manager = new AuthManager(config); const info = manager.getAuthInfo(); - TestAssert.equal(info.method, 'OAuth 1.0a'); + TestAssert.equal(info.method, 'Basic Authentication'); TestAssert.isFalse(info.secure); }); +suite.test('AuthManager: ck_/cs_ key pair over HTTP auto-selects OAuth (GF only checks key Basic over SSL)', () => { + for (const baseUrl of ['http://localhost:8893', 'http://dev.test', 'http://remote.example.com']) { + const config = { ...testEnv, GRAVITY_FORMS_BASE_URL: baseUrl }; + delete config.GRAVITY_FORMS_AUTH_METHOD; // ck_test_key / cs_test_secret from testEnv + + const manager = new AuthManager(config); + const info = manager.getAuthInfo(); + + TestAssert.equal(info.method, 'OAuth 1.0a', `${baseUrl} with a key pair should sign with OAuth`); + } +}); + +suite.test('AuthManager: app-password credentials get Basic on local HTTP (WP core auth path)', () => { + for (const baseUrl of ['http://localhost:8893', 'http://dev.test', 'http://127.0.0.1:8080', 'http://mysite.local']) { + const config = { + ...testEnv, + GRAVITY_FORMS_BASE_URL: baseUrl, + GRAVITY_FORMS_CONSUMER_KEY: 'admin', + GRAVITY_FORMS_CONSUMER_SECRET: 'abcd efgh ijkl mnop qrst uvwx' + }; + delete config.GRAVITY_FORMS_AUTH_METHOD; + + const manager = new AuthManager(config); + const info = manager.getAuthInfo(); + + TestAssert.equal(info.method, 'Basic Authentication', `${baseUrl} with app-password creds should use Basic`); + } +}); + suite.test('AuthManager: Should remove trailing slash from base URL', () => { const config = { ...testEnv, @@ -325,7 +368,7 @@ suite.test('Failure Mode: Should handle malformed OAuth signature', () => { }); // Run tests when executed directly -const isMain = process.argv[1] && import.meta.url.endsWith(process.argv[1].replace(/.*\//, "")); +const isMain = isMainModule(import.meta.url, process.argv[1]); if (isMain) { suite.run().then(results => { process.exit(results.failed > 0 ? 1 : 0); diff --git a/src/tests/bug-fixes.test.js b/test/bug-fixes.test.js similarity index 94% rename from src/tests/bug-fixes.test.js rename to test/bug-fixes.test.js index da30e13..3d7208b 100644 --- a/src/tests/bug-fixes.test.js +++ b/test/bug-fixes.test.js @@ -12,8 +12,8 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -const srcDir = join(__dirname, '..'); -const projectDir = join(__dirname, '..', '..'); +const srcDir = join(__dirname, '..', 'src'); +const projectDir = join(__dirname, '..'); const suite = new TestRunner('Bug Fix Regression Tests'); @@ -200,7 +200,7 @@ suite.test('Bug #5: Delete tools have destructiveHint: true', () => { // ================================= suite.test('Bug #7: validateField strips _variant and _meta from output', async () => { - const { FieldAwareValidator } = await import('../config/field-validation.js'); + const { FieldAwareValidator } = await import('../src/config/field-validation.js'); const field = { id: 1, @@ -215,7 +215,7 @@ suite.test('Bug #7: validateField strips _variant and _meta from output', async }); suite.test('Bug #7: validateFormFields strips _variant and _meta from all fields', async () => { - const { FieldAwareValidator } = await import('../config/field-validation.js'); + const { FieldAwareValidator } = await import('../src/config/field-validation.js'); const fields = [ { id: 1, type: 'text', label: 'Text Field' }, @@ -234,7 +234,7 @@ suite.test('Bug #7: validateFormFields strips _variant and _meta from all fields // ================================= suite.test('Bug #8: gf_add_field throws errors instead of swallowing them', async () => { - const { fieldOperationHandlers } = await import('../field-operations/index.js'); + const { fieldOperationHandlers } = await import('../src/field-operations/index.js'); // Create a mock fieldManager that throws const mockFieldOps = { @@ -261,7 +261,7 @@ suite.test('Bug #8: gf_add_field throws errors instead of swallowing them', asyn }); suite.test('Bug #8: gf_update_field throws errors instead of swallowing them', async () => { - const { fieldOperationHandlers } = await import('../field-operations/index.js'); + const { fieldOperationHandlers } = await import('../src/field-operations/index.js'); const mockFieldOps = { fieldManager: { @@ -283,7 +283,7 @@ suite.test('Bug #8: gf_update_field throws errors instead of swallowing them', a }); suite.test('Bug #8: gf_delete_field throws errors instead of swallowing them', async () => { - const { fieldOperationHandlers } = await import('../field-operations/index.js'); + const { fieldOperationHandlers } = await import('../src/field-operations/index.js'); const mockFieldOps = { fieldManager: { @@ -309,7 +309,7 @@ suite.test('Bug #8: gf_delete_field throws errors instead of swallowing them', a // ================================= suite.test('Bug #9: Name field registry has correct sub-input mapping', async () => { - const { fieldRegistry } = await import('../field-definitions/field-registry.js'); + const { fieldRegistry } = await import('../src/field-definitions/field-registry.js'); const nameField = fieldRegistry.name; TestAssert.exists(nameField, 'Name field should exist in registry'); @@ -325,7 +325,7 @@ suite.test('Bug #9: Name field registry has correct sub-input mapping', async () }); suite.test('Bug #9: generateCompoundInputs matches registry for name field', async () => { - const { generateCompoundInputs } = await import('../field-definitions/field-registry.js'); + const { generateCompoundInputs } = await import('../src/field-definitions/field-registry.js'); const field = { id: 5, type: 'name', nameFormat: 'advanced' }; const inputs = generateCompoundInputs(field); @@ -410,8 +410,8 @@ suite.test('Bug #23: mcp.json version matches package.json version', () => { // ================================= suite.test('Bug #24: Filtering by "conditional" feature returns >0 results', async () => { - const { fieldOperationHandlers } = await import('../field-operations/index.js'); - const fieldRegistry = (await import('../field-definitions/field-registry.js')).default; + const { fieldOperationHandlers } = await import('../src/field-operations/index.js'); + const fieldRegistry = (await import('../src/field-definitions/field-registry.js')).default; const result = await fieldOperationHandlers.gf_list_field_types( { feature: 'conditional' }, diff --git a/src/tests/checkbox-expansion.test.js b/test/checkbox-expansion.test.js similarity index 99% rename from src/tests/checkbox-expansion.test.js rename to test/checkbox-expansion.test.js index 2b6432a..34b0d6e 100644 --- a/src/tests/checkbox-expansion.test.js +++ b/test/checkbox-expansion.test.js @@ -10,7 +10,7 @@ * option/quiz/survey/poll checkbox variants, multiselect, radio fallback. */ -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert, diff --git a/src/tests/compact.test.js b/test/compact.test.js similarity index 99% rename from src/tests/compact.test.js rename to test/compact.test.js index 4ffe396..861d7a1 100644 --- a/src/tests/compact.test.js +++ b/test/compact.test.js @@ -7,7 +7,7 @@ * Preserves: false, 0, "0" */ -import { stripEmpty, stripEntryMeta, stripEntryMetaFromResponse } from '../utils/compact.js'; +import { stripEmpty, stripEntryMeta, stripEntryMetaFromResponse } from '../src/utils/compact.js'; import { TestRunner, TestAssert } from './helpers.js'; const runner = new TestRunner('Compact Utility Tests'); diff --git a/src/tests/entries.test.js b/test/entries.test.js similarity index 99% rename from src/tests/entries.test.js rename to test/entries.test.js index f5bf347..967515d 100644 --- a/src/tests/entries.test.js +++ b/test/entries.test.js @@ -3,7 +3,7 @@ * Tests all 6 entries management tools with comprehensive coverage */ -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert, diff --git a/src/tests/feeds.test.js b/test/feeds.test.js similarity index 99% rename from src/tests/feeds.test.js rename to test/feeds.test.js index f887125..628c0a3 100644 --- a/src/tests/feeds.test.js +++ b/test/feeds.test.js @@ -3,7 +3,7 @@ * Tests all 7 add-on feed management tools */ -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert, diff --git a/src/tests/field-dependencies.test.js b/test/field-dependencies.test.js similarity index 99% rename from src/tests/field-dependencies.test.js rename to test/field-dependencies.test.js index 892ebde..275ca59 100644 --- a/src/tests/field-dependencies.test.js +++ b/test/field-dependencies.test.js @@ -5,7 +5,7 @@ import test from 'node:test'; import assert from 'node:assert'; -import { DependencyTracker } from '../field-operations/field-dependencies.js'; +import { DependencyTracker } from '../src/field-operations/field-dependencies.js'; // Sample form with various dependencies const createTestForm = () => ({ diff --git a/src/tests/field-manager.test.js b/test/field-manager.test.js similarity index 75% rename from src/tests/field-manager.test.js rename to test/field-manager.test.js index b65db72..88a900c 100644 --- a/src/tests/field-manager.test.js +++ b/test/field-manager.test.js @@ -5,20 +5,24 @@ import test from 'node:test'; import assert from 'node:assert'; -import { FieldManager } from '../field-operations/field-manager.js'; +import { FieldManager } from '../src/field-operations/field-manager.js'; -// Mock dependencies +// Mock dependencies. Mirrors the GravityFormsClient contract FieldManager +// actually consumes: getForm() resolves { form } and replaceForm() does a +// direct PUT resolving { form } (see field-manager.js). const createMockApiClient = () => ({ - getForm: async (formId) => ({ - id: formId, - title: 'Test Form', - fields: [ - { id: 1, type: 'text', label: 'Name' }, - { id: 2, type: 'email', label: 'Email' }, - { id: 3, type: 'textarea', label: 'Message' } - ] + getForm: async () => ({ + form: { + id: 1, + title: 'Test Form', + fields: [ + { id: 1, type: 'text', label: 'Name' }, + { id: 2, type: 'email', label: 'Email' }, + { id: 3, type: 'textarea', label: 'Message' } + ] + } }), - updateForm: async (form) => ({ form }) + replaceForm: async (formId, form) => ({ form }) }); const createMockRegistry = () => ({ @@ -337,4 +341,51 @@ test('FieldManager - deleteField', async (t) => { assert.strictEqual(cleanupCalled, true); assert.ok(result.actions_taken.includes('Dependencies cleaned up')); }); -}); \ No newline at end of file +}); +test('FieldManager - normalizeLayoutProperties', async (t) => { + const manager = new FieldManager(createMockApiClient(), createMockRegistry(), createMockValidator()); + + await t.test('valid 8-char hex layoutGroupId passes through unchanged', () => { + const field = { layoutGroupId: 'a1b2c3d4' }; + manager.normalizeLayoutProperties(field, 7); + assert.strictEqual(field.layoutGroupId, 'a1b2c3d4'); + }); + + await t.test('friendly layoutGroupId hashes to stable 8-char hex per form', () => { + const first = manager.normalizeLayoutProperties({ layoutGroupId: 'name-row' }, 7); + const second = manager.normalizeLayoutProperties({ layoutGroupId: 'name-row' }, 7); + assert.match(first.layoutGroupId, /^[0-9a-f]{8}$/); + assert.strictEqual(first.layoutGroupId, second.layoutGroupId, 'same name + form must share a row'); + const otherForm = manager.normalizeLayoutProperties({ layoutGroupId: 'name-row' }, 8); + assert.notStrictEqual(first.layoutGroupId, otherForm.layoutGroupId, 'different forms must not collide'); + }); + + await t.test('layoutGridColumnSpan clamps to the 1-12 editor grid', () => { + assert.strictEqual(manager.normalizeLayoutProperties({ layoutGridColumnSpan: 20 }, 1).layoutGridColumnSpan, 12); + assert.strictEqual(manager.normalizeLayoutProperties({ layoutGridColumnSpan: 0 }, 1).layoutGridColumnSpan, 1); + assert.strictEqual(manager.normalizeLayoutProperties({ layoutGridColumnSpan: '6' }, 1).layoutGridColumnSpan, 6); + }); + + await t.test('non-numeric layoutGridColumnSpan is dropped for the editor to assign', () => { + const field = manager.normalizeLayoutProperties({ layoutGridColumnSpan: 'wide' }, 1); + assert.strictEqual('layoutGridColumnSpan' in field, false); + }); + + await t.test('layoutGridColumnSpan drops floats and partial-numeric strings', () => { + const dropped = (value) => 'layoutGridColumnSpan' in manager.normalizeLayoutProperties({ layoutGridColumnSpan: value }, 1) === false; + assert.ok(dropped('6wide'), '"6wide" should be dropped, not coerced to 6'); + assert.ok(dropped('6.5'), '"6.5" should be dropped'); + assert.ok(dropped(6.5), '6.5 (float) should be dropped'); + assert.ok(dropped(''), 'empty string should be dropped'); + assert.ok(dropped(' '), 'whitespace-only string should be dropped'); + assert.ok(dropped(true), 'boolean should be dropped'); + // Valid integers (and integer strings) are still kept. + assert.strictEqual(manager.normalizeLayoutProperties({ layoutGridColumnSpan: 8 }, 1).layoutGridColumnSpan, 8); + assert.strictEqual(manager.normalizeLayoutProperties({ layoutGridColumnSpan: ' 7 ' }, 1).layoutGridColumnSpan, 7); + }); + + await t.test('empty and missing layoutGroupId are left alone', () => { + assert.strictEqual(manager.normalizeLayoutProperties({ layoutGroupId: '' }, 1).layoutGroupId, ''); + assert.strictEqual('layoutGroupId' in manager.normalizeLayoutProperties({}, 1), false); + }); +}); diff --git a/src/tests/field-operations-e2e.test.js b/test/field-operations-e2e.test.js similarity index 98% rename from src/tests/field-operations-e2e.test.js rename to test/field-operations-e2e.test.js index 913ede4..c23850c 100644 --- a/src/tests/field-operations-e2e.test.js +++ b/test/field-operations-e2e.test.js @@ -3,10 +3,10 @@ * Tests real-world scenarios from user perspective */ -import { fieldOperationHandlers } from '../field-operations/index.js'; -import { testConfig, TestFormManager } from '../config/test-config.js'; -import GravityFormsClient from '../gravity-forms-client.js'; -import FieldAwareValidator from '../config/field-validation.js'; +import { fieldOperationHandlers } from '../src/field-operations/index.js'; +import { testConfig, TestFormManager } from '../src/config/test-config.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; +import FieldAwareValidator from '../src/config/field-validation.js'; console.log('🎭 Field Operations E2E Test Scenarios\n'); diff --git a/src/tests/field-operations-integration.test.js b/test/field-operations-integration.test.js similarity index 96% rename from src/tests/field-operations-integration.test.js rename to test/field-operations-integration.test.js index f207f62..2148c5a 100644 --- a/src/tests/field-operations-integration.test.js +++ b/test/field-operations-integration.test.js @@ -3,11 +3,11 @@ * Tests the complete flow from MCP tool calls to API interactions */ -import { fieldOperationHandlers } from '../field-operations/index.js'; -import { testConfig, TestFormManager } from '../config/test-config.js'; -import GravityFormsClient from '../gravity-forms-client.js'; -import fieldRegistry from '../field-definitions/field-registry.js'; -import FieldAwareValidator from '../config/field-validation.js'; +import { fieldOperationHandlers } from '../src/field-operations/index.js'; +import { testConfig, TestFormManager } from '../src/config/test-config.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; +import fieldRegistry from '../src/field-definitions/field-registry.js'; +import FieldAwareValidator from '../src/config/field-validation.js'; console.log('🧪 Field Operations Integration Tests\n'); diff --git a/src/tests/field-positioner.test.js b/test/field-positioner.test.js similarity index 99% rename from src/tests/field-positioner.test.js rename to test/field-positioner.test.js index acb80c2..5153bef 100644 --- a/src/tests/field-positioner.test.js +++ b/test/field-positioner.test.js @@ -5,7 +5,7 @@ import test from 'node:test'; import assert from 'node:assert'; -import { PositionEngine } from '../field-operations/field-positioner.js'; +import { PositionEngine } from '../src/field-operations/field-positioner.js'; // Create test fields with page breaks const createTestFields = () => [ diff --git a/src/tests/field-registry.test.js b/test/field-registry.test.js similarity index 99% rename from src/tests/field-registry.test.js rename to test/field-registry.test.js index 980d697..5efbad5 100644 --- a/src/tests/field-registry.test.js +++ b/test/field-registry.test.js @@ -4,7 +4,7 @@ import test from 'node:test'; import assert from 'node:assert'; -import { generateCompoundInputs, isCompoundField, getFieldDefinition } from '../field-definitions/field-registry.js'; +import { generateCompoundInputs, isCompoundField, getFieldDefinition } from '../src/field-definitions/field-registry.js'; test('generateCompoundInputs - address field', async (t) => { await t.test('generates US address inputs', () => { diff --git a/src/tests/field-validation.test.js b/test/field-validation.test.js similarity index 99% rename from src/tests/field-validation.test.js rename to test/field-validation.test.js index 68b5e2b..3105dc6 100644 --- a/src/tests/field-validation.test.js +++ b/test/field-validation.test.js @@ -5,14 +5,14 @@ * the field registry to ensure 100% valid structure. */ -import { FieldAwareValidator } from '../config/field-validation.js'; +import { FieldAwareValidator } from '../src/config/field-validation.js'; import { fieldRegistry, getFieldDefinition, isCompoundField, isArrayField, detectFieldVariant -} from '../field-definitions/field-registry.js'; +} from '../src/field-definitions/field-registry.js'; // Test runner let passedTests = 0; diff --git a/src/tests/forms.test.js b/test/forms.test.js similarity index 99% rename from src/tests/forms.test.js rename to test/forms.test.js index a5550f7..d35c447 100644 --- a/src/tests/forms.test.js +++ b/test/forms.test.js @@ -3,7 +3,7 @@ * Tests all 6 forms management tools with happy path, edge cases, and failure modes */ -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert, diff --git a/src/tests/helpers.js b/test/helpers.js similarity index 90% rename from src/tests/helpers.js rename to test/helpers.js index 609fba2..f8b7e4e 100644 --- a/src/tests/helpers.js +++ b/test/helpers.js @@ -19,6 +19,40 @@ export function generateString(prefix = 'test') { return `${prefix}_${crypto.randomBytes(4).toString('hex')}`; } +// --- Behavior helpers (extracted for testability; see helpers.test.js) --- + +/** + * Whether the running module is the process entrypoint (so a test file run + * directly via `node test/x.test.js` self-executes). + */ +export function isMainModule(importMetaUrl, argv1) { + // Strip up to the last "/" or "\" so it works on POSIX and Windows paths. + return !!argv1 && importMetaUrl.endsWith(argv1.replace(/.*[\\/]/, '')); +} + +/** + * Whether a feed error means the add-on or its infrastructure is unavailable + * (so a feed test should skip), vs a genuine error that must be re-thrown. + */ +export function feedUnavailable(message) { + // No mandatory space after add-?on, so "addon_slug ... is not registered" + // matches; genuine errors (bad meta, validation) don't. + return /table does not exist|missing_table|not installed|not active|invalid add-?on|add-?on.*not (registered|found)/i.test(message || ''); +} + +/** + * Await a promise; on rejection, report (don't swallow) the error and + * continue. Returns the resolved value, or undefined on rejection. + */ +export async function settleWithReport(promise, report) { + try { + return await promise; + } catch (error) { + report(error); + return undefined; + } +} + /** * Generate mock form data */ diff --git a/test/helpers.test.js b/test/helpers.test.js new file mode 100644 index 0000000..2940709 --- /dev/null +++ b/test/helpers.test.js @@ -0,0 +1,63 @@ +/** + * Unit tests for the behavior helpers in helpers.js + * (isMainModule, feedUnavailable, settleWithReport). + */ + +import test from 'node:test'; +import assert from 'node:assert'; +import { isMainModule, feedUnavailable, settleWithReport } from './helpers.js'; + +// --- isMainModule --- + +test('isMainModule: matches a forward-slash argv path', () => { + assert.equal(isMainModule('file:///a/b/auth.test.js', '/a/b/auth.test.js'), true); +}); + +test('isMainModule: matches a Windows backslash argv path', () => { + assert.equal(isMainModule('file:///C:/a/b/auth.test.js', 'C:\\a\\b\\auth.test.js'), true); +}); + +test('isMainModule: false when the basename differs', () => { + assert.equal(isMainModule('file:///a/b/auth.test.js', '/a/b/run.js'), false); +}); + +test('isMainModule: false when argv is missing', () => { + assert.equal(isMainModule('file:///a/b/auth.test.js', undefined), false); +}); + +// --- feedUnavailable --- + +const UNAVAILABLE = [ + 'gf_create_feed failed: The wp_gf_addon_feed table does not exist.', + 'addon_slug gravityformsmailchimp is not registered', + 'Feed add-on not active', + 'The gravityformsmailchimp Add-On is not installed', +]; +for (const msg of UNAVAILABLE) { + test(`feedUnavailable: skips unavailable message — "${msg}"`, () => { + assert.equal(feedUnavailable(msg), true); + }); +} + +const GENUINE = ['Invalid feed meta', 'Validation error: feedName is required', '']; +for (const msg of GENUINE) { + test(`feedUnavailable: re-throws genuine error — "${msg}"`, () => { + assert.equal(feedUnavailable(msg), false); + }); +} + +// --- settleWithReport --- + +test('settleWithReport: reports the error and resolves on rejection', async () => { + let reported = null; + const result = await settleWithReport(Promise.reject(new Error('boom')), (e) => { reported = e.message; }); + assert.equal(reported, 'boom'); + assert.equal(result, undefined); +}); + +test('settleWithReport: returns the value and does not report on success', async () => { + let reported = false; + const result = await settleWithReport(Promise.resolve(42), () => { reported = true; }); + assert.equal(result, 42); + assert.equal(reported, false); +}); diff --git a/src/tests/integration.test.js b/test/integration.test.js similarity index 85% rename from src/tests/integration.test.js rename to test/integration.test.js index f119b5c..3066297 100644 --- a/src/tests/integration.test.js +++ b/test/integration.test.js @@ -4,9 +4,9 @@ */ import dotenv from 'dotenv'; -import GravityFormsClient from '../gravity-forms-client.js'; -import { TestRunner, TestAssert } from './helpers.js'; -import { validateRestApiAccess } from '../config/auth.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; +import { TestRunner, TestAssert, feedUnavailable, settleWithReport } from './helpers.js'; +import { validateRestApiAccess } from '../src/config/auth.js'; // Set test mode to suppress initialization messages to stderr process.env.GRAVITY_FORMS_TEST_MODE = 'true'; @@ -51,7 +51,9 @@ suite.beforeAll(async () => { GRAVITY_FORMS_CONSUMER_KEY: process.env.GRAVITY_FORMS_TEST_CONSUMER_KEY, GRAVITY_FORMS_CONSUMER_SECRET: process.env.GRAVITY_FORMS_TEST_CONSUMER_SECRET, GRAVITY_FORMS_BASE_URL: process.env.GRAVITY_FORMS_TEST_BASE_URL, - GRAVITY_FORMS_AUTH_METHOD: process.env.GRAVITY_FORMS_AUTH_METHOD || 'basic', + // Leave unset for credential-aware auto-selection; an explicit + // method (TEST_ variant wins) is passed through as-is. + GRAVITY_FORMS_AUTH_METHOD: process.env.GRAVITY_FORMS_TEST_AUTH_METHOD || process.env.GRAVITY_FORMS_AUTH_METHOD, GRAVITY_FORMS_ALLOW_DELETE: 'true' // Enable for cleanup }); @@ -373,19 +375,8 @@ suite.test('Integration: List available feeds', async () => { }); suite.test('Integration: Create test feed (if MailChimp available)', async () => { - // First check if MailChimp addon is available by trying to list its feeds - let mailchimpAvailable = false; - try { - const feeds = await client.listFeeds({ addon: 'gravityformsmailchimp' }); - // If we get here without error, addon might be available - mailchimpAvailable = !feeds.error || !feeds.error.includes('not installed'); - } catch (error) { - // Addon check failed, likely not available - mailchimpAvailable = false; - } - - if (!mailchimpAvailable) { - console.log(' MailChimp addon not installed - skipping feed test'); + if (!testFormId) { + console.log(' No test form available - skipping feed creation'); return; } @@ -401,7 +392,19 @@ suite.test('Integration: Create test feed (if MailChimp available)', async () => } }; - const result = await client.createFeed(feedData); + // Attempt creation and skip (don't fail) when the feed add-on or its + // infrastructure isn't present: a fresh Gravity Forms install has no + // wp_gf_addon_feed table, and MailChimp may not be installed at all. + let result; + try { + result = await client.createFeed(feedData); + } catch (error) { + if (feedUnavailable(error.message)) { + console.log(` MailChimp feed add-on not available - skipping (${error.message})`); + return; + } + throw error; + } TestAssert.isNotNull(result.feed, 'Feed should be created'); TestAssert.isNotNull(result.feed.id, 'Should return feed ID'); @@ -710,6 +713,73 @@ suite.test('Integration: Test entries pagination with paging parameters', async } }); + +// =================================================================== +// Security: deny-paths. Every auth method must REJECT requests that +// are unauthenticated, carry bad credentials, lack GF capabilities, +// or exceed their key's permission level. +// =================================================================== + +suite.test('Security: unauthenticated request is denied', async () => { + const response = await fetch(`${process.env.GRAVITY_FORMS_TEST_BASE_URL}/wp-json/gf/v2/forms`); + TestAssert.isTrue( + response.status === 401 || response.status === 403, + `Unauthenticated /forms must be 401/403, got ${response.status}` + ); +}); + +suite.test('Security: authenticated user WITHOUT GF capabilities is denied', async () => { + const user = process.env.GRAVITY_FORMS_TEST_LOWPRIV_USER; + const pass = process.env.GRAVITY_FORMS_TEST_LOWPRIV_APP_PASSWORD; + if (!user || !pass) { + console.log(' Skipping - set GRAVITY_FORMS_TEST_LOWPRIV_USER / _APP_PASSWORD (e.g. a subscriber app password)'); + return; + } + + const auth = Buffer.from(`${user}:${pass}`).toString('base64'); + const response = await fetch(`${process.env.GRAVITY_FORMS_TEST_BASE_URL}/wp-json/gf/v2/forms`, { + headers: { Authorization: `Basic ${auth}` } + }); + TestAssert.isTrue( + response.status === 401 || response.status === 403, + `Subscriber on /forms must be 401/403, got ${response.status}` + ); +}); + +suite.test('Security: read-only API key cannot write', async () => { + const roKey = process.env.GRAVITY_FORMS_TEST_READONLY_CONSUMER_KEY; + const roSecret = process.env.GRAVITY_FORMS_TEST_READONLY_CONSUMER_SECRET; + if (!roKey || !roSecret) { + console.log(' Skipping - set GRAVITY_FORMS_TEST_READONLY_CONSUMER_KEY / _SECRET (a key with "read" permissions)'); + return; + } + + // No AUTH_METHOD: credential-aware auto-selection picks the right + // transport for the key pair (OAuth on http, Basic on https). + const roClient = new GravityFormsClient({ + GRAVITY_FORMS_BASE_URL: process.env.GRAVITY_FORMS_TEST_BASE_URL, + GRAVITY_FORMS_CONSUMER_KEY: roKey, + GRAVITY_FORMS_CONSUMER_SECRET: roSecret + }); + // initialize() can legitimately fail for a read-only key (its REST-access + // probe may not pass every endpoint); continue to the read/write checks, + // but log rather than swallow so a real init failure stays visible. + await settleWithReport(roClient.initialize(), (e) => console.log(` read-only client init reported: ${e.message} — continuing`)); + + // Reads must work… (GF /forms returns an ID-keyed object, not an array) + const listed = await roClient.listForms({}); + TestAssert.isTrue(!!listed.forms && typeof listed.forms === 'object', 'read-only key should list forms'); + + // …writes must not. + let writeDenied = false; + try { + await roClient.createForm({ title: 'TEST_should_never_exist' }); + } catch (e) { + writeDenied = true; + } + TestAssert.isTrue(writeDenied, 'read-only key must be denied on createForm'); +}); + // Run tests if executed directly if (import.meta.url === `file://${process.argv[1]}`) { suite.run().then((results) => { diff --git a/src/tests/mutex.test.js b/test/mutex.test.js similarity index 99% rename from src/tests/mutex.test.js rename to test/mutex.test.js index 8cc68ce..a9a6c99 100644 --- a/src/tests/mutex.test.js +++ b/test/mutex.test.js @@ -9,7 +9,7 @@ */ import { TestRunner, TestAssert, wait } from './helpers.js'; -import ResourceMutex, { resourceMutex } from '../utils/mutex.js'; +import ResourceMutex, { resourceMutex } from '../src/utils/mutex.js'; const suite = new TestRunner('Mutex & Concurrency Tests'); diff --git a/src/tests/run.js b/test/run.js similarity index 96% rename from src/tests/run.js rename to test/run.js index d88c486..3350d66 100644 --- a/src/tests/run.js +++ b/test/run.js @@ -16,6 +16,7 @@ import validationTests from './validation.test.js'; import sanitizeTests from './sanitize.test.js'; import bugFixesTests from './bug-fixes.test.js'; import mutexTests from './mutex.test.js'; +import abilitiesLoaderTests from './abilities-loader.test.js'; // Test suites to run const testSuites = [ @@ -29,7 +30,8 @@ const testSuites = [ validationTests, sanitizeTests, mutexTests, - bugFixesTests + bugFixesTests, + abilitiesLoaderTests ]; // Test statistics diff --git a/src/tests/sanitize.test.js b/test/sanitize.test.js similarity index 99% rename from src/tests/sanitize.test.js rename to test/sanitize.test.js index 0df299b..83d5b38 100644 --- a/src/tests/sanitize.test.js +++ b/test/sanitize.test.js @@ -4,7 +4,7 @@ */ import { TestRunner, TestAssert } from './helpers.js'; -import { sanitize, sanitizeUrl, sanitizeHeaders } from '../utils/sanitize.js'; +import { sanitize, sanitizeUrl, sanitizeHeaders } from '../src/utils/sanitize.js'; const suite = new TestRunner('Sanitization Tests'); diff --git a/test/server-runtime.test.js b/test/server-runtime.test.js new file mode 100644 index 0000000..751a015 --- /dev/null +++ b/test/server-runtime.test.js @@ -0,0 +1,79 @@ +/** + * Unit tests for server-runtime helpers (two-plane init, tool list assembly, + * ability-call routing). + */ + +import test from 'node:test'; +import assert from 'node:assert'; +import { runPlaneInit, buildToolList, classifyAbilityCall } from '../src/server-runtime.js'; + +// --- runPlaneInit (#1: WP not blocked by a slow GF probe) --- + +test('runPlaneInit: WP plane initializes before a slow GF probe resolves', async () => { + let gfResolved = false; + let gfResolvedWhenWpRan = null; + const initGravityFormsPlane = () => new Promise((r) => setTimeout(() => { gfResolved = true; r(true); }, 30)); + const initWordPressPlane = () => { gfResolvedWhenWpRan = gfResolved; return true; }; + await runPlaneInit({ initGravityFormsPlane, initWordPressPlane }); + assert.equal(gfResolvedWhenWpRan, false, 'WP plane must run before the GF probe resolves'); +}); + +test('runPlaneInit: throws when neither plane is usable', async () => { + await assert.rejects( + runPlaneInit({ initGravityFormsPlane: async () => false, initWordPressPlane: () => false }), + /Neither/ + ); +}); + +test('runPlaneInit: returns each plane status', async () => { + const r = await runPlaneInit({ initGravityFormsPlane: async () => true, initWordPressPlane: () => false }); + assert.deepEqual(r, { gfOk: true, wpOk: false }); +}); + +// --- buildToolList (#2: GF tools only when the plane is live) --- + +const defs = { + gfToolDefs: [{ name: 'gf_list_forms' }], + fieldOpTools: [{ name: 'gf_add_field' }], + abilityDefs: [{ name: 'gv_view_create' }], + gkReloadDef: { name: 'gk_reload_abilities' }, +}; + +test('buildToolList: omits GF + field-op tools when the GF plane is not ready', () => { + const names = buildToolList({ gfReady: false, ...defs }).map((t) => t.name); + assert.ok(!names.includes('gf_list_forms'), 'gf tools excluded'); + assert.ok(!names.includes('gf_add_field'), 'field-op tools excluded'); + assert.ok(names.includes('gv_view_create'), 'ability tools present'); + assert.ok(names.includes('gk_reload_abilities'), 'reload tool present'); +}); + +test('buildToolList: includes everything when the GF plane is ready', () => { + const names = buildToolList({ gfReady: true, ...defs }).map((t) => t.name).sort(); + assert.deepEqual(names, ['gf_add_field', 'gf_list_forms', 'gk_reload_abilities', 'gv_view_create']); +}); + +test('buildToolList: reload tool present, no crash on missing abilities', () => { + const names = buildToolList({ gfReady: false, gkReloadDef: defs.gkReloadDef, abilityDefs: null }).map((t) => t.name); + assert.deepEqual(names, ['gk_reload_abilities']); +}); + +// --- classifyAbilityCall (#3: route by handler-map membership, any prefix) --- + +test('classifyAbilityCall: dispatches any product-prefixed tool in the handler map', () => { + assert.equal(classifyAbilityCall({ name: 'gv_view_create', hasWpClient: true, handlers: { gv_view_create() {} } }), 'dispatch'); + assert.equal(classifyAbilityCall({ name: 'gc_chart_create', hasWpClient: true, handlers: { gc_chart_create() {} } }), 'dispatch'); +}); + +test('classifyAbilityCall: unknown when not in a loaded handler map', () => { + assert.equal(classifyAbilityCall({ name: 'gv_nope', hasWpClient: true, handlers: { gv_view_create() {} } }), 'unknown'); +}); + +test('classifyAbilityCall: no-wp-client when WP client is absent (any prefix)', () => { + assert.equal(classifyAbilityCall({ name: 'gv_view_create', hasWpClient: false, handlers: null }), 'no-wp-client'); + assert.equal(classifyAbilityCall({ name: 'gc_chart_create', hasWpClient: false, handlers: null }), 'no-wp-client'); +}); + +test('classifyAbilityCall: catalog-unreachable when WP is up but catalog not loaded (any prefix)', () => { + assert.equal(classifyAbilityCall({ name: 'gv_view_create', hasWpClient: true, handlers: null }), 'catalog-unreachable'); + assert.equal(classifyAbilityCall({ name: 'gc_chart_create', hasWpClient: true, handlers: null }), 'catalog-unreachable'); +}); diff --git a/src/tests/server-tools.test.js b/test/server-tools.test.js similarity index 100% rename from src/tests/server-tools.test.js rename to test/server-tools.test.js diff --git a/src/tests/submissions.test.js b/test/submissions.test.js similarity index 99% rename from src/tests/submissions.test.js rename to test/submissions.test.js index 9b1c125..42f80f3 100644 --- a/src/tests/submissions.test.js +++ b/test/submissions.test.js @@ -3,7 +3,7 @@ * Tests form submission workflow, validation, and notifications */ -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert, diff --git a/test/user-agent.test.js b/test/user-agent.test.js new file mode 100644 index 0000000..2e429d9 --- /dev/null +++ b/test/user-agent.test.js @@ -0,0 +1,40 @@ +/** + * Both API clients must report the same User-Agent, single-sourced from + * package.json (no per-client hardcoded version that can drift). + */ + +import test from 'node:test'; +import assert from 'node:assert'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import GravityFormsClient from '../src/gravity-forms-client.js'; +import { WordPressClient } from '../src/wp-client.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')); +const expected = `GravityKit-MCP/${pkg.version}`; + +const uaOf = (client) => client.httpClient.defaults.headers['User-Agent']; +const gfClient = () => new GravityFormsClient({ + GRAVITY_FORMS_BASE_URL: 'https://example.test', + GRAVITY_FORMS_CONSUMER_KEY: 'k', + GRAVITY_FORMS_CONSUMER_SECRET: 's', +}); +const wpClient = () => new WordPressClient({ + GRAVITYKIT_WP_URL: 'https://example.test', + GRAVITYKIT_WP_USERNAME: 'u', + GRAVITYKIT_WP_APP_PASSWORD: 'p', +}); + +test('GravityFormsClient User-Agent matches package version', () => { + assert.equal(uaOf(gfClient()), expected); +}); + +test('WordPressClient User-Agent matches package version', () => { + assert.equal(uaOf(wpClient()), expected); +}); + +test('both clients agree on the User-Agent', () => { + assert.equal(uaOf(gfClient()), uaOf(wpClient())); +}); diff --git a/src/tests/validation.test.js b/test/validation.test.js similarity index 99% rename from src/tests/validation.test.js rename to test/validation.test.js index 628f6c4..4d0a713 100644 --- a/src/tests/validation.test.js +++ b/test/validation.test.js @@ -3,7 +3,7 @@ * Tests input validation for all tools and edge cases */ -import GravityFormsClient from '../gravity-forms-client.js'; +import GravityFormsClient from '../src/gravity-forms-client.js'; import { TestRunner, TestAssert, diff --git a/test/views-stress.test.js b/test/views-stress.test.js new file mode 100644 index 0000000..20ba3b9 --- /dev/null +++ b/test/views-stress.test.js @@ -0,0 +1,3468 @@ +/** + * GravityView REST stress tests — portable, runs against any + * GravityView dev install. + * + * Codifies the contracts the round-2 stress sweep proved: + * + * - SHAPE: /layouts uses `has_grid` (not `is_grid_aware`), + * excludes preset_* + *_placeholder; schema responses drop the + * static `groups` map + UI-only keys (priority/class/tooltip/ + * article/codemirror/mount_target/extension/raw-DSL); requires + * envelope merges show/hide sub-keys with single-condition + * collapse + multi-condition QF group wrap; synthetic _id + * hashes stripped while real QF ids preserved; desc HTML + * stripped to plain text; empty values dropped; apply default + * compact; slot_uid alias removed; version string is a real + * timestamp not the Unix epoch. + * - ROUND-TRIP: bulk apply preserves every setting verbatim; + * Unicode + emoji survives custom_label; HTML body of custom + * content survives; conditional_logic v2 doc round-trips; + * move_field keeps slot UID + settings; preview stage transient + * reads back with correct ownership. + * - SANITISATION: text-typed settings strip all HTML; textarea- + * typed settings keep safe HTML via wp_kses_post; Bold link', + }); + const v = stored.custom_label || ''; + TestAssert.isTrue(!v.includes(''), 'b tag stripped'); + TestAssert.isTrue(!v.includes(' { + if (suite.skip) return; + const viewId = await mintView('approval labels strip'); + const stored = await roundTripSlot(viewId, 'directory_list-description', 'sanitappr001', { + field_id: 'is_approved', + approved_label: ' Accepted', + disapproved_label: '✗ Declined', + unapproved_label: '⏳ Pending', + }); + TestAssert.equal(stored.approved_label.trim(), '✓ Accepted'); + TestAssert.isTrue(!stored.disapproved_label.includes('<'), 'no tags'); + TestAssert.isTrue(!stored.unapproved_label.includes('javascript:'), 'javascript: dropped'); + TestAssert.isTrue(stored.unapproved_label.includes('⏳ Pending'), 'Pending text + glyph survive'); +}); + +suite.test('Sanitisation: bare URLs in text settings survive', async () => { + if (suite.skip) return; + const viewId = await mintView('url preservation'); + const stored = await roundTripSlot(viewId, 'directory_list-description', 'sanitrtxt001', { + field_id: 'other_entries', + link_format: 'https://example.com/entries/{entry_id}?ref=view#anchor', + after_link: 'See https://docs.example.com for details.', + }); + TestAssert.equal(stored.link_format, 'https://example.com/entries/{entry_id}?ref=view#anchor'); + TestAssert.isTrue(stored.after_link.includes('https://docs.example.com'), 'URL preserved in after_link'); +}); + +suite.test('Sanitisation: custom content keeps full HTML body', async () => { + if (suite.skip) return; + const viewId = await mintView('custom content html'); + const stored = await roundTripSlot(viewId, 'directory_list-description', 'sanitcust001', { + field_id: 'custom', + content: '

Hello

From here

link
', + wpautop: false, + oembed: false, + }); + TestAssert.isTrue(stored.content.includes('
'), 'div+class survives'); + TestAssert.isTrue(stored.content.includes('

Hello

'), 'h3 survives'); + TestAssert.isTrue(stored.content.includes('href="https://x.example.com"'), 'anchor href survives'); +}); + +suite.test('Sanitisation: numeric values coerce to int regardless of mode', async () => { + if (suite.skip) return; + const viewId = await mintView('numeric coerce'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const stored = await roundTripSlot(viewId, 'directory_table-columns', 'sanitnum001', { + field_id: fieldIds.name, + width: '50', + }); + TestAssert.equal(stored.width, 50, 'string "50" → int 50'); +}); + +// ============================================================ +// VALIDATION: validateAgainstSchemas catches typos +// ============================================================ + +suite.test('Validation: validateAgainstSchemas accepts valid input-type overlay (emailmailto on email field)', async () => { + if (suite.skip) return; + const viewId = await mintView('email overlay valid'); + await validator.validateAgainstSchemas({ + id: viewId, + fields: { + 'directory_list-title': [{ + field_id: fieldIds.email, + slot: 'validmail001', + emailmailto: '1', + emailsubject:'Hi', + emailbody: 'Re', + }], + }, + }); + // No throw = pass. +}); + +suite.test('Validation: validateAgainstSchemas rejects typo on numeric form field', async () => { + if (suite.skip) return; + const viewId = await mintView('email overlay typo'); + await TestAssert.throwsAsync( + () => validator.validateAgainstSchemas({ + id: viewId, + fields: { + 'directory_list-title': [{ + field_id: fieldIds.email, + slot: 'invalmail001', + not_a_real_email_setting:'x', + }], + }, + }), + 'unknown setting "not_a_real_email_setting"' + ); +}); + +suite.test('Validation: validateAgainstSchemas rejects typo on meta-field (custom)', async () => { + if (suite.skip) return; + const viewId = await mintView('custom typo'); + await TestAssert.throwsAsync( + () => validator.validateAgainstSchemas({ + id: viewId, + fields: { + 'directory_list-description': [{ + field_id: 'custom', + slot: 'invalcust001', + content: '

OK

', + made_up: 'x', + }], + }, + }), + 'unknown setting "made_up"' + ); +}); + +// ============================================================ +// CONCURRENCY: optimistic 412 on stale ETag +// ============================================================ + +suite.test('Concurrency: parallel writes with same ETag → 1 accepted, rest rejected with 412', async () => { + if (suite.skip) return; + const viewId = await mintView('concurrency lock'); + + // Read once to capture the starting ETag, then fire N parallel + // applies with the same If-Match. Server's GET_LOCK + counter + // bump means exactly ONE should accept. + const config = await h.gv_view_config_get({ id: viewId }); + const etag = `"${config.version}"`; + const N = 5; + + const results = await Promise.allSettled( + Array.from({ length: N }, (_, i) => + h.gv_view_config_apply({ + id: viewId, + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: `conc${String(i).padStart(3, '0')}` }] }, + mode: 'merge', + ifMatch: etag, + }) + ) + ); + + const accepted = results.filter((r) => r.status === 'fulfilled').length; + const rejected = results.filter( + (r) => r.status === 'rejected' && (r.reason?.response?.status === 412 || /412|precondition/i.test(String(r.reason?.message || ''))) + ).length; + + TestAssert.equal(accepted, 1, `expected exactly 1 accepted write, got ${accepted}`); + TestAssert.equal(rejected, N - 1, `expected ${N - 1} stale rejections, got ${rejected}`); +}); + +// ============================================================ +// PREVIEW STAGE: transient round-trip +// ============================================================ + +suite.test('Preview stage: POST returns 32-hex key, DELETE clears, ownership enforced', async () => { + if (suite.skip) return; + const viewId = await mintView('preview stage'); + + const created = await createPreviewStage(viewId, { + fields: { + 'directory_list-title': { + stage1: { id: fieldIds.name, custom_label: 'Staged label' }, + }, + }, + template_settings: { page_size: 99 }, + }); + TestAssert.isTrue( + /^[a-f0-9]{32}$/.test(created.stage_key), + `stage_key matches 32-hex (got "${created.stage_key}")` + ); + + const cleared = await deletePreviewStage(viewId, created.stage_key); + TestAssert.isTrue(cleared.cleared === true || cleared.cleared === undefined, 'DELETE returns OK envelope'); +}); + +// ============================================================ +// WARNINGS: conditional_logic rejections surface in apply response +// ============================================================ + +suite.test('Warnings: valid conditional_logic doc → no warnings in apply response', async () => { + if (suite.skip) return; + const viewId = await mintView('cl valid no warnings'); + const apply = await h.gv_view_config_apply({ + id: viewId, + fields: { + 'directory_list-title': [{ + field_id: fieldIds.name, + slot: 'clok001', + conditional_logic: { version: 2, actionType: 'show', logicType: 'all', rules: [] }, + }], + }, + mode: 'merge', + }); + TestAssert.isTrue( + !('warnings' in apply) || apply.warnings.length === 0, + 'valid CL emits no warnings' + ); +}); + +suite.test('Warnings: CL missing version → reason=missing_version, value dropped', async () => { + if (suite.skip) return; + const viewId = await mintView('cl missing version'); + const apply = await h.gv_view_config_apply({ + id: viewId, + fields: { + 'directory_list-title': [{ + field_id: fieldIds.name, + slot: 'clbad001', + // Missing `version` key — the advanced-filter reader's v1-upgrade + // path would crash on this in the public View render. + conditional_logic: { actionType: 'show', logicType: 'all', rules: [{ fieldId: fieldIds.name, operator: 'is', value: 'X' }] }, + }], + }, + mode: 'merge', + }); + TestAssert.isTrue(Array.isArray(apply.warnings), 'warnings array present on rejection'); + const match = apply.warnings.find((w) => + w.key === 'conditional_logic' && + w.slot === 'clbad001' && + w.area === 'directory_list-title' && + w.reason === 'missing_version' + ); + TestAssert.isNotNull(match, 'warning carries area/slot/key/reason=missing_version'); + + // Confirm the value was actually dropped from the persisted slot. + const config = await h.gv_view_config_get({ id: viewId }); + const stored = config.fields['directory_list-title']['clbad001']; + TestAssert.isTrue( + !stored.conditional_logic || stored.conditional_logic === '', + 'rejected CL was dropped (empty string or absent)' + ); +}); + +// ============================================================ +// WIDGET CREATE: persists ALL payload settings, not just id+label +// ============================================================ + +suite.test('Widget create: persists every payload setting beyond id+label', async () => { + if (suite.skip) return; + const viewId = await mintView('widget create persists settings'); + + // default_table has stable static widget zones (header_top / + // footer_top) — using it instead of Layout Builder lets the + // test target a known area without first creating grid rows. + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + + const created = await h.gv_view_widget_add({ + id: viewId, + area: 'header_top', + widget: { + field_id: 'custom_content', + label: 'Headline', + content: '

Welcome to our list.

', + wpautop: false, + custom_class: 'top-banner highlight', + }, + }); + + // Confirm the response echoes the persisted slot (not just id+label). + TestAssert.isTrue(created.values?.content?.includes('our list'), 'content survives in response echo'); + TestAssert.equal(created.values.custom_class, 'top-banner highlight'); + + // Confirm GET /config sees the same settings (proves persistence, + // not just response shape). + const config = await h.gv_view_config_get({ id: viewId }); + const slot = config.widgets?.header_top?.[created.slot]; + TestAssert.isNotNull(slot, 'widget slot persisted in config'); + TestAssert.equal(slot.id, 'custom_content'); + TestAssert.equal(slot.label, 'Headline'); + TestAssert.isTrue(slot.content.includes('our list'), 'content survives in persisted config'); + TestAssert.equal(slot.custom_class, 'top-banner highlight'); +}); + +suite.test('Widget create: search_bar payload auto-migrates to modern shape', async () => { + if (suite.skip) return; + const viewId = await mintView('widget create search_bar modern'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + + const created = await h.gv_view_widget_add({ + id: viewId, + area: 'header_top', + widget: { + field_id: 'search_bar', + label: 'Find', + search_layout: 'horizontal', + search_clear: '1', + }, + }); + + TestAssert.equal(created.values.search_layout, 'horizontal'); + // Server coerces numeric strings to int via sanitize_setting_value. + TestAssert.equal(Number(created.values.search_clear), 1); + // Modern shape lives under `search_fields_section`; legacy + // `search_fields` JSON should NOT be persisted by a fresh write. + TestAssert.isTrue( + !('search_fields' in created.values) || created.values.search_fields === '', + 'no legacy search_fields JSON on fresh write' + ); +}); + +// ============================================================ +// RENDER: staged_slot lets unsaved slots preview +// ============================================================ + +suite.test('Render: unknown slot WITHOUT staged_slot returns 404', async () => { + if (suite.skip) return; + const viewId = await mintView('render no staged'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + + let status = null; + try { + await h.gv_view_field_render({ + id: viewId, + area: 'directory_table-columns', + slot: 'never0001', + }); + } catch (err) { + status = err?.response?.status ?? null; + } + TestAssert.equal(status, 404, 'unknown slot 404s when no staged_slot supplied'); +}); + +suite.test('Render: staged_slot synthesizes an unsaved slot for preview', async () => { + if (suite.skip) return; + const viewId = await mintView('render staged unsaved'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + + // Render with `staged_slot` carrying field_id + a custom_label — + // server should synthesize a slot record and run the production + // renderer instead of returning 404. Result envelope shape comes + // from the existing /render endpoint contract. + let result; + try { + result = await h.gv_view_field_render({ + id: viewId, + area: 'directory_table-columns', + slot: 'staged0001', + staged_slot: { + field_id: fieldIds.name, + custom_label: 'Preview-only label', + show_label: '1', + }, + }); + } catch (err) { + // 503 "no entry" is acceptable when the form has zero entries + // — that's a renderer limitation, not a staged_slot one. Skip + // gracefully in that case. + if (err?.response?.status === 503) { + console.log(' (skipping body assertions — form has no entries to render against)'); + return; + } + throw err; + } + + TestAssert.isNotNull(result, 'render returns a body'); + // The render response carries `html` (the rendered slot markup). + // Confirm the slot UID we passed in surfaces in the data-gv-slot + // attribute so the LivePreview receiver can match the staged + // node to the slot. + const html = String(result.html ?? ''); + TestAssert.isTrue(html.length > 0, 'rendered HTML body is non-empty'); +}); + +suite.test('Render: settings override on saved slot still works (regression)', async () => { + if (suite.skip) return; + const viewId = await mintView('render settings override'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + + // First save a real slot. + await h.gv_view_config_apply({ + id: viewId, + fields: { + 'directory_table-columns': [{ field_id: fieldIds.name, slot: 'saved001' }], + }, + mode: 'merge', + }); + + // Then render with a settings override. Should not 404. + let status = null; + try { + await h.gv_view_field_render({ + id: viewId, + area: 'directory_table-columns', + slot: 'saved001', + settings: { custom_label: 'Overridden via render', show_label: '1' }, + }); + } catch (err) { + // 503 = no entry to render against → still proves the slot + // was found (no 404). Anything other than 404/503 is bad. + status = err?.response?.status ?? null; + if (status !== 503) throw err; + } + TestAssert.isTrue(status === null || status === 503, 'saved slot found (no 404 regression)'); +}); + +// ============================================================ +// SEARCH FIELD INPUT TYPES: server discovery + write-time reject +// ============================================================ + +suite.test('Search input types: GET /search-fields/input-types returns canonical core slugs', async () => { + if (suite.skip) return; + const { input_types } = await h.gv_search_input_types_list({}); + TestAssert.isTrue(Array.isArray(input_types), 'input_types is an array'); + // The core list must contain at minimum these slugs. Add-ons may + // contribute more via the gravityview/search/input_labels filter, + // so we don't pin an exact length. + for (const required of ['input_text', 'select', 'date', 'date_range', 'submit', 'hidden']) { + TestAssert.isTrue(input_types.includes(required), `core slug "${required}" present`); + } +}); + +suite.test('Search input types: client pre-flight throws on typo BEFORE network call', async () => { + if (suite.skip) return; + await TestAssert.throwsAsync( + () => gvClient.assertSearchInputType('datepiker'), + 'Unknown search field input "datepiker"' + ); +}); + +suite.test('Search input types: client pre-flight accepts valid slug', async () => { + if (suite.skip) return; + await gvClient.assertSearchInputType('date_range'); + await gvClient.assertSearchInputType('input_text'); + // Empty / undefined → no-op (server defaults). + await gvClient.assertSearchInputType(''); + await gvClient.assertSearchInputType(undefined); +}); + +suite.test('Search input types: server rejects typo with 400 + helpful error', async () => { + if (suite.skip) return; + const viewId = await mintView('search input server reject'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + + // Server-side allow-list check. The ability shim threads the input + // through to the legacy create_search_field_slot validator, which + // rejects unknown input slugs with a 400 — error surfaces back as + // an axios throw with err.response.data.message. + let serverStatus = null; + let serverMessage = ''; + try { + await h.gv_search_field_add({ + id: viewId, + widget_area: 'header_top', + widget_slot: widget.slot, + position: 'search-general_top::100::ROW_STUB', + field: { id: fieldIds.name, input: 'datepiker', label: 'Bad' }, + }); + } catch (err) { + serverStatus = err?.response?.status ?? null; + serverMessage = String(err?.response?.data?.message ?? err?.message ?? ''); + } + TestAssert.equal(serverStatus, 400, 'server returns 400'); + TestAssert.isTrue( + /not allowed for field|Unknown search field input/.test(serverMessage), + `server rejection message present (got "${serverMessage}")` + ); + TestAssert.isTrue(serverMessage.includes('datepiker'), 'server echoes the bad slug'); + TestAssert.isTrue(serverMessage.includes('input_text'), 'server lists a known-valid slug'); +}); + +// ============================================================ +// TEMPLATE SETTINGS SOURCES (unified discovery + bridge) +// +// These tests exercise the inspector's template-settings architecture +// using a TEST mu-plugin fixture that registers two FAKE silo'd +// sources via `gk/gravityview/rest/template-settings/sources`. The +// fixture lives at GravityView/tests/fixtures/inspector-template-sources-mock.php +// and is auto-installed in beforeAll when GRAVITYVIEW_PLUGIN_PATH + +// WP_MU_PLUGINS_DIR env vars point at the dev install. Without +// either env var set, the mock-source tests skip (the contract is +// still partially exercised by the always-present core-source test). +// +// Fixture sources: +// - prefix `mockone` on template `default_table`, schema { foo, bar, content } +// - prefix `mocktwo` on template `default_list`, schema { alpha, beta } +// ============================================================ + +suite.test('Template settings schema: core source always exposes shared keys (page_size etc.)', async () => { + if (suite.skip) return; + const { schema, template_id } = await h.gv_template_settings_schema_get({ template_id: 'default_table' }); + TestAssert.equal(template_id, 'default_table'); + TestAssert.isTrue(Array.isArray(schema) && schema.length > 0, 'schema array non-empty'); + const slugs = new Set(schema.map((it) => it?.slug)); + TestAssert.isTrue(slugs.has('page_size'), 'core slug "page_size" present'); +}); + +suite.test('Mock source: schema exposes dotted slugs gated by template_ids', async () => { + if (suite.skip || !mockFixtureActive) { + console.log(' (skipping — mock fixture not installed; set GRAVITYVIEW_PLUGIN_PATH + WP_MU_PLUGINS_DIR)'); + return; + } + // mockone is gated on default_table — should appear there + nowhere else. + const onTable = await h.gv_template_settings_schema_get({ template_id: 'default_table' }); + const onList = await h.gv_template_settings_schema_get({ template_id: 'default_list' }); + + const tableSlugs = new Set(onTable.schema.map((it) => it?.slug)); + const listSlugs = new Set(onList.schema.map((it) => it?.slug)); + + for (const required of ['mockone.foo', 'mockone.bar', 'mockone.content']) { + TestAssert.isTrue(tableSlugs.has(required), `mockone slug "${required}" present on default_table`); + TestAssert.isTrue(!listSlugs.has(required), `mockone slug "${required}" NOT present on default_list (template_ids gate)`); + } + for (const required of ['mocktwo.alpha', 'mocktwo.beta']) { + TestAssert.isTrue(listSlugs.has(required), `mocktwo slug "${required}" present on default_list`); + TestAssert.isTrue(!tableSlugs.has(required), `mocktwo slug "${required}" NOT present on default_table`); + } +}); + +suite.test('Mock source: core source dedupes entries claimed by silo `groups`', async () => { + if (suite.skip) return; + if (!mockFixtureActive) { + console.log(' (skipping — mock fixture not installed; set GRAVITYVIEW_PLUGIN_PATH + WP_MU_PLUGINS_DIR)'); + return; + } + // The mock sources own `mock_silo` + `mock_alt` groups. Even + // though View_Settings::defaults(true) doesn't naturally surface + // those slugs, the dedupe logic must still ensure the core source + // emits NOTHING with those group values regardless of how the + // catalog grows. Belt-and-braces check. + const { schema } = await h.gv_template_settings_schema_get({ template_id: 'default_table' }); + for (const it of schema) { + if ((it?.group ?? '') === 'mock_silo' && !(it?.slug ?? '').startsWith('mockone.')) { + throw new Error(`core source emitted undotted slug "${it.slug}" for silo'd group "mock_silo"`); + } + } +}); + +suite.test('Mock source: PATCH /template-settings routes nested writes to the right silo meta', async () => { + if (suite.skip) return; + if (!mockFixtureActive) { + console.log(' (skipping — mock fixture not installed; set GRAVITYVIEW_PLUGIN_PATH + WP_MU_PLUGINS_DIR)'); + return; + } + const viewId = await mintView('mock silo round-trip'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + + await h.gv_view_settings_patch({ + id: viewId, + template_settings: { + mockone: { foo: 'hello', bar: '42', content: '

html ok

' }, + page_size: '99', // top-level — must stay on core meta + }, + }); + + const config = await h.gv_view_config_get({ id: viewId }); + TestAssert.equal(String(config.template_settings.page_size), '99', 'top-level page_size on core meta'); + TestAssert.isTrue( + config.template_settings.mockone && typeof config.template_settings.mockone === 'object', + 'mockone namespace present in read' + ); + TestAssert.equal(config.template_settings.mockone.foo, 'hello'); + TestAssert.equal(Number(config.template_settings.mockone.bar), 42, 'numeric coercion through sanitize_setting_value'); + TestAssert.isTrue( + String(config.template_settings.mockone.content).includes('html ok'), + 'textarea-typed setting content survives' + ); +}); + +suite.test('Mock source: /apply also splits namespaced writes to silo meta', async () => { + if (suite.skip) return; + if (!mockFixtureActive) { + console.log(' (skipping — mock fixture not installed; set GRAVITYVIEW_PLUGIN_PATH + WP_MU_PLUGINS_DIR)'); + return; + } + const viewId = await mintView('mock silo apply path'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + + await h.gv_view_config_apply({ + id: viewId, + template_settings: { + page_size: '25', + mockone: { foo: 'via-apply' }, + }, + mode: 'merge', + }); + + const config = await h.gv_view_config_get({ id: viewId }); + TestAssert.equal(String(config.template_settings.page_size), '25', 'core key persisted via apply'); + TestAssert.equal(config.template_settings.mockone?.foo, 'via-apply', 'silo key persisted via apply'); +}); + +suite.test('Mock source: keys NOT in the partial payload survive the merge', async () => { + if (suite.skip) return; + if (!mockFixtureActive) { + console.log(' (skipping — mock fixture not installed; set GRAVITYVIEW_PLUGIN_PATH + WP_MU_PLUGINS_DIR)'); + return; + } + const viewId = await mintView('mock silo non-overlap merge'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + + // Seed two keys on the silo, then patch only one — the other + // must remain. This proves the per-source bucket-seeded merge + // doesn't blow away unmentioned silo keys. + await h.gv_view_settings_patch({ + id: viewId, + template_settings: { mockone: { foo: 'first', bar: '7' } }, + }); + await h.gv_view_settings_patch({ + id: viewId, + template_settings: { mockone: { foo: 'second' } }, + }); + + const config = await h.gv_view_config_get({ id: viewId }); + TestAssert.equal(config.template_settings.mockone?.foo, 'second', 'updated key takes new value'); + TestAssert.equal(Number(config.template_settings.mockone?.bar), 7, 'untouched key survives merge'); +}); + +// ============================================================ +// VALIDATION + SANITIZATION REGRESSIONS +// ============================================================ + +suite.test('CL with leading whitespace is accepted (trim bug)', async () => { + if (suite.skip) return; + const viewId = await mintView('cl trim bug'); + const padded = ' ' + JSON.stringify({ version: 2, actionType: 'show', logicType: 'all', rules: [] }) + ' \n'; + const apply = await h.gv_view_config_apply({ + id: viewId, + fields: { + 'directory_list-title': [{ + field_id: fieldIds.name, + slot: 'cltrim001', + conditional_logic: padded, + }], + }, + mode: 'merge', + }); + TestAssert.isTrue( + !('warnings' in apply) || apply.warnings.length === 0, + 'no warning emitted — padded JSON was trimmed and accepted' + ); + + const config = await h.gv_view_config_get({ id: viewId }); + const stored = config.fields['directory_list-title']['cltrim001'].conditional_logic; + TestAssert.isTrue( + typeof stored === 'string' && stored.startsWith('{') && stored.endsWith('}'), + `stored CL is the canonical trimmed JSON (got "${stored}")` + ); +}); + +suite.test('Per-field narrowing rejects date_range on search_mode', async () => { + if (suite.skip) return; + const viewId = await mintView('per-field narrow search_mode'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + + let serverStatus = null; + let serverMessage = ''; + try { + await h.gv_search_field_add({ + id: viewId, + widget_area: 'header_top', + widget_slot: widget.slot, + position: 'search-general_top::100::ROW_STUB', + // `date_range` IS in the global allow-list, but invalid for + // `search_mode` per get_input_types_by_field_type. Narrow check + // must reject this combination. + field: { id: 'search_mode', input: 'date_range', label: 'Bad combo' }, + }); + } catch (err) { + serverStatus = err?.response?.status ?? null; + serverMessage = String(err?.response?.data?.message ?? err?.message ?? ''); + } + TestAssert.equal(serverStatus, 400, 'server rejects field-invalid combination'); + TestAssert.isTrue(serverMessage.includes('search_mode'), 'error names the field id'); + TestAssert.isTrue(serverMessage.includes('date_range'), 'error names the bad input'); +}); + +suite.test('Per-field narrowing accepts hidden on search_mode', async () => { + if (suite.skip) return; + const viewId = await mintView('per-field narrow search_mode ok'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + + // `hidden` IS valid for search_mode — should succeed. + const created = await h.gv_search_field_add({ + id: viewId, + widget_area: 'header_top', + widget_slot: widget.slot, + position: 'search-general_top::100::ROW_OK', + field: { id: 'search_mode', input: 'hidden', label: 'Mode' }, + }); + TestAssert.isNotNull(created?.search_slot); +}); + +suite.test('create_widget_slot rejects nested invalid search_fields_section', async () => { + if (suite.skip) return; + const viewId = await mintView('widget nested search reject'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + + let serverStatus = null; + let serverMessage = ''; + try { + await h.gv_view_widget_add({ + id: viewId, + area: 'header_top', + widget: { + field_id: 'search_bar', + label: 'Search', + search_fields_section: { + 'search-general_top::100::ROW': { + stub1: { id: 'search_mode', input: 'date_range', label: 'Bad nested' }, + }, + }, + }, + }); + } catch (err) { + serverStatus = err?.response?.status ?? null; + serverMessage = String(err?.response?.data?.message ?? err?.message ?? ''); + } + TestAssert.equal(serverStatus, 400, 'server rejects nested invalid search input'); + TestAssert.isTrue(serverMessage.includes('Nested search field'), 'error identifies the nested entry'); + TestAssert.isTrue(serverMessage.includes('search_mode'), 'error names the offending field id'); +}); + +suite.test('Warnings: CL string that isn\'t a JSON object → reason=not_json_object', async () => { + if (suite.skip) return; + const viewId = await mintView('cl not json object'); + const apply = await h.gv_view_config_apply({ + id: viewId, + fields: { + 'directory_list-title': [{ + field_id: fieldIds.name, + slot: 'clbad002', + // String that's neither empty nor a JSON object. + conditional_logic: 'hello world', + }], + }, + mode: 'merge', + }); + TestAssert.isTrue(Array.isArray(apply.warnings), 'warnings array present'); + const match = apply.warnings.find((w) => + w.slot === 'clbad002' && w.reason === 'not_json_object' + ); + TestAssert.isNotNull(match, 'warning carries reason=not_json_object'); +}); + +// ============================================================ +// BUGS-CAUGHT regressions — each test pins behaviour that a real +// user demo just surfaced. If any of these fail, the corresponding +// bug has come back. +// ============================================================ + +suite.test('Area keys: gv_create_grid_row returns ready-to-use prefixed area_keys', async () => { + if (suite.skip) return; + const viewId = await mintView('area_keys contract'); + // Layout Builder is the only grid-aware template by default. + await h.gv_view_template_switch({ id: viewId, template_id: 'gravityview-layout-builder' }); + const row = await h.gv_grid_row_add({ + id: viewId, + type: '25/25/25/25', + zones: ['directory'], + }); + TestAssert.isTrue(Array.isArray(row.area_keys), '`area_keys` is an array'); + TestAssert.equal(row.area_keys.length, 4, '4 cells from a 25/25/25/25 row'); + // Every entry must already carry the zone prefix. + row.area_keys.forEach((k) => { + TestAssert.isTrue( + k.startsWith('directory_'), + `area_key "${k}" carries the directory_ prefix` + ); + }); +}); + +suite.test('Area keys: apply_view_config REJECTS a fields area key missing the zone prefix', async () => { + if (suite.skip) return; + const viewId = await mintView('reject unprefixed'); + await h.gv_view_template_switch({ id: viewId, template_id: 'gravityview-layout-builder' }); + const row = await h.gv_grid_row_add({ id: viewId, type: '100', zones: ['directory'] }); + // Use the LEGACY unprefixed key (this is the exact bug the demo hit). + const badKey = `gravityview-layout-builder-top::100::${row.row_uid}`; + let status = null, code = null; + try { + await h.gv_view_config_apply({ + id: viewId, + mode: 'merge', + fields: { [badKey]: [{ field_id: fieldIds.name, slot: 'should_fail' }] }, + }); + } catch (err) { + status = err?.response?.status ?? null; + code = err?.response?.data?.code ?? null; + } + TestAssert.equal(status, 400, 'unprefixed area key → 400'); + TestAssert.equal(code, 'gv_rest_invalid_area_key', 'specific error code surfaces'); +}); + +suite.test('Area keys: apply_view_config REJECTS a bogus widget area key', async () => { + if (suite.skip) return; + const viewId = await mintView('reject bogus widget area'); + let status = null, code = null; + try { + await h.gv_view_config_apply({ + id: viewId, + mode: 'merge', + widgets: { 'not_a_real_zone': [{ field_id: 'search_bar', slot: 'x' }] }, + }); + } catch (err) { + status = err?.response?.status ?? null; + code = err?.response?.data?.code ?? null; + } + TestAssert.equal(status, 400, 'bogus widget zone → 400'); + TestAssert.equal(code, 'gv_rest_invalid_area_key', 'specific error code surfaces'); +}); + +suite.test('Area keys: prefixed keys round-trip end-to-end (create-row → use area_keys → read-back)', async () => { + if (suite.skip) return; + const viewId = await mintView('e2e prefixed roundtrip'); + await h.gv_view_template_switch({ id: viewId, template_id: 'gravityview-layout-builder' }); + const row = await h.gv_grid_row_add({ id: viewId, type: '50/50', zones: ['directory'] }); + TestAssert.equal(row.area_keys.length, 2); + + // Use the API's own returned keys verbatim — the test the demo would have passed. + await h.gv_view_config_apply({ + id: viewId, + mode: 'merge', + fields: { + [row.area_keys[0]]: [{ field_id: fieldIds.name, slot: 'rt_a' }], + [row.area_keys[1]]: [{ field_id: fieldIds.email, slot: 'rt_b' }], + }, + }); + + const cfg = await h.gv_view_config_get({ id: viewId }); + TestAssert.isNotNull(cfg.fields[row.area_keys[0]]?.rt_a, 'first area has its slot after apply'); + TestAssert.isNotNull(cfg.fields[row.area_keys[1]]?.rt_b, 'second area has its slot after apply'); + + // No orphan unprefixed keys. + const orphans = Object.keys(cfg.fields || {}).filter( + (k) => !k.startsWith('directory_') && !k.startsWith('single_') && !k.startsWith('edit_'), + ); + TestAssert.equal(orphans.length, 0, `no orphan unprefixed area keys (got: ${orphans.join(', ') || 'none'})`); +}); + +suite.test('Inspector shape: template_ids contains directory + single ONLY (no edit)', async () => { + if (suite.skip) return; + const viewId = await mintView('template_ids no edit'); + const cfg = await h.gv_view_config_get({ id: viewId }); + TestAssert.isNotNull(cfg.template_ids?.directory, 'template_ids.directory present'); + TestAssert.isNotNull(cfg.template_ids?.single, 'template_ids.single present'); + TestAssert.isTrue( + !('edit' in (cfg.template_ids || {})), + 'template_ids.edit absent — Edit Entry has no per-zone template choice', + ); +}); + +suite.test('Inspector shape: template_settings does NOT carry the legacy `template` key', async () => { + if (suite.skip) return; + const viewId = await mintView('no legacy template key'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const cfg = await h.gv_view_config_get({ id: viewId }); + TestAssert.isTrue( + !('template' in (cfg.template_settings || {})), + 'template_settings.template stripped — canonical store is template_ids.directory', + ); +}); + +suite.test('Inspector shape: template_settings stays clean even after a template switch', async () => { + if (suite.skip) return; + const viewId = await mintView('template switch no leak'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + await h.gv_view_template_switch({ id: viewId, template_id: 'gravityview-layout-builder' }); + const cfg = await h.gv_view_config_get({ id: viewId }); + TestAssert.isTrue( + !('template' in (cfg.template_settings || {})), + 'template_settings.template still absent after switch', + ); +}); + +// ============================================================ +// Search-bar legacy-save round-trip +// +// The bug: MCP-written search fields used `input` + `type` keys +// and bare numeric GF ids. The legacy admin metabox parser only +// recognises `input_type` and `{form_id}::{field_id}` ids. On +// save through the legacy UI, every MCP-written field was +// silently dropped. +// +// The fix: normalise_search_field_payload now routes every entry +// through Search_Field::from_configuration() → to_configuration(), +// so storage carries the canonical shape regardless of who wrote it. +// ============================================================ + +suite.test('Search field shape: gv_search_field_add emits the domain canonical shape', async () => { + if (suite.skip) return; + const viewId = await mintView('search field canonical shape'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + const created = await h.gv_search_field_add({ + id: viewId, + widget_area: 'header_top', + widget_slot: widget.slot, + position: 'search-general_top::100::canonshape_row', + field: { id: fieldIds.name, input: 'input_text', label: 'Speaker' }, + }); + + const cfg = await h.gv_view_config_get({ id: viewId }); + const stored = cfg.widgets.header_top[widget.slot].search_fields_section[ + 'search-general_top::100::canonshape_row' + ][created.search_slot]; + + // Canonical shape per Search_Field::to_configuration(): + // id, UID, type ({form_id}::{field_id}), label, position, form_id, + // show_label, input_type, plus any explicit settings. + // For GF fields the GF subclass also emits `form_field` (the resolved + // GF Field object). + TestAssert.equal(stored.input_type, 'input_text', 'input_type set (canonical)'); + TestAssert.isTrue(!('input' in stored), 'input alias dropped after translation'); + TestAssert.isTrue('UID' in stored, 'UID minted by domain'); + TestAssert.isTrue('type' in stored, 'type emitted by domain (canonical {form_id}::{field_id})'); + TestAssert.isTrue('form_id' in stored, 'form_id stamped per field'); + TestAssert.isTrue('show_label' in stored, 'show_label default carried (true unless overridden)'); + // The legacy-admin pad-with-empties keys are intentionally absent — + // the domain class only emits what was actually set, and downstream + // consumers (required_cap, etc.) read with `?? defaults`. + TestAssert.isTrue(!('custom_label' in stored), 'custom_label NOT padded (domain emits only set settings)'); + TestAssert.isTrue(!('only_loggedin' in stored), 'only_loggedin NOT padded (default is null at read time)'); +}); + +suite.test('Search field shape: GF field carries `type`={form_id}::{field_id} + form_field object', async () => { + if (suite.skip) return; + const viewId = await mintView('search field gf canonical type'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + await h.gv_search_field_add({ + id: viewId, + widget_area: 'header_top', + widget_slot: widget.slot, + position: 'search-general_top::100::gftype_row', + field: { id: fieldIds.email, input: 'input_text', label: 'Email' }, + }); + + const cfg = await h.gv_view_config_get({ id: viewId }); + const slot = Object.values( + cfg.widgets.header_top[widget.slot].search_fields_section['search-general_top::100::gftype_row'], + )[0]; + + // The domain emits the canonical `{form_id}::{field_id}` under the + // `type` key (Search_Field_Gravity_Forms::get_type), keeping `id` + // as the bare GF id the user supplied. + TestAssert.isTrue( + /^\d+::\d+(\.\d+)?$/.test(String(slot.type)), + `type is form-prefixed canonical id (got "${slot.type}")`, + ); + // GF subclass also resolves the inner GF field array into form_field. + TestAssert.isTrue(slot.form_field && typeof slot.form_field === 'object', 'form_field object resolved'); + TestAssert.isTrue('id' in slot.form_field && 'type' in slot.form_field, 'form_field carries the GF field shape'); +}); + +suite.test('Search field shape: bulk apply (gv_view_config_apply) routes nested entries through the domain too', async () => { + if (suite.skip) return; + const viewId = await mintView('bulk normalise search section'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + // Bulk-apply a nested search_fields_section through gv_view_config_apply — + // entries SHOULD pass through Search_Field::from_configuration → to_configuration + // just like the per-field CRUD path does. + await h.gv_view_config_apply({ + id: viewId, + mode: 'merge', + widgets: { + header_top: [{ + field_id: 'search_bar', + slot: widget.slot, + label: 'Search', + search_fields_section: { + 'search-general_top::100::bulkrow': { + bulkfield: { id: fieldIds.name, input: 'input_text', label: 'Bulk-name' }, + }, + }, + }], + }, + }); + + const cfg = await h.gv_view_config_get({ id: viewId }); + const stored = cfg.widgets.header_top[widget.slot].search_fields_section[ + 'search-general_top::100::bulkrow' + ].bulkfield; + TestAssert.equal(stored.input_type, 'input_text', 'input_type translated in bulk path'); + TestAssert.isTrue(!('input' in stored), 'input alias dropped in bulk path'); + TestAssert.isTrue('UID' in stored, 'UID minted in bulk path'); + TestAssert.isTrue('form_id' in stored, 'form_id stamped in bulk path'); + TestAssert.isTrue('show_label' in stored, 'show_label default in bulk path'); + TestAssert.isTrue( + /^\d+::\d+(\.\d+)?$/.test(String(stored.type)), + `type is form-prefixed in bulk path (got "${stored.type}")`, + ); + TestAssert.isTrue(stored.form_field && typeof stored.form_field === 'object', 'form_field object resolved in bulk path'); +}); + +suite.test('Search field round-trip: fields survive a legacy WP save_post (bug-caught regression)', async () => { + if (suite.skip) return; + // Canonical bug-caught test: MCP writes a search bar; we re-trigger + // save_post (the same chain the legacy admin metabox save fires); + // MCP-written fields MUST still be there afterwards. Without + // Search_Field domain delegation, the legacy parser would drop + // every field with non-canonical keys. + // + // Uses wp-cli (via the cp.execSync helper). When wp-cli isn't + // reachable from the test runner (e.g., remote CI), skips with a + // clear note. + // Requires both wp-cli on PATH AND a WP_ROOT env var pointing at + // the WordPress install. Skips cleanly without a hardcoded fallback + // — this test runs in any dev env that supplies them. + const wpRoot = process.env.WP_ROOT; + if (!wpRoot) { + console.log(' (skipping — set WP_ROOT to your WP install path to run this test)'); + return; + } + let cp; + try { + cp = await import('node:child_process'); + cp.execSync('which wp', { stdio: 'pipe' }); + } catch (_) { + console.log(' (skipping — wp-cli not available; install wp-cli to run this test)'); + return; + } + + const viewId = await mintView('search field legacy roundtrip'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, + area: 'header_top', + widget: { field_id: 'search_bar', label: 'Search' }, + }); + await h.gv_search_field_add({ + id: viewId, + widget_area: 'header_top', + widget_slot: widget.slot, + position: 'search-general_top::100::roundtrip_row', + slot: 'roundtrip_slot', + field: { id: fieldIds.name, input: 'input_text', label: 'Speaker' }, + }); + + // Trigger save_post via wp-cli — runs the legacy metabox save_post + // hook chain end-to-end against the live WP install. + cp.execSync(`cd ${wpRoot} && wp post update ${viewId} --post_modified="$(date -u +'%Y-%m-%d %H:%M:%S')"`, { stdio: 'pipe' }); + + const cfg = await h.gv_view_config_get({ id: viewId }); + const section = cfg.widgets?.header_top?.[widget.slot]?.search_fields_section; + TestAssert.isNotNull(section, 'search_fields_section present after legacy save'); + const row = section['search-general_top::100::roundtrip_row']; + TestAssert.isNotNull(row, 'position key survived legacy save'); + TestAssert.isNotNull(row?.roundtrip_slot, 'MCP-written slot UID survived legacy save'); + TestAssert.equal(row.roundtrip_slot.input_type, 'input_text', 'input_type survived legacy save'); +}); + +// ============================================================ +// HOSTILE STRESS TESTS — actively try to break the API surface +// +// Every test here either confirms the surface holds under abuse, +// or fails loudly when behaviour is wrong (silent success on bad +// input, 500 instead of 400, torn reads, lost writes, etc.). +// Tests are deliberately ruthless — see the prompt for the full +// matrix. Tests are NOT modified to "match reality" when they +// uncover a bug — they're left failing with a clear message so +// the bug shows up in the suite output. +// ============================================================ + +/** Convenience: pull HTTP status / WP error code off a thrown axios error. */ +function errStatus(err) { return err?.response?.status ?? null; } +function errCode(err) { return err?.response?.data?.code ?? null; } +function errMessage(err) { + return String(err?.response?.data?.message ?? err?.message ?? ''); +} + +/** Run an apply that we expect to throw; return the captured error info. */ +async function expectApplyError(args) { + try { + const result = await h.gv_view_config_apply(args); + return { thrown: false, result }; + } catch (err) { + return { + thrown: true, + status: errStatus(err), + code: errCode(err), + message: errMessage(err), + }; + } +} + +// ---------- CONCURRENCY chaos ---------- + +suite.test('[hostile] Concurrency: 10 parallel applies with same If-Match → exactly 1 wins', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-conc-10'); + const config = await h.gv_view_config_get({ id: viewId }); + const etag = `"${config.version}"`; + const N = 10; + + const results = await Promise.allSettled( + Array.from({ length: N }, (_, i) => + h.gv_view_config_apply({ + id: viewId, + ifMatch: etag, + mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: `parc${String(i).padStart(3, '0')}` }] }, + }) + ) + ); + const accepted = results.filter((r) => r.status === 'fulfilled').length; + const rejected = results.filter((r) => r.status === 'rejected' && (errStatus(r.reason) === 412 || /412|precondition/i.test(errMessage(r.reason)))).length; + TestAssert.equal(accepted, 1, `expected exactly 1 accepted write out of ${N}, got ${accepted}`); + TestAssert.equal(rejected, N - 1, `expected ${N - 1} 412 rejections, got ${rejected}`); +}); + +suite.test('[hostile] Concurrency: 50 parallel reads of gv_view_config_get all succeed', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-50-reads'); + const N = 50; + const results = await Promise.allSettled( + Array.from({ length: N }, () => h.gv_view_config_get({ id: viewId })) + ); + const ok = results.filter((r) => r.status === 'fulfilled' && r.value?.view_id === viewId).length; + TestAssert.equal(ok, N, `all ${N} reads should succeed, got ${ok}`); +}); + +suite.test('[hostile] Concurrency: 20 interleaved apply+read pairs — no torn reads', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-interleaved'); + const N = 20; + + // Sequential apply→read pairs (no ifMatch — abilities path doesn't + // wire the GV client version cache, and we want every write to land + // unconditionally so we can assert each one is visible immediately). + for (let i = 0; i < N; i++) { + const slotUid = `tear${String(i).padStart(3, '0')}`; + await h.gv_view_config_apply({ + id: viewId, + mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: slotUid, custom_label: `Iter ${i}` }] }, + }); + const cfg = await h.gv_view_config_get({ id: viewId }); + const slot = cfg.fields?.['directory_list-title']?.[slotUid]; + TestAssert.isNotNull(slot, `iteration ${i}: slot ${slotUid} must be present in subsequent read`); + TestAssert.equal(slot.custom_label, `Iter ${i}`, `iteration ${i}: custom_label torn`); + } +}); + +suite.test('[hostile] Concurrency: two writers racing on different slots in same area both land', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-two-writers'); + const cfg = await h.gv_view_config_get({ id: viewId }); + const etag = `"${cfg.version}"`; + + // Same etag, but distinct slot UIDs in the same area. + const results = await Promise.allSettled([ + h.gv_view_config_apply({ + id: viewId, + ifMatch: etag, + mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'race_a' }] }, + }), + h.gv_view_config_apply({ + id: viewId, + ifMatch: etag, + mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.email, slot: 'race_b' }] }, + }), + ]); + // Optimistic concurrency means with same ifMatch, only one wins. The other + // gets 412 — the caller must retry. Document this contract. + const accepted = results.filter((r) => r.status === 'fulfilled').length; + TestAssert.equal(accepted, 1, 'optimistic concurrency: only 1 same-etag write lands per round'); + + // Now retry the rejected write WITHOUT ifMatch (the docs say omit = bypass). + await h.gv_view_config_apply({ + id: viewId, + mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.email, slot: 'race_b' }] }, + }); + const final = await h.gv_view_config_get({ id: viewId }); + TestAssert.isNotNull(final.fields?.['directory_list-title']?.race_a, 'race_a landed'); + TestAssert.isNotNull(final.fields?.['directory_list-title']?.race_b, 'race_b landed after retry without ifMatch'); +}); + +// ---------- HUGE PAYLOADS ---------- + +suite.test('[hostile] Huge payload: 200 fields in a single apply', async () => { + if (suite.skip) return; + console.log(' (building 200-field payload…)'); + const viewId = await mintView('hostile-huge-payload-200-fields'); + const slots = Array.from({ length: 200 }, (_, i) => ({ + field_id: fieldIds.name, + slot: `huge${String(i).padStart(4, '0')}`, + custom_label: `Slot #${i}`, + })); + const t0 = Date.now(); + let result, err; + try { + result = await h.gv_view_config_apply({ + id: viewId, + mode: 'merge', + fields: { 'directory_list-title': slots }, + }); + } catch (e) { err = e; } + console.log(` (200-field apply took ${Date.now() - t0}ms)`); + + if (err) { + // A 413 / 400 with a meaningful "too large" code is acceptable. + const status = errStatus(err); + TestAssert.isTrue( + status === 400 || status === 413, + `200-field apply failed with non-sane status ${status}: ${errMessage(err)}` + ); + return; + } + TestAssert.isNotNull(result.applied, '200-field apply succeeded'); + // Spot-check the round trip — pick start/end/middle. + const cfg = await h.gv_view_config_get({ id: viewId }); + const titleArea = cfg.fields?.['directory_list-title'] || {}; + TestAssert.isNotNull(titleArea.huge0000, 'first slot present'); + TestAssert.isNotNull(titleArea.huge0099, 'middle slot present'); + TestAssert.isNotNull(titleArea.huge0199, 'last slot present'); +}); + +suite.test('[hostile] Huge payload: custom_content with 50KB HTML body survives', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-50kb-custom-content'); + const big = '

' + 'lorem ipsum dolor sit amet '.repeat(2000) + '

'; // ~ 53 KB + const stored = await roundTripSlot(viewId, 'directory_list-description', 'huge50kb', { + field_id: 'custom', + content: big, + wpautop: false, + }); + TestAssert.isTrue(stored.content && stored.content.length >= 50 * 1000, `content survived (got ${stored.content?.length ?? 0} bytes)`); +}); + +suite.test('[hostile] Huge payload: conditional_logic with 100 nested rules', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-cl-100-rules'); + const rules = Array.from({ length: 100 }, (_, i) => ({ + fieldId: fieldIds.name, operator: 'is', value: `v${i}`, + })); + const cl = { version: 2, actionType: 'show', logicType: 'all', rules }; + let stored, err; + try { + stored = await roundTripSlot(viewId, 'directory_list-title', 'cl100', { + field_id: fieldIds.name, + conditional_logic: cl, + }); + } catch (e) { err = e; } + if (err) { + const status = errStatus(err); + TestAssert.isTrue(status === 400 || status === 413, `100-rule CL must reject cleanly, got status ${status}: ${errMessage(err)}`); + return; + } + TestAssert.isNotNull(stored.conditional_logic, '100-rule CL persisted'); + const decoded = typeof stored.conditional_logic === 'string' + ? JSON.parse(stored.conditional_logic) + : stored.conditional_logic; + TestAssert.equal(Array.isArray(decoded.rules) ? decoded.rules.length : 0, 100, 'all 100 rules persisted'); +}); + +suite.test('[hostile] Huge payload: search bar with 50 search fields across 5 positions', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-50-search-fields'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, + }); + const positions = ['10', '20', '30', '40', '50']; + for (const pos of positions) { + for (let i = 0; i < 10; i++) { + try { + await h.gv_search_field_add({ + id: viewId, widget_area: 'header_top', widget_slot: widget.slot, + position: `search-general_top::${pos}::row_${pos}`, + slot: `s_${pos}_${i}`, + field: { id: fieldIds.name, input: 'input_text', label: `f${i}@${pos}` }, + }); + } catch (e) { + throw new Error(`search field ${pos}/${i} failed: ${errStatus(e)} ${errMessage(e)}`); + } + } + } + const cfg = await h.gv_view_config_get({ id: viewId }); + const section = cfg.widgets?.header_top?.[widget.slot]?.search_fields_section || {}; + let total = 0; + for (const row of Object.values(section)) total += Object.keys(row || {}).length; + TestAssert.equal(total, 50, `expected 50 search fields stored, got ${total}`); +}); + +// ---------- MALFORMED INPUTS ---------- + +suite.test('[hostile] Malformed: fields = string-not-object → 400', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-fields-string'); + const r = await expectApplyError({ id: viewId, mode: 'merge', fields: 'not-an-object' }); + TestAssert.isTrue(r.thrown, 'string fields must reject'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `status must be 4xx, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: fields = { area: "not-an-array" } → 400', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-area-string'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': 'not-an-array' }, + }); + TestAssert.isTrue(r.thrown, 'string-typed area value must reject'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `status must be 4xx, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: field_id = null → reject (4xx, no 500)', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-field-null'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: null, slot: 'fnull01' }] }, + }); + TestAssert.isTrue(r.thrown, 'null field_id must reject'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `status must be 4xx, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: field_id = object → reject', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-field-obj'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: { evil: true }, slot: 'fobj01' }] }, + }); + TestAssert.isTrue(r.thrown, 'object field_id must reject'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `status must be 4xx, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: ifMatch = empty string is treated as no-precondition', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-ifmatch-empty'); + // Empty string ifMatch should be a no-op (not a 412, not a 500). + const r = await expectApplyError({ + id: viewId, mode: 'merge', ifMatch: '', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'iem01' }] }, + }); + TestAssert.isTrue(!r.thrown, `empty ifMatch should not throw (got ${r.status} ${r.message})`); +}); + +suite.test('[hostile] Malformed: ifMatch = "" (literal quoted empty) → 412 or treated as no-op cleanly', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-ifmatch-quoted-empty'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', ifMatch: '""', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'ieq01' }] }, + }); + // Either accept (no version match attempt) or reject 412 — but never 5xx. + if (r.thrown) { + TestAssert.isTrue(r.status === 412 || (r.status >= 400 && r.status < 500), `status must be sane, got ${r.status}: ${r.message}`); + } +}); + +suite.test('[hostile] Malformed: ifMatch = whitespace only → no 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-ifmatch-ws'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', ifMatch: ' ', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'iws01' }] }, + }); + if (r.thrown) { + TestAssert.isTrue(r.status >= 400 && r.status < 500, `status must be 4xx, got ${r.status}: ${r.message}`); + } +}); + +suite.test('[hostile] Malformed: ifMatch = giant string (10KB) → 4xx, not 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-ifmatch-giant'); + const giant = '"' + 'A'.repeat(10000) + '"'; + const r = await expectApplyError({ + id: viewId, mode: 'merge', ifMatch: giant, + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'ig01' }] }, + }); + // Will mismatch → 412. + TestAssert.isTrue(r.thrown, 'giant ifMatch must reject'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `must 4xx, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: ifMatch = SQL injection attempt → safe rejection', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-ifmatch-sqli'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + ifMatch: `"' OR '1'='1"; DROP TABLE wp_posts; --"`, + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'sqli01' }] }, + }); + TestAssert.isTrue(r.thrown, 'malicious ifMatch must reject'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `must be 4xx, got ${r.status}: ${r.message}`); + // Confirm the view is still alive afterwards (no DB damage). + const cfg = await h.gv_view_config_get({ id: viewId }); + TestAssert.equal(cfg.view_id, viewId, 'view still readable after SQLi attempt'); +}); + +suite.test('[hostile] Malformed: area key with newlines/tabs/null bytes → 400 with invalid_area_key', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-area-newlines'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title\n\tevil': [{ field_id: fieldIds.name, slot: 'ank01' }] }, + }); + TestAssert.isTrue(r.thrown, 'must reject control-char area key'); + TestAssert.equal(r.status, 400, `must 400, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: area key 10000 chars long → 400', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-area-long'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { ['directory_' + 'a'.repeat(10000)]: [{ field_id: fieldIds.name, slot: 'al01' }] }, + }); + TestAssert.isTrue(r.thrown, 'must reject huge area key'); + TestAssert.isTrue(r.status >= 400 && r.status < 500, `must 4xx, got ${r.status}: ${r.message}`); +}); + +suite.test('[hostile] Malformed: slot uid with path traversal "../../../etc/passwd"', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-slot-traversal'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: '../../../etc/passwd' }] }, + }); + // Server should normalise / reject. If it accepts, ensure no filesystem + // surprise — the slot must just be a string key, not a real path. + if (!r.thrown) { + const cfg = await h.gv_view_config_get({ id: viewId }); + const area = cfg.fields?.['directory_list-title'] || {}; + const keys = Object.keys(area); + TestAssert.isTrue(keys.length > 0, 'something stored'); + // Whatever the slot got rewritten to, it MUST not contain raw "../" + // (a downstream consumer might use it as a path component). + for (const k of keys) { + TestAssert.isTrue(!k.includes('../'), `slot key "${k}" must not contain "../"`); + } + } else { + TestAssert.isTrue(r.status >= 400 && r.status < 500, `if rejected, must 4xx (got ${r.status})`); + } +}); + +suite.test('[hostile] Sanitisation: custom_label with Hello', + }); + TestAssert.isTrue(!String(stored.custom_label || '').includes(' must be stripped, got "${stored.custom_label}"`); + TestAssert.isTrue(String(stored.custom_label || '').includes('Hello'), 'inner text "Hello" survives'); +}); + +suite.test('[hostile] Sanitisation: custom_label with NUL/control chars/RTL override → no NUL/RTLO survives', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-control-label'); + const evil = 'safenul‎rtl‮flipbell'; + const stored = await roundTripSlot(viewId, 'directory_list-title', 'ccl01', { + field_id: fieldIds.name, + custom_label: evil, + }); + const out = String(stored.custom_label || ''); + // NUL bytes must not survive — they break log lines, JSON serialisers, etc. + TestAssert.isTrue(!out.includes(''), `NUL byte must be stripped (got "${JSON.stringify(out)}")`); + // BEL is a control char that should also not survive in a plain text setting. + TestAssert.isTrue(!out.includes(''), `BEL must be stripped (got "${JSON.stringify(out)}")`); +}); + +suite.test('[hostile] Edge: field_id = "0" (zero) → either reject or treat as numeric 0 form field', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-field-zero'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: '0', slot: 'fz01' }] }, + }); + if (r.thrown) { + TestAssert.isTrue(r.status >= 400 && r.status < 500, `field_id 0 reject must be 4xx (got ${r.status})`); + } + // If accepted, view must still be readable. + const cfg = await h.gv_view_config_get({ id: viewId }); + TestAssert.equal(cfg.view_id, viewId, 'view readable after field_id=0'); +}); + +suite.test('[hostile] Edge: field_id = "-1" → reject or accept without 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-field-neg'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: '-1', slot: 'fn01' }] }, + }); + if (r.thrown) { + TestAssert.isTrue(r.status >= 400 && r.status < 500, `field_id -1 reject must be 4xx (got ${r.status})`); + } +}); + +suite.test('[hostile] Edge: field_id = "1.2.3.4" (IP-like) → no 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-field-iplike'); + const r = await expectApplyError({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: '1.2.3.4', slot: 'fi01' }] }, + }); + if (r.thrown) { + TestAssert.isTrue(r.status >= 400 && r.status < 500, `IP-like field_id reject must be 4xx (got ${r.status})`); + } +}); + +suite.test('[hostile] CL: invalid JSON string → warning, value dropped, no 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-cl-bad-json'); + const apply = await h.gv_view_config_apply({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ + field_id: fieldIds.name, slot: 'clbj01', + conditional_logic: '{ this is not json', + }] }, + }); + TestAssert.isTrue(Array.isArray(apply.warnings), 'warnings array must be present for bad CL'); + const match = apply.warnings.find((w) => w.slot === 'clbj01' && w.key === 'conditional_logic'); + TestAssert.isNotNull(match, `bad-JSON CL must surface a warning (got ${JSON.stringify(apply.warnings)})`); +}); + +suite.test('[hostile] CL: valid JSON of wrong shape (e.g. array) → warning, value dropped', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-cl-wrong-shape'); + const apply = await h.gv_view_config_apply({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ + field_id: fieldIds.name, slot: 'clws01', + conditional_logic: '[1, 2, 3]', + }] }, + }); + TestAssert.isTrue(Array.isArray(apply.warnings), 'warnings array must be present'); + const match = apply.warnings.find((w) => w.slot === 'clws01' && w.key === 'conditional_logic'); + TestAssert.isNotNull(match, `array-shape CL must warn (got ${JSON.stringify(apply.warnings)})`); +}); + +suite.test('[hostile] Unicode: zalgo-text custom_label round-trips', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-zalgo'); + const zalgo = 'Z̸̢̧̛̛̮̪̱͚̑̄̀͆a̵̧͉̭̱̾̾̊̏l̶̢̧̪̟̥͉̃̃̄g̶̢̗̱͉̑̄̾͂o̸̧̮̪̭̭̾̾̃̃'; + const stored = await roundTripSlot(viewId, 'directory_list-title', 'zlg01', { + field_id: fieldIds.name, + custom_label: zalgo, + }); + TestAssert.equal(stored.custom_label, zalgo); +}); + +suite.test('[hostile] Unicode: emoji-only custom_label', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-emoji-only'); + const emoji = '🔥🎉🚀💥🐉🦄🌈'; + const stored = await roundTripSlot(viewId, 'directory_list-title', 'emo01', { + field_id: fieldIds.name, + custom_label: emoji, + }); + TestAssert.equal(stored.custom_label, emoji); +}); + +// ---------- INPUT TYPE EDGE CASES ---------- + +suite.test('[hostile] Search input: leading/trailing whitespace (" input_text ") → reject or trim cleanly', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-search-whitespace'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, + }); + let status = null; + try { + await h.gv_search_field_add({ + id: viewId, widget_area: 'header_top', widget_slot: widget.slot, + position: 'search-general_top::100::ws_row', + field: { id: fieldIds.name, input: ' input_text ', label: 'WS' }, + }); + } catch (e) { status = errStatus(e); } + // Either pre-flight rejects (acceptable) or server rejects (acceptable). + // 5xx is NEVER acceptable. + if (status !== null) { + TestAssert.isTrue(status >= 400 && status < 500, `whitespace input slug must 4xx, got ${status}`); + } +}); + +suite.test('[hostile] Search input: wrong-case ("INPUT_TEXT") → reject or normalise', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-search-case'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, + }); + let status = null; + try { + await h.gv_search_field_add({ + id: viewId, widget_area: 'header_top', widget_slot: widget.slot, + position: 'search-general_top::100::case_row', + field: { id: fieldIds.name, input: 'INPUT_TEXT', label: 'CASE' }, + }); + } catch (e) { status = errStatus(e); } + if (status !== null) { + TestAssert.isTrue(status === 400, `wrong-case input slug must 400, got ${status}`); + } +}); + +suite.test('[hostile] Search input: numeric (1) instead of string', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-search-numeric-input'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, + }); + let status = null; + try { + await h.gv_search_field_add({ + id: viewId, widget_area: 'header_top', widget_slot: widget.slot, + position: 'search-general_top::100::num_row', + field: { id: fieldIds.name, input: 1, label: 'NUM' }, + }); + } catch (e) { status = errStatus(e); } + if (status !== null) { + TestAssert.isTrue(status >= 400 && status < 500, `numeric input must 4xx, got ${status}`); + } +}); + +suite.test('[hostile] Search field: field.id = 0 → reject', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-searchfield-zero'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, + }); + let status = null; + try { + await h.gv_search_field_add({ + id: viewId, widget_area: 'header_top', widget_slot: widget.slot, + position: 'search-general_top::100::z_row', + field: { id: 0, input: 'input_text', label: 'Z' }, + }); + } catch (e) { status = errStatus(e); } + if (status !== null) { + TestAssert.isTrue(status >= 400 && status < 500, `field.id=0 must 4xx, got ${status}`); + } +}); + +suite.test('[hostile] Search field: field.id pointing at a deleted/non-existent GF field', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-searchfield-ghost'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const widget = await h.gv_view_widget_add({ + id: viewId, area: 'header_top', widget: { field_id: 'search_bar', label: 'Search' }, + }); + let status = null; + try { + await h.gv_search_field_add({ + id: viewId, widget_area: 'header_top', widget_slot: widget.slot, + position: 'search-general_top::100::g_row', + field: { id: 99999, input: 'input_text', label: 'Ghost' }, + }); + } catch (e) { status = errStatus(e); } + // Ideally rejects; if it accepts (because the value isn't validated until render), + // confirm no 5xx happened. + if (status !== null) { + TestAssert.isTrue(status >= 400 && status < 500, `ghost field reject must 4xx, got ${status}`); + } +}); + +// ---------- WIDGET / SEARCH chaos ---------- + +suite.test('[hostile] Widget: bogus widget id (definitely_not_real) → reject', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-widget-bogus-id'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + let status = null; + try { + await h.gv_view_widget_add({ + id: viewId, area: 'header_top', + widget: { field_id: 'definitely_not_a_real_widget', label: 'X' }, + }); + } catch (e) { status = errStatus(e); } + if (status !== null) { + TestAssert.isTrue(status >= 400 && status < 500, `bogus widget reject must 4xx, got ${status}`); + } +}); + +suite.test('[hostile] area_settings injected as a "field" must NOT be treated as a slot', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-area-settings-injection'); + // area_settings is a meta key on the area itself, not a slot. + // A slot literally named "area_settings" should either reject or be + // namespaced so it doesn't clobber real area_settings. + await h.gv_view_config_apply({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'area_settings' }] }, + }); + const cfg = await h.gv_view_config_get({ id: viewId }); + // If "area_settings" got stored as a real slot key, it MUST have a field_id — + // otherwise it's been confused with the area-level settings envelope. + const stored = cfg.fields?.['directory_list-title']?.area_settings; + if (stored) { + TestAssert.isTrue( + 'field_id' in stored || 'id' in stored, + `slot "area_settings" must look like a slot, not an envelope (got ${JSON.stringify(stored)})` + ); + } +}); + +// ---------- TEMPLATE SETTINGS edge cases ---------- + +suite.test('[hostile] template-settings: page_size = 0 → coerced or rejected, no 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-pagesize-0'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + let err; + try { + await h.gv_view_settings_patch({ + id: viewId, template_settings: { page_size: 0 }, + }); + } catch (e) { err = e; } + if (err) { + TestAssert.isTrue(errStatus(err) >= 400 && errStatus(err) < 500, `page_size=0 reject must 4xx, got ${errStatus(err)}: ${errMessage(err)}`); + } +}); + +suite.test('[hostile] template-settings: page_size = -1 → coerced or rejected', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-pagesize-neg'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + let err; + try { + await h.gv_view_settings_patch({ + id: viewId, template_settings: { page_size: -1 }, + }); + } catch (e) { err = e; } + if (err) { + TestAssert.isTrue(errStatus(err) >= 400 && errStatus(err) < 500, `page_size=-1 reject must 4xx, got ${errStatus(err)}: ${errMessage(err)}`); + } +}); + +suite.test('[hostile] template-settings: page_size = "abc" → 4xx or coerced to 0/default', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-pagesize-string'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + let err; + try { + await h.gv_view_settings_patch({ + id: viewId, template_settings: { page_size: 'abc' }, + }); + } catch (e) { err = e; } + if (err) { + TestAssert.isTrue(errStatus(err) >= 400 && errStatus(err) < 500, `page_size="abc" reject must 4xx, got ${errStatus(err)}: ${errMessage(err)}`); + } else { + const cfg = await h.gv_view_config_get({ id: viewId }); + const ps = cfg.template_settings?.page_size; + TestAssert.isTrue( + ps === undefined || ps === null || ps === 0 || /^\d+$/.test(String(ps)), + `page_size after "abc" must be numeric or absent, got ${JSON.stringify(ps)}` + ); + } +}); + +suite.test('[hostile] template-settings: 100 keys at once', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-100-template-keys'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const ts = {}; + for (let i = 0; i < 100; i++) ts[`unknown_key_${i}`] = `v${i}`; + let err; + try { + await h.gv_view_settings_patch({ id: viewId, template_settings: ts }); + } catch (e) { err = e; } + if (err) { + TestAssert.isTrue(errStatus(err) >= 400 && errStatus(err) < 500, `bulk-template-settings must 4xx, got ${errStatus(err)}`); + } +}); + +// ---------- RENDER hot path ---------- + +suite.test('[hostile] Render: 50 parallel staged_slot renders', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-render-50-parallel'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const N = 50; + const t0 = Date.now(); + const results = await Promise.allSettled( + Array.from({ length: N }, (_, i) => h.gv_view_field_render({ + id: viewId, area: 'directory_table-columns', + slot: `parallel${String(i).padStart(3, '0')}`, + staged_slot: { field_id: fieldIds.name, custom_label: `Parallel ${i}`, show_label: '1' }, + })) + ); + console.log(` (50 parallel renders took ${Date.now() - t0}ms)`); + // Allow 503 (no entries) — but never 5xx other than 503, never 4xx unless 404. + let okOrEmpty = 0, problems = []; + for (const r of results) { + if (r.status === 'fulfilled') { okOrEmpty++; continue; } + const s = errStatus(r.reason); + if (s === 503) { okOrEmpty++; continue; } + problems.push(`status ${s}: ${errMessage(r.reason)}`); + } + TestAssert.equal(problems.length, 0, `parallel renders had ${problems.length} unexpected problems: ${problems.slice(0, 3).join(' | ')}`); +}); + +suite.test('[hostile] Render: extremely long custom_label (10KB) — no 5xx', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-render-long-label'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + const longLabel = 'L'.repeat(10000); + let status = null; + try { + await h.gv_view_field_render({ + id: viewId, area: 'directory_table-columns', slot: 'longlabel001', + staged_slot: { field_id: fieldIds.name, custom_label: longLabel, show_label: '1' }, + }); + } catch (e) { status = errStatus(e); } + TestAssert.isTrue(status === null || status === 503 || (status >= 400 && status < 500), + `long-label render must succeed/4xx/503 only, got ${status}`); +}); + +// ---------- RACE / CROSS-VIEW ---------- + +suite.test('[hostile] Apply to a deleted view → 404 (not 500)', async () => { + if (suite.skip) return; + // Needs wp-cli — the WP REST `gravityview` post type isn't exposed + // for DELETE, so we have to go through wp-cli or skip cleanly. + const wpRoot = process.env.WP_ROOT; + let cp; + if (wpRoot) { + try { + cp = await import('node:child_process'); + cp.execSync('which wp', { stdio: 'pipe' }); + } catch (_) { cp = null; } + } + if (!cp || !wpRoot) { + console.log(' (skipping — needs WP_ROOT + wp-cli to actually delete the view)'); + return; + } + + const viewId = await mintView('hostile-apply-after-delete'); + // Trash + force-delete via wp-cli. + cp.execSync(`cd ${wpRoot} && wp post delete ${viewId} --force`, { stdio: 'pipe' }); + // Drop from cleanup tracking — already gone. + mintedViewIds = mintedViewIds.filter((v) => v !== viewId); + + let status = null; + let message = ''; + try { + await h.gv_view_config_apply({ + id: viewId, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'd01' }] }, + }); + } catch (e) { status = errStatus(e); message = errMessage(e); } + TestAssert.equal(status, 404, `deleted view apply must 404 (got ${status}: ${message})`); +}); + +suite.test('[hostile] Cross-view ifMatch → 412', async () => { + if (suite.skip) return; + // Mint view A, mutate it so its version counter bumps to ":1+", then mint B. + // This guarantees A's etag is genuinely incompatible with B's fresh ":0". + const viewA = await mintView('hostile-xview-A'); + await h.gv_view_config_apply({ + id: viewA, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'bump_a' }] }, + }); + const cfgA = await h.gv_view_config_get({ id: viewA }); + const etagA = `"${cfgA.version}"`; + + const viewB = await mintView('hostile-xview-B'); + const cfgB = await h.gv_view_config_get({ id: viewB }); + // Sanity: versions must differ — otherwise the test can't prove anything. + TestAssert.isTrue(cfgA.version !== cfgB.version, `view versions must differ; got A=${cfgA.version} B=${cfgB.version}`); + + let status = null, message = ''; + try { + await h.gv_view_config_apply({ + id: viewB, ifMatch: etagA, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'xvm01' }] }, + }); + } catch (e) { status = errStatus(e); message = errMessage(e); } + TestAssert.equal(status, 412, `cross-view ifMatch must 412 (got ${status}: ${message})`); +}); + +suite.test('[hostile] Same apply twice with same ifMatch → second 412', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-replay'); + const cfg = await h.gv_view_config_get({ id: viewId }); + const etag = `"${cfg.version}"`; + await h.gv_view_config_apply({ + id: viewId, ifMatch: etag, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'rep01' }] }, + }); + let status = null; + try { + await h.gv_view_config_apply({ + id: viewId, ifMatch: etag, mode: 'merge', + fields: { 'directory_list-title': [{ field_id: fieldIds.name, slot: 'rep02' }] }, + }); + } catch (e) { status = errStatus(e); } + TestAssert.equal(status, 412, `replay must 412 (got ${status})`); +}); + +// ---------- RECURSIVE / SELF-REFERENTIAL ---------- + +suite.test('[hostile] Self-ref: field with conditional_logic referencing itself round-trips', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-cl-selfref'); + // CL references the same field — the renderer should handle this without + // infinite recursion. The setting itself should at least round-trip. + const cl = { + version: 2, actionType: 'show', logicType: 'all', + rules: [{ fieldId: fieldIds.name, operator: 'is', value: 'self' }], + }; + const stored = await roundTripSlot(viewId, 'directory_list-title', 'sref01', { + field_id: fieldIds.name, conditional_logic: cl, + }); + TestAssert.isNotNull(stored.conditional_logic, 'self-ref CL persisted'); +}); + +suite.test('[hostile] Self-ref: custom_content embedding [gravityview id=""] shortcode', async () => { + if (suite.skip) return; + const viewId = await mintView('hostile-shortcode-selfref'); + const stored = await roundTripSlot(viewId, 'directory_list-description', 'shsref01', { + field_id: 'custom', + content: `[gravityview id="${viewId}"]`, + wpautop: false, + }); + TestAssert.isTrue(String(stored.content || '').includes(`id="${viewId}"`), 'shortcode body persisted verbatim'); + // The /render path is the place a recursion bomb would explode — skip + // hitting it here on purpose; persistence is the contract being asserted. +}); + +// ============================================================ +// AESTHETIC REFACTOR — new + consolidated abilities +// (renamed handlers are exercised throughout the suite via the +// global sweep — these tests exclusively cover surfaces that +// didn't exist before this pass.) +// ============================================================ + +suite.test('Consolidated schema: gv_view_field_schemas_get with no filter returns the bulk map', async () => { + if (suite.skip) return; + const viewId = await mintView('schema bulk mode'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + await h.gv_view_config_apply({ + id: viewId, + fields: { 'directory_table-columns': [ + { field_id: fieldIds.name, slot: 'sb_a' }, + { field_id: fieldIds.email, slot: 'sb_b' }, + ] }, + mode: 'merge', + }); + const r = await h.gv_view_field_schemas_get({ id: viewId }); + TestAssert.isTrue(typeof r.schemas === 'object', 'schemas object present'); + const keys = Object.keys(r.schemas); + TestAssert.isTrue(keys.length >= 2, `bulk returned at least the 2 placed slots (got ${keys.length})`); +}); + +suite.test('Consolidated schema: gv_view_field_schemas_get filtered to area+slot returns one-key map', async () => { + if (suite.skip) return; + const viewId = await mintView('schema single mode'); + await h.gv_view_template_switch({ id: viewId, template_id: 'default_table' }); + await h.gv_view_config_apply({ + id: viewId, + fields: { 'directory_table-columns': [ + { field_id: fieldIds.name, slot: 'sb_solo' }, + { field_id: fieldIds.email, slot: 'sb_other' }, + ] }, + mode: 'merge', + }); + const r = await h.gv_view_field_schemas_get({ + id: viewId, + area: 'directory_table-columns', + slot: 'sb_solo', + }); + const keys = Object.keys(r.schemas); + TestAssert.equal(keys.length, 1, 'single-slot filter narrows to ONE entry'); + TestAssert.equal(keys[0], 'directory_table-columns/sb_solo', 'returned key matches the requested area/slot'); +}); + +suite.test('Safety: gv_view_delete defaults to soft delete (mode=trash, recoverable from trash)', async () => { + if (suite.skip) return; + // gv_view_delete exists, but a default invocation (force omitted) + // soft-deletes the View — it transitions to trash status, + // recoverable from WP admin's "Restore from trash". This test pins + // the contract: an AI agent calling gv_view_delete without + // explicitly setting force=true gets a recoverable delete, not + // permanent destruction. + TestAssert.isTrue( + typeof h.gv_view_delete === 'function', + 'gv_view_delete is loaded as a tool handler', + ); + const viewId = await mintView('soft-delete via gv_view_delete'); + const r = await h.gv_view_delete({ id: viewId }); + TestAssert.equal(r.deleted, true, 'default invocation reports deleted=true'); + TestAssert.equal(r.mode, 'trash', 'default invocation soft-deletes (mode=trash)'); + TestAssert.equal(r.force, false, 'default does not force-permadelete'); +}); + +suite.test('Safety: set-view-status: trash works as the soft-remove path (recoverable)', async () => { + if (suite.skip) return; + const viewId = await mintView('soft-trash via set-status'); + const r = await h.gv_view_status_set({ id: viewId, status: 'trash' }); + TestAssert.equal(r.changed, true); + TestAssert.equal(r.status, 'trash'); + // Trashed views are still in the post table — recoverable. The + // get_post_status check is the canonical "is this trashed" probe. + // We assert via wp-cli rather than a REST round-trip because the + // trashed view's accessibility through gv_view_config_get is a + // separate contract. +}); + +suite.test('Safety: abilities-loader does NOT add a client-side destructive gate', async () => { + if (suite.skip) return; + // Field/widget removal is normal authoring (and reversible — re-add + // the same field_id). The server's permission_callback (edit_post / + // edit_gravityviews) is the only protection layer; there must NOT be + // an env-var refusal in the loader closure. We probe by ensuring the + // handler contacts the server (a 4xx from auth/missing-resource is + // proof we got past the loader). The previous "Refusing to call + // destructive ability" gate was removed when DeleteView shipped — + // there is no longer any "delete the whole View" path through the + // ability registry, so the env-var ratchet served no remaining + // purpose. Status-level removal still flows through gv_view_status_set + // and is gated server-side by the WP `delete_post` capability. + const { loadAbilitiesAsTools } = await import('../src/abilities/loader.js'); + const { handlers } = await loadAbilitiesAsTools(gvClient); + let msg = ''; + try { + await handlers.gv_remove_view_field({ id: 999999999, area: 'directory_list-title', slot: 'never' }); + } catch (err) { + msg = String(err?.message ?? ''); + } + TestAssert.isTrue( + !msg.includes('Refusing to call destructive ability'), + `loader must not refuse destructive calls client-side (got "${msg}")`, + ); +}); + +suite.test('New ability: gv_view_duplicate clones form + template + fields', async () => { + if (suite.skip) return; + const sourceId = await mintView('duplicate source'); + await h.gv_view_template_switch({ id: sourceId, template_id: 'default_table' }); + await h.gv_view_config_apply({ + id: sourceId, + fields: { 'directory_table-columns': [ + { field_id: fieldIds.name, slot: 'dup_a', custom_label: 'Carry-over' }, + ] }, + mode: 'merge', + }); + + const r = await h.gv_view_duplicate({ id: sourceId, title: 'Duplicated for stress test' }); + TestAssert.equal(r.duplicated, true); + TestAssert.equal(r.source_id, sourceId); + TestAssert.isTrue(r.view_id > 0 && r.view_id !== sourceId, 'fresh post id, distinct from source'); + TestAssert.equal(r.title, 'Duplicated for stress test'); + mintedViewIds.push(r.view_id); + + const dup = await h.gv_view_config_get({ id: r.view_id }); + TestAssert.equal(dup.form_id, Number(formId), 'form binding cloned'); + TestAssert.equal(dup.template_id, 'default_table', 'template cloned'); + TestAssert.equal( + dup.fields['directory_table-columns']?.dup_a?.custom_label, + 'Carry-over', + 'field placement + custom_label cloned', + ); +}); + +suite.test('New ability: gv_view_status_set — publish → draft round-trip + idempotent re-set', async () => { + if (suite.skip) return; + const viewId = await mintView('set-status round-trip'); + + const pub = await h.gv_view_status_set({ id: viewId, status: 'publish' }); + TestAssert.equal(pub.status, 'publish'); + TestAssert.equal(pub.previous_status, 'draft'); + TestAssert.equal(pub.changed, true); + + const noop = await h.gv_view_status_set({ id: viewId, status: 'publish' }); + TestAssert.equal(noop.changed, false, 'idempotent — same status returns changed: false'); + + const draft = await h.gv_view_status_set({ id: viewId, status: 'draft' }); + TestAssert.equal(draft.status, 'draft'); + TestAssert.equal(draft.previous_status, 'publish'); + TestAssert.equal(draft.changed, true); +}); + +suite.test('New ability: gv_view_status_set rejects an invalid status enum value', async () => { + if (suite.skip) return; + const viewId = await mintView('set-status invalid'); + let status = null; + try { + await h.gv_view_status_set({ id: viewId, status: 'totally_made_up' }); + } catch (err) { + status = err?.response?.status ?? null; + } + TestAssert.equal(status, 400, 'invalid status → 400'); +}); + +// ==================================================================== +// Coverage for the post-Gemini-review enhancements +// ==================================================================== + +suite.test('New ability: gv_views_list enumerates with status / form_id / search filters', async () => { + if (suite.skip) return; + const seedTitle = `[stress] list-views needle ${Date.now()}`; + const view = await h.gv_view_create({ + title: seedTitle, + form_id: Number(formId), + template_id: 'gravityview-layout-builder', + status: 'draft', + }); + mintedViewIds.push(view.view_id); + + // Substring search picks up the freshly-created View. + const found = await h.gv_views_list({ search: 'list-views needle', per_page: 10 }); + TestAssert.isTrue(Array.isArray(found.views), 'returns views array'); + TestAssert.isTrue(found.total >= 1, 'total reflects matching count'); + const match = found.views.find((v) => v.view_id === view.view_id); + TestAssert.isTrue(!!match, 'newly-minted View appears in search results'); + TestAssert.equal(match.form_id, Number(formId), 'form_id reflected'); + TestAssert.equal(match.status, 'draft', 'status reflected'); + + // form_id filter narrows to that form (every result must match). + const byForm = await h.gv_views_list({ form_id: Number(formId), per_page: 5 }); + for (const v of byForm.views) { + TestAssert.equal(v.form_id, Number(formId), 'every row matches form_id filter'); + } + + // Pagination metadata. + const paged = await h.gv_views_list({ per_page: 2, page: 1 }); + TestAssert.equal(paged.per_page, 2, 'per_page echoed'); + TestAssert.equal(paged.page, 1, 'page echoed'); + TestAssert.isTrue(paged.total_pages >= 1, 'total_pages computed'); +}); + +suite.test('Projection: gv_view_config_get include narrows the response shape', async () => { + if (suite.skip) return; + const viewId = await mintView('projection'); + + const full = await h.gv_view_config_get({ id: viewId, compact: false }); + TestAssert.isTrue('template_id' in full, 'full has template_id'); + TestAssert.isTrue('fields' in full, 'full has fields'); + TestAssert.isTrue('widgets' in full, 'full has widgets'); + + const slim = await h.gv_view_config_get({ + id: viewId, + include: ['template_settings', 'form_id'], + compact: false, + }); + TestAssert.isTrue('view_id' in slim, 'view_id always present (projection invariant)'); + TestAssert.isTrue('form_id' in slim, 'requested form_id present'); + TestAssert.isTrue('template_settings' in slim, 'requested template_settings present'); + TestAssert.isTrue(!('fields' in slim), 'unrequested fields stripped'); + TestAssert.isTrue(!('widgets' in slim), 'unrequested widgets stripped'); + TestAssert.isTrue(!('template_id' in slim), 'unrequested template_id stripped'); +}); + +suite.test('Dry-run: gv_view_config_apply dry_run=true does NOT persist + flags response', async () => { + if (suite.skip) return; + const viewId = await mintView('dry-run apply'); + + // Real bulk write to set a baseline. Each apply bumps the version, + // so re-fetch + re-quote between writes (the test harness only + // caches the version returned by the LAST call; the unit-test + // happens to interleave reads + writes that defeat that). + await h.gv_view_config_apply({ + id: viewId, + mode: 'merge', + template_settings: { page_size: 25 }, + }); + const before = await h.gv_view_config_get({ id: viewId, include: ['template_settings'] }); + TestAssert.equal(before.template_settings.page_size, 25, 'baseline persisted'); + + const dry = await h.gv_view_config_apply({ + id: viewId, + mode: 'merge', + template_settings: { page_size: 999 }, + dry_run: true, + }); + TestAssert.equal(dry.dry_run, true, 'response flagged dry_run'); + // would_apply was dropped in Foundation FIX-22/66 (redundant with dry_run). + // The dry-run envelope is now { applied, dry_run, version, view_id }. + + const after = await h.gv_view_config_get({ id: viewId, include: ['template_settings'] }); + TestAssert.equal(after.template_settings.page_size, 25, 'meta unchanged after dry-run'); +}); + +suite.test('Dry-run: gv_view_field_patch dry_run=true validates without persisting', async () => { + if (suite.skip) return; + const viewId = await mintView('dry-run patch-field'); + await h.gv_grid_row_add({ + id: viewId, + surface: 'fields', + row_uid: 'r1', + type: '100', + template_ids: ['default_table'], + }); + const added = await h.gv_view_field_add({ + id: viewId, + area: 'directory_table-columns', + field_id: 'custom', + label: 'Original', + }); + await h.gv_view_field_patch({ + id: viewId, + area: 'directory_table-columns', + slot: added.slot, + settings: { custom_label: 'Real Label' }, + }); + + const dryPatch = await h.gv_view_field_patch({ + id: viewId, + area: 'directory_table-columns', + slot: added.slot, + settings: { custom_label: 'Dry Label' }, + dry_run: true, + }); + TestAssert.equal(dryPatch.dry_run, true); + + const after = await h.gv_view_config_get({ id: viewId }); + const stored = after.fields['directory_table-columns']?.[added.slot]?.custom_label; + TestAssert.equal(stored, 'Real Label', 'meta still holds the real-write value, not the dry-run value'); +}); + +suite.test('Dry-run: gv_view_field_add dry_run=true returns shape but does NOT add a slot', async () => { + if (suite.skip) return; + const viewId = await mintView('dry-run add-field'); + await h.gv_grid_row_add({ + id: viewId, + surface: 'fields', + row_uid: 'r1', + type: '100', + template_ids: ['default_table'], + }); + + const beforeCount = Object.keys( + (await h.gv_view_config_get({ id: viewId })).fields?.['directory_table-columns'] ?? {}, + ).length; + + const dry = await h.gv_view_field_add({ + id: viewId, + area: 'directory_table-columns', + field_id: 'custom', + label: 'Hypothetical', + dry_run: true, + }); + TestAssert.equal(dry.dry_run, true); + + const afterCount = Object.keys( + (await h.gv_view_config_get({ id: viewId })).fields?.['directory_table-columns'] ?? {}, + ).length; + TestAssert.equal(afterCount, beforeCount, 'slot count unchanged after dry-run add'); +}); + +suite.test('Catalog: every gk-gravityview ability advertises a next_steps annotation', async () => { + if (suite.skip) return; + const { data: catalog } = await gvClient.httpClient.request({ + method: 'GET', + baseURL: gvClient.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', + }); + const ours = catalog.filter((a) => typeof a?.name === 'string' && a.name.startsWith('gk-gravityview/')); + TestAssert.isTrue(ours.length > 0, 'at least one gk-gravityview ability'); + for (const ab of ours) { + const ns = ab?.meta?.annotations?.next_steps; + TestAssert.isTrue(Array.isArray(ns), `${ab.name} has next_steps array`); + for (const step of ns) { + TestAssert.isTrue( + typeof step?.ability === 'string' && step.ability.startsWith('gk-gravityview/'), + `${ab.name} next-step references gk-gravityview ability`, + ); + TestAssert.isTrue(typeof step?.when === 'string' && step.when.length > 0, `${ab.name} next-step has when text`); + } + } +}); + +suite.test('Discovery bridge: layouts-list has_grid description points at grid-row-types-list', async () => { + if (suite.skip) return; + const { data: catalog } = await gvClient.httpClient.request({ + method: 'GET', + baseURL: gvClient.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', + }); + // Foundation's verb-first → noun-first ability rename moved + // `list-layouts` → `layouts-list`, `list-grid-row-types` → + // `grid-row-types-list`, `list-view-areas` → `view-areas-get`. + // The has_grid description bridges to the new noun-first names. + const layoutsList = catalog.find((a) => a.name === 'gk-gravityview/layouts-list'); + const hasGridDesc = + layoutsList?.output_schema?.properties?.layouts?.items?.properties?.has_grid?.description ?? ''; + TestAssert.isTrue( + hasGridDesc.includes('grid-row-types-list'), + 'has_grid description bridges to grid-row-types-list (the discovery step)', + ); + TestAssert.isTrue( + hasGridDesc.includes('view-areas-get'), + 'has_grid description bridges to view-areas-get for static layouts', + ); +}); + +suite.test('Field presets: default catalog is empty (filter-driven, core ships none)', async () => { + if (suite.skip) return; + const r = await h.gv_field_presets_list(); + TestAssert.equal(r.count, 0, 'no core-shipped presets'); + TestAssert.isTrue(Array.isArray(r.presets), 'presets is an array'); + TestAssert.equal(r.presets.length, 0); +}); + +suite.test('Field presets: apply-field-preset rejects an unknown preset id with 404', async () => { + if (suite.skip) return; + const viewId = await mintView('preset 404'); + let status = null; + try { + await h.gv_field_preset_apply({ + id: viewId, + preset_id: 'definitely-not-registered', + area: 'directory_list-title', + }); + } catch (err) { + status = err?.response?.status ?? null; + } + TestAssert.equal(status, 404, 'unknown preset id → 404'); +}); + +// ==================================================================== +// Multiple Forms add-on stress (gk-multiple-forms/* abilities + +// cross-plugin filters on get/apply-view-config + list-views). +// +// All tests skip themselves when MFV isn't loaded — that's surfaced +// via the absence of `gv_list_joins` from the catalog. On dev.test +// MFV is active, so they run for real. +// ==================================================================== + +const mfvSkip = () => suite.skip || typeof h?.gv_list_joins !== 'function'; + +/** Mint a throwaway secondary GF form for join-target tests. */ +async function mintSecondaryForm(label) { + const created = await gfClient.createForm({ + title: `[stress mfv ${label}] ${Date.now()}`, + fields: [ + { id: 1, type: 'text', label: 'Customer Ref' }, + { id: 2, type: 'text', label: 'Email' }, + { id: 3, type: 'number', label: 'Amount' }, + ], + }); + // createForm returns `{ form: { id }, edit_url, entries_url }` — + // the form id lives on `.form.id`, not on the top-level result. + const id = Number(created?.form?.id ?? 0); + if (!id) { + throw new Error('mintSecondaryForm: created form id missing from createForm response'); + } + return id; +} + +suite.test('MFV: catalog exposes the three gk-multiple-forms/* abilities', async () => { + if (mfvSkip()) return; + TestAssert.isTrue(typeof h.gv_list_joins === 'function', 'gv_list_joins handler present'); + TestAssert.isTrue(typeof h.gv_apply_joins === 'function', 'gv_apply_joins handler present'); + TestAssert.isTrue(typeof h.gv_list_joinable_fields === 'function', 'gv_list_joinable_fields handler present'); +}); + +suite.test('MFV: list-joins on a no-joins View → empty + count=0', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv list empty'); + const r = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(r.count, 0, 'no joins on a fresh View'); + TestAssert.isTrue(Array.isArray(r.joins), 'joins is an array'); +}); + +suite.test('MFV: list-joinable-fields enumerates form fields + entry-property aliases', async () => { + if (mfvSkip()) return; + const formId = await mintSecondaryForm('joinable'); + const r = await h.gv_list_joinable_fields({ form_id: formId }); + TestAssert.isTrue(r.fields.length >= 3, 'at least 3 numeric fields + aliases'); + const ids = r.fields.map((f) => f.id); + for (const expected of ['1', '2', '3', 'entry_id', 'created_by']) { + TestAssert.isTrue(ids.includes(expected), `expected ${expected} in joinable fields`); + } + // Cleanup + await gfClient.deleteForm(formId).catch(() => {}); +}); + +suite.test('MFV: apply-joins dry_run → flags response + does NOT persist', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv dry-run apply-joins'); + const joinedFormId = await mintSecondaryForm('dry'); + const dry = await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), '1', joinedFormId, '1']], + dry_run: true, + }); + TestAssert.equal(dry.dry_run, true, 'dry_run flag stamped'); + TestAssert.equal(dry.would_apply, true, 'would_apply flag stamped'); + TestAssert.equal(dry.count, 1, 'count reports the validated row count'); + + const after = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(after.count, 0, 'meta unchanged after dry-run'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: apply-joins persists + list-joins inflates form/field labels', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv apply real'); + const joinedFormId = await mintSecondaryForm('real'); + const r = await h.gv_apply_joins({ + id: viewId, + joins: [ + [Number(formId), '1', joinedFormId, '1'], + [Number(formId), 'entry_id', joinedFormId, '3'], + ], + }); + TestAssert.equal(r.count, 2); + + const list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 2, 'two joins surfaced'); + TestAssert.isTrue(list.joins[0].details.base_form_label.length > 0, 'base form label inflated'); + TestAssert.isTrue(list.joins[0].details.base_form_active, 'base form active'); + TestAssert.isTrue(list.joins[0].details.join_form_active, 'join form active'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: apply-joins replaces (not merges) — 3 → 1 → 0', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv replace'); + const joinedFormId = await mintSecondaryForm('replace'); + + // Set 3 + let r = await h.gv_apply_joins({ + id: viewId, + joins: [ + [Number(formId), '1', joinedFormId, '1'], + [Number(formId), '2', joinedFormId, '2'], + [Number(formId), 'entry_id', joinedFormId, '3'], + ], + }); + TestAssert.equal(r.count, 3); + + // Replace with 1 + r = await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), '1', joinedFormId, '1']], + }); + TestAssert.equal(r.count, 1, 'apply-joins is replace-not-merge'); + let list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 1); + + // Clear with empty + r = await h.gv_apply_joins({ id: viewId, joins: [] }); + TestAssert.equal(r.count, 0); + list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 0, 'empty array clears all joins'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: apply-joins rejects malformed rows with 400', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv invalid rows'); + let status = null; + try { + await h.gv_apply_joins({ + id: viewId, + joins: [ + [Number(formId), '1', 999999, '1'], + ['not-numeric', '1', 999999, '1'], // invalid base_form_id type + ], + }); + } catch (err) { + status = err?.response?.status ?? null; + } + TestAssert.equal(status, 400, 'malformed row → 400'); + + // Verify the View still has no joins (atomic rollback on error). + const list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 0, 'no partial write on validation error'); +}); + +suite.test('MFV: apply-view-config writes joins via the cross-plugin filter', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv cross-plugin'); + const joinedFormId = await mintSecondaryForm('crossplugin'); + + await h.gv_view_config_apply({ + id: viewId, + mode: 'merge', + joins: [ + [Number(formId), '1', joinedFormId, '1'], + [Number(formId), 'entry_id', joinedFormId, '3'], + ], + }); + + const cfg = await h.gv_view_config_get({ id: viewId, compact: false }); + TestAssert.isTrue(Array.isArray(cfg.joins), 'get-view-config exposes joins'); + TestAssert.equal(cfg.joins.length, 2, 'apply-view-config persisted both joins'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: get-view-config include=[joins] projection narrows shape', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv projection'); + const joinedFormId = await mintSecondaryForm('proj'); + await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), '1', joinedFormId, '1']], + }); + + const slim = await h.gv_view_config_get({ + id: viewId, + include: ['joins'], + compact: false, + }); + TestAssert.isTrue('joins' in slim, 'projection kept joins'); + TestAssert.equal(slim.joins.length, 1); + TestAssert.isTrue(!('fields' in slim), 'projection stripped fields'); + TestAssert.isTrue(!('widgets' in slim), 'projection stripped widgets'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: list-views match_joined surfaces Views joining a form (not just primary)', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv list-views match'); + const joinedFormId = await mintSecondaryForm('listmatch'); + await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), '1', joinedFormId, '1']], + }); + + // Search for Views connected to the JOINED form (not primary). + const matched = await h.gv_views_list({ form_id: joinedFormId, match_joined: true }); + TestAssert.isTrue( + matched.views.some((v) => v.view_id === viewId), + 'View surfaces under match_joined when its form is only joined', + ); + + // Without match_joined, the joined-only View must NOT show up. + const unmatched = await h.gv_views_list({ form_id: joinedFormId }); + TestAssert.isTrue( + !unmatched.views.some((v) => v.view_id === viewId), + 'plain form_id filter excludes joined-only Views', + ); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: list-available-fields includes joined_form_fields tagged with form_id', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv available-fields'); + const joinedFormId = await mintSecondaryForm('availfields'); + await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), '1', joinedFormId, '1']], + }); + + const r = await h.gv_available_fields_get({ id: viewId, zone: 'directory' }); + TestAssert.isTrue(Array.isArray(r.joined_form_fields), 'joined_form_fields is an array'); + TestAssert.isTrue(r.joined_form_fields.length > 0, 'at least one joined-form field returned'); + for (const f of r.joined_form_fields) { + TestAssert.equal(f.form_id, joinedFormId, 'every joined field tagged with the joined form_id'); + } + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV: every gk-multiple-forms/* ability advertises a next_steps annotation', async () => { + if (mfvSkip()) return; + const { data: catalog } = await gvClient.httpClient.request({ + method: 'GET', + baseURL: gvClient.baseUrl, + url: '/wp-json/wp-abilities/v1/abilities', + }); + const ours = catalog.filter((a) => typeof a?.name === 'string' && a.name.startsWith('gk-multiple-forms/')); + TestAssert.isTrue(ours.length >= 3, 'at least three MFV abilities registered'); + for (const ab of ours) { + const ns = ab?.meta?.annotations?.next_steps; + TestAssert.isTrue(Array.isArray(ns) && ns.length > 0, `${ab.name} advertises next_steps`); + } +}); + +// -------------------------------------------------------------------- +// DEEP Multi-Form authoring stress — mixes fields from both the primary +// and joined forms into the same View areas, the way a real +// multi-form authoring flow does. Verifies the field tree records +// each slot's source `form_id` correctly so renderers hydrate against +// the right form / field collision space. +// -------------------------------------------------------------------- + +suite.test('MFV deep: field slots from primary AND joined forms coexist in one area', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv mixed fields'); + + // Mint the joined form with field IDs that would COLLIDE with the + // primary if disambiguation were broken: both forms have id=1 + id=2. + const joinedFormId = await mintSecondaryForm('mixed-fields'); + + // Wire the join so the View can pull from both forms. + await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), '1', joinedFormId, '1']], + }); + + // Layout Builder needs a row before fields can land. + await h.gv_grid_row_add({ + id: viewId, + surface: 'fields', + row_uid: 'mixed_row', + type: '100', + template_ids: ['gravityview-layout-builder'], + }); + + // Discover fields available from both forms. + const avail = await h.gv_available_fields_get({ id: viewId, zone: 'directory' }); + TestAssert.isTrue(Array.isArray(avail.form_fields), 'primary form_fields surfaced'); + TestAssert.isTrue(Array.isArray(avail.joined_form_fields), 'joined_form_fields surfaced'); + TestAssert.isTrue(avail.form_fields.length > 0, 'primary form has fields'); + TestAssert.isTrue(avail.joined_form_fields.length > 0, 'joined form has fields'); + + // Every joined field must carry the joined form_id tag — not the primary. + for (const f of avail.joined_form_fields) { + TestAssert.equal(f.form_id, joinedFormId, 'joined field tagged with joined form_id'); + } + + // Pick one numeric field id from each form. Using `1` from both + // intentionally — that's the collision case real Multi-Form + // configurations hit. + const primaryFieldId = avail.form_fields[0]?.id; + const joinedFieldId = avail.joined_form_fields[0]?.id; + TestAssert.isTrue(!!primaryFieldId, 'have a primary field id'); + TestAssert.isTrue(!!joinedFieldId, 'have a joined field id'); + + // Add a slot from the PRIMARY form into the View's grid area. + const primarySlot = await h.gv_view_field_add({ + id: viewId, + area: 'directory_mixed_row-1', + field_id: primaryFieldId, + label: 'Primary Field', + form_id: Number(formId), + }); + TestAssert.isTrue(!!primarySlot.slot, 'primary slot created'); + + // Add a slot from the JOINED form into the SAME area. + const joinedSlot = await h.gv_view_field_add({ + id: viewId, + area: 'directory_mixed_row-1', + field_id: joinedFieldId, + label: 'Joined Field', + form_id: joinedFormId, + }); + TestAssert.isTrue(!!joinedSlot.slot, 'joined slot created'); + TestAssert.isTrue(joinedSlot.slot !== primarySlot.slot, 'unique slot UID despite same field_id'); + + // Verify both slots are persisted in the field tree under the same area. + const cfg = await h.gv_view_config_get({ id: viewId, compact: false }); + const area = cfg.fields?.['directory_mixed_row-1']; + TestAssert.isTrue(typeof area === 'object' && area !== null, 'area exists in field tree'); + TestAssert.isTrue(primarySlot.slot in area, 'primary slot in tree'); + TestAssert.isTrue(joinedSlot.slot in area, 'joined slot in tree'); + + // The View's joins are still intact after the field placements. + const cfgJoins = cfg.joins; + TestAssert.isTrue(Array.isArray(cfgJoins) && cfgJoins.length === 1, 'join survived field placements'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV deep: 3-form join + fields from each form land in distinct areas', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv 3-form'); + const orderForm = await mintSecondaryForm('orders'); + const addressForm = await mintSecondaryForm('addresses'); + + // Triple-form join: primary ← orders ← addresses (cascading). + await h.gv_apply_joins({ + id: viewId, + joins: [ + [Number(formId), '1', orderForm, '1'], + [orderForm, '2', addressForm, '2'], + ], + }); + + // List joins includes both rows + inflates labels for all three forms. + const list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 2); + const labels = list.joins.map((j) => `${j.details.base_form_label}/${j.details.join_form_label}`); + TestAssert.isTrue(labels.length === 2, 'two label pairs'); + TestAssert.isTrue(labels.every((l) => l.includes('/')), 'every join surfaces both form labels'); + + // available-fields now spans all three forms. + const avail = await h.gv_available_fields_get({ id: viewId }); + const joinedFormIds = new Set(avail.joined_form_fields.map((f) => f.form_id)); + TestAssert.isTrue(joinedFormIds.has(orderForm), 'orders form_id present in joined_form_fields'); + TestAssert.isTrue(joinedFormIds.has(addressForm), 'addresses form_id present in joined_form_fields'); + + // Place one field from each form into 3 different rows so the View + // is genuinely cross-form authored. + for (const rowUid of ['r_orders', 'r_addr']) { + await h.gv_grid_row_add({ + id: viewId, + surface: 'fields', + row_uid: rowUid, + type: '100', + template_ids: ['gravityview-layout-builder'], + }); + } + + await h.gv_view_field_add({ + id: viewId, + area: 'directory_r_orders-1', + field_id: avail.joined_form_fields.find((f) => f.form_id === orderForm)?.id, + label: 'Order Field', + form_id: orderForm, + }); + await h.gv_view_field_add({ + id: viewId, + area: 'directory_r_addr-1', + field_id: avail.joined_form_fields.find((f) => f.form_id === addressForm)?.id, + label: 'Address Field', + form_id: addressForm, + }); + + const cfg = await h.gv_view_config_get({ id: viewId, compact: false }); + TestAssert.isTrue(Object.keys(cfg.fields?.['directory_r_orders-1'] ?? {}).length === 1, 'orders row has 1 slot'); + TestAssert.isTrue(Object.keys(cfg.fields?.['directory_r_addr-1'] ?? {}).length === 1, 'address row has 1 slot'); + + await gfClient.deleteForm(orderForm).catch(() => {}); + await gfClient.deleteForm(addressForm).catch(() => {}); +}); + +suite.test('MFV deep: apply-view-config bulk — joins + fields from both forms in one call', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv bulk mixed'); + const joinedFormId = await mintSecondaryForm('bulk'); + + // First materialise a row to host the slots. + await h.gv_grid_row_add({ + id: viewId, + surface: 'fields', + row_uid: 'bulk_row', + type: '100', + template_ids: ['gravityview-layout-builder'], + }); + + // Bulk write everything in one shot: joins + fields tree spanning both forms. + await h.gv_view_config_apply({ + id: viewId, + mode: 'merge', + joins: [[Number(formId), '1', joinedFormId, '1']], + fields: { + 'directory_bulk_row-1': [ + { field_id: '1', label: 'From Primary', form_id: Number(formId) }, + { field_id: '2', label: 'Email Primary', form_id: Number(formId) }, + { field_id: '1', label: 'From Joined', form_id: joinedFormId }, + { field_id: '3', label: 'Amount Joined', form_id: joinedFormId }, + ], + }, + }); + + const cfg = await h.gv_view_config_get({ id: viewId, compact: false }); + TestAssert.isTrue(Array.isArray(cfg.joins) && cfg.joins.length === 1, 'bulk wrote joins'); + const slots = cfg.fields?.['directory_bulk_row-1'] ?? {}; + TestAssert.equal(Object.keys(slots).length, 4, 'four slots in bulk_row-1'); + + // Verify that the View knows which slots came from which form. + const formIds = Object.values(slots).map((s) => Number(s.form_id ?? 0)); + const primaryCount = formIds.filter((id) => id === Number(formId)).length; + const joinedCount = formIds.filter((id) => id === joinedFormId).length; + TestAssert.isTrue(primaryCount >= 2, 'at least 2 slots from primary form'); + TestAssert.isTrue(joinedCount >= 2, 'at least 2 slots from joined form'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV deep: dry_run on mixed-form bulk apply does NOT persist any slot', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv dry mixed'); + const joinedFormId = await mintSecondaryForm('dry-mixed'); + + await h.gv_grid_row_add({ + id: viewId, + surface: 'fields', + row_uid: 'dry_row', + type: '100', + template_ids: ['gravityview-layout-builder'], + }); + + const dry = await h.gv_view_config_apply({ + id: viewId, + mode: 'merge', + dry_run: true, + joins: [[Number(formId), '1', joinedFormId, '1']], + fields: { + 'directory_dry_row-1': [ + { field_id: '1', label: 'Primary', form_id: Number(formId) }, + { field_id: '1', label: 'Joined', form_id: joinedFormId }, + ], + }, + }); + TestAssert.equal(dry.dry_run, true); + + const cfg = await h.gv_view_config_get({ id: viewId, compact: false }); + TestAssert.isTrue(!cfg.joins || cfg.joins.length === 0, 'no joins persisted on dry-run'); + const slots = cfg.fields?.['directory_dry_row-1'] ?? {}; + TestAssert.equal(Object.keys(slots).length, 0, 'no slots persisted on dry-run'); + + await gfClient.deleteForm(joinedFormId).catch(() => {}); +}); + +suite.test('MFV deep: apply-joins clears + replaces, list-joins reflects each step', async () => { + if (mfvSkip()) return; + const viewId = await mintView('mfv replace cycle'); + const f1 = await mintSecondaryForm('repl1'); + const f2 = await mintSecondaryForm('repl2'); + + // Round 1: 2 joins + await h.gv_apply_joins({ + id: viewId, + joins: [ + [Number(formId), '1', f1, '1'], + [Number(formId), '2', f2, '2'], + ], + }); + let list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 2); + + // Round 2: replace with a single join to f2 only + await h.gv_apply_joins({ + id: viewId, + joins: [[Number(formId), 'entry_id', f2, '3']], + }); + list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 1); + TestAssert.equal(list.joins[0].join_form_id, f2); + + // Round 3: clear + await h.gv_apply_joins({ id: viewId, joins: [] }); + list = await h.gv_list_joins({ id: viewId }); + TestAssert.equal(list.count, 0); + + // get-view-config reflects the cleared state. + const cfg = await h.gv_view_config_get({ id: viewId, include: ['joins'], compact: false }); + TestAssert.isTrue(!cfg.joins || cfg.joins.length === 0, 'cleared joins reflected in get-view-config'); + + await gfClient.deleteForm(f1).catch(() => {}); + await gfClient.deleteForm(f2).catch(() => {}); +}); + +suite.run(); diff --git a/test/views.test.js b/test/views.test.js new file mode 100644 index 0000000..b76433e --- /dev/null +++ b/test/views.test.js @@ -0,0 +1,449 @@ +/** + * GravityView Inspector Endpoint Tests + * + * Mirrors `forms.test.js` shape: MockHttpClient for the transport, + * scenarios cover happy path, negative client-side validation, and + * the inspector-specific behaviours (If-Match optimistic-concurrency, + * mode replace/merge, area-key URL encoding). + */ + +import { GravityViewInspectorClient } from '../src/gravityview/inspector-client.js'; +import { ViewValidator } from '../src/gravityview/view-validator.js'; +import { + TestRunner, + TestAssert, + MockHttpClient, + MockResponse, + setupTestEnvironment, +} from './helpers.js'; + +const suite = new TestRunner('GravityView Inspector Endpoint Tests'); + +let client; +let mockHttpClient; +let testEnv; + +suite.beforeEach(() => { + // GravityViewInspectorClient falls back to GRAVITY_FORMS_* creds, so the + // shared setupTestEnvironment values cover both surfaces. + testEnv = setupTestEnvironment(); + mockHttpClient = new MockHttpClient(); + client = new GravityViewInspectorClient(testEnv); + client.httpClient = mockHttpClient; // bypass real network +}); + +// ==================================================================== +// Construction + auth +// ==================================================================== + +suite.test('Constructor: throws without a base URL', () => { + TestAssert.throws(() => new GravityViewInspectorClient({}), 'GRAVITYKIT_WP_URL'); +}); + +suite.test('Constructor: throws without credentials', () => { + TestAssert.throws( + () => new GravityViewInspectorClient({ GRAVITYKIT_WP_URL: 'https://example.com' }), + 'requires credentials' + ); +}); + +suite.test('Constructor: builds Basic auth header from WP creds', () => { + const c = new GravityViewInspectorClient({ + GRAVITYKIT_WP_URL: 'https://example.com', + GRAVITYKIT_WP_USERNAME: 'admin', + GRAVITYKIT_WP_APP_PASSWORD: 'abc def ghi jkl', + }); + TestAssert.equal( + c.basicAuth, + 'Basic ' + Buffer.from('admin:abc def ghi jkl').toString('base64') + ); +}); + +suite.test('Constructor: falls back to GRAVITY_FORMS_CONSUMER_KEY/SECRET', () => { + const c = new GravityViewInspectorClient({ + GRAVITY_FORMS_BASE_URL: 'https://example.com', + GRAVITY_FORMS_CONSUMER_KEY: 'fk', + GRAVITY_FORMS_CONSUMER_SECRET: 'fs', + }); + TestAssert.equal(c.basicAuth, 'Basic ' + Buffer.from('fk:fs').toString('base64')); +}); + +// ==================================================================== +// Discovery +// ==================================================================== + +suite.test('listLayouts: returns the layouts array', async () => { + mockHttpClient.setMockResponse( + 'GET', + '/layouts', + new MockResponse({ + layouts: [ + { id: 'gravityview-layout-builder', label: 'Layout Builder', is_grid_aware: true }, + { id: 'diy', label: 'DIY', is_grid_aware: false }, + ], + }) + ); + const data = await client.listLayouts(); + TestAssert.equal(data.layouts.length, 2); + TestAssert.equal(data.layouts[0].id, 'gravityview-layout-builder'); + TestAssert.equal(data.layouts[0].is_grid_aware, true); +}); + +suite.test('getFieldTypeSchema: requires field_type', async () => { + await TestAssert.throwsAsync( + () => client.getFieldTypeSchema({}), + 'field_type is required' + ); +}); + +// ==================================================================== +// Create +// ==================================================================== + +suite.test('createView: posts to /views and caches the version from ETag', async () => { + mockHttpClient.setMockResponse( + 'POST', + '/views', + new MockResponse( + { view_id: 42, version: 'v1', template_id: 'default_list', created: true }, + 201, + { etag: '"v1"' } + ) + ); + + const result = await client.createView({ + title: 'Smoke', + form_id: 7, + template_id: 'default_list', + template_settings: { lightbox: true }, + }); + + TestAssert.equal(result.view_id, 42); + TestAssert.equal(client.versionCache.get(42), 'v1'); + + const req = mockHttpClient.getRequests().find((r) => r.method === 'POST' && r.path === '/views'); + TestAssert.notEqual(req, undefined); + TestAssert.equal(req.config.data.title, 'Smoke'); + TestAssert.equal(req.config.data.form_id, 7); + // status / mode / search_criteria / fields / widgets shouldn't appear + // when the caller didn't pass them — undefined is stripped. + TestAssert.equal('status' in req.config.data, false); + TestAssert.equal('search_criteria' in req.config.data, false); +}); + +suite.test('createView: rejects non-string title', async () => { + await TestAssert.throwsAsync( + () => client.createView({ form_id: 7 }), + 'title is required' + ); +}); + +suite.test('createView: rejects non-positive form_id', async () => { + await TestAssert.throwsAsync( + () => client.createView({ title: 'x', form_id: 0 }), + 'form_id' + ); +}); + +// ==================================================================== +// Bulk apply + If-Match +// ==================================================================== + +suite.test('applyViewConfig: posts to /views/{id}/config/_apply with the payload', async () => { + mockHttpClient.setMockResponse( + 'POST', + '/views/9/config/_apply', + new MockResponse({ view_id: 9, version: 'v2', template_settings: { page_size: 25 } }) + ); + const result = await client.applyViewConfig({ + id: 9, + template_settings: { page_size: 25 }, + mode: 'merge', + }); + TestAssert.equal(result.template_settings.page_size, 25); + const req = mockHttpClient.getRequests().find((r) => r.path === '/views/9/config/_apply'); + TestAssert.equal(req.config.data.mode, 'merge'); + TestAssert.equal(req.config.data.template_settings.page_size, 25); +}); + +suite.test('applyViewConfig: ifMatch="auto" pulls the cached version into the header', async () => { + // Seed the cache via a read. + mockHttpClient.setMockResponse( + 'GET', + '/views/9/config', + new MockResponse({ view_id: 9, version: 'cached-v3' }, 200, { etag: '"cached-v3"' }) + ); + await client.getViewConfig({ id: 9 }); + TestAssert.equal(client.versionCache.get(9), 'cached-v3'); + + mockHttpClient.setMockResponse( + 'POST', + '/views/9/config/_apply', + new MockResponse({ view_id: 9, version: 'cached-v4' }) + ); + await client.applyViewConfig({ id: 9, template_settings: { page_size: 1 }, ifMatch: 'auto' }); + + const req = mockHttpClient.getRequests().find((r) => r.path === '/views/9/config/_apply'); + TestAssert.equal(req.config.headers['If-Match'], '"cached-v3"'); +}); + +suite.test('applyViewConfig: explicit ifMatch is wrapped in quotes', async () => { + mockHttpClient.setMockResponse( + 'POST', + '/views/9/config/_apply', + new MockResponse({ view_id: 9, version: 'v5' }) + ); + await client.applyViewConfig({ id: 9, template_settings: {}, ifMatch: 'literal' }); + const req = mockHttpClient.getRequests().find((r) => r.path === '/views/9/config/_apply'); + TestAssert.equal(req.config.headers['If-Match'], '"literal"'); +}); + +// ==================================================================== +// Surgical fields +// ==================================================================== + +suite.test('addViewField: requires field.field_id', async () => { + await TestAssert.throwsAsync( + () => client.addViewField({ id: 9, area: 'directory_list-title', field: {} }), + 'field.field_id' + ); +}); + +suite.test('patchViewField: encodes Layout Builder area keys with ::', async () => { + // Area keys like "directory_gravityview-layout-builder-top::100::row_uid" + // contain `::` separators. The client should preserve them so they + // route to the correct InspectorRoute regex. + const area = 'directory_gravityview-layout-builder-top::100::abc123'; + mockHttpClient.setMockResponse( + 'PATCH', + `/views/9/fields/${area}/slot1`, + new MockResponse({ values: { custom_label: 'New' } }) + ); + await client.patchViewField({ id: 9, area, slot: 'slot1', settings: { custom_label: 'New' } }); + const req = mockHttpClient.getRequests().find((r) => r.method === 'PATCH'); + TestAssert.equal(req.path, `/views/9/fields/${area}/slot1`); +}); + +suite.test('moveViewField: posts to /fields/_move with from/to/position', async () => { + mockHttpClient.setMockResponse( + 'POST', + '/views/9/fields/_move', + new MockResponse({ to: { area: 'directory_list-subtitle', slot: 'slot1', position: 0 } }) + ); + await client.moveViewField({ + id: 9, + from: { area: 'directory_list-title', slot: 'slot1' }, + to: { area: 'directory_list-subtitle' }, + position: 0, + }); + const req = mockHttpClient.getRequests().find((r) => r.path === '/views/9/fields/_move'); + TestAssert.equal(req.config.data.from.slot, 'slot1'); + TestAssert.equal(req.config.data.position, 0); +}); + +suite.test('removeViewField: deletes via DELETE /views/{id}/fields/{area}/{slot}', async () => { + // Field/widget removal is part of normal authoring (reversible by + // re-adding the same field_id), so there is no client-side gate — + // the only protection layer is the WP capability check on the + // server's permission_callback. + mockHttpClient.setMockResponse( + 'DELETE', + '/views/9/fields/directory_list-title/slot1', + new MockResponse({ deleted: true }) + ); + const result = await client.removeViewField({ id: 9, area: 'directory_list-title', slot: 'slot1' }); + TestAssert.equal(result.deleted, true); +}); + +// ==================================================================== +// renderViewField +// ==================================================================== + +suite.test('renderViewField: GET when no settings overrides supplied', async () => { + mockHttpClient.setMockResponse( + 'GET', + '/views/9/fields/directory_list-title/slot1/render', + new MockResponse({ html: '
rendered
' }) + ); + const r = await client.renderViewField({ id: 9, area: 'directory_list-title', slot: 'slot1' }); + TestAssert.equal(r.html, '
rendered
'); +}); + +suite.test('renderViewField: POST when settings overrides supplied (no persistence)', async () => { + mockHttpClient.setMockResponse( + 'POST', + '/views/9/fields/directory_list-title/slot1/render', + new MockResponse({ html: '
preview
' }) + ); + await client.renderViewField({ + id: 9, + area: 'directory_list-title', + slot: 'slot1', + settings: { custom_label: 'Preview' }, + }); + const req = mockHttpClient.getRequests().find( + (r) => r.method === 'POST' && r.path === '/views/9/fields/directory_list-title/slot1/render' + ); + TestAssert.equal(req.config.data.settings.custom_label, 'Preview'); +}); + +// ==================================================================== +// Validator +// ==================================================================== + +suite.test('Validator: validateApplyPayload accepts a clean payload', () => { + const v = new ViewValidator(client); + v.validateApplyPayload({ + template_id: 'default_list', + template_settings: { page_size: 25 }, + fields: { 'directory_list-title': [{ field_id: '1' }] }, + mode: 'replace', + }); +}); + +suite.test('Validator: rejects unknown mode', () => { + const v = new ViewValidator(client); + TestAssert.throws(() => v.validateApplyPayload({ mode: 'append' }), 'mode must be one of'); +}); + +suite.test('Validator: rejects fields[area] that isn\'t an array', () => { + const v = new ViewValidator(client); + TestAssert.throws( + () => v.validateApplyPayload({ fields: { 'directory_list-title': { field_id: '1' } } }), + 'must be an array' + ); +}); + +suite.test('Validator: rejects field entries missing field_id', () => { + const v = new ViewValidator(client); + TestAssert.throws( + () => v.validateApplyPayload({ fields: { 'directory_list-title': [{ label: 'oops' }] } }), + 'missing required key "field_id"' + ); +}); + +suite.test('Validator: rejects non-string/non-number field_id (false, object, array, whitespace)', () => { + const v = new ViewValidator(client); + for (const bad of [false, {}, [], ' ', true]) { + TestAssert.throws( + () => v.validateApplyPayload({ fields: { 'directory_list-title': [{ field_id: bad }] } }), + 'field_id' + ); + } +}); + +suite.test('Validator: accepts a numeric field_id', () => { + const v = new ViewValidator(client); + // field_id is later coerced via String(item.field_id) — numbers are valid. + v.validateApplyPayload({ fields: { 'directory_list-title': [{ field_id: 1 }] } }); +}); + +suite.test('Validator: validateAgainstSchemas rejects unknown setting keys', async () => { + // Fake schema for the "custom" field type. + client.getFieldTypeSchema = async () => ({ + field_type: 'custom', + schema: [ + { slug: 'show_label' }, + { slug: 'custom_class' }, + { slug: 'content' }, + ], + }); + const v = new ViewValidator(client); + await TestAssert.throwsAsync( + () => + v.validateAgainstSchemas({ + fields: { + 'directory_list-title': [{ field_id: 'custom', made_up_setting: 'nope' }], + }, + }), + 'unknown setting "made_up_setting"' + ); +}); + +suite.test('Validator: validateAgainstSchemas resolves input_type for numeric ids and rejects unknown keys', async () => { + // Numeric field id "2" is form field of type=email. The validator + // looks up the input_type via listAvailableFields, then fetches + // the schema with field_type=field + input_type=email so the + // email-specific overlay (emailmailto, emailsubject, emailbody, + // emailencrypt) is in the valid-key set. A bogus setting like + // `made_up_email_setting` must be rejected. + client.listAvailableFields = async () => ({ + form_fields: [ + { id: '2', label: 'Email', input_type: 'email', type: 'email' }, + ], + }); + client.getFieldTypeSchema = async ({ field_type, input_type }) => { + TestAssert.equal(field_type, 'field'); + TestAssert.equal(input_type, 'email'); + return { + field_type: 'field', + schema: [ + { slug: 'show_label' }, + { slug: 'custom_label' }, + { slug: 'custom_class' }, + // Email-specific overlays: + { slug: 'emailmailto' }, + { slug: 'emailsubject' }, + { slug: 'emailbody' }, + { slug: 'emailencrypt' }, + ], + }; + }; + const v = new ViewValidator(client); + + // Email-specific setting allowed: + await v.validateAgainstSchemas({ + id: 9999, + fields: { + 'directory_list-title': [{ field_id: '2', emailmailto: '1', emailsubject: 'Hi' }], + }, + }); + + // Bogus setting rejected — was the case Zack flagged in stress + // testing where validateAgainstSchemas:true silently accepted + // typoed keys on numeric form fields. + await TestAssert.throwsAsync( + () => + v.validateAgainstSchemas({ + id: 9999, + fields: { + 'directory_list-title': [{ field_id: '2', made_up_email_setting: 'nope' }], + }, + }), + 'unknown setting "made_up_email_setting"' + ); +}); + +suite.test('Validator: validateAgainstSchemas falls back to base schema when input_type lookup fails', async () => { + // No id supplied (e.g. gv_create_view before the View exists) — + // input_type lookup is skipped and the validator hits the field + // schema with no input_type. Catches the common typos against + // the base settings without false-positive-rejecting overlay + // settings the validator can't see. + client.getFieldTypeSchema = async ({ field_type, input_type }) => { + TestAssert.equal(field_type, 'field'); + TestAssert.equal(input_type, undefined); + return { + field_type: 'field', + schema: [ + { slug: 'show_label' }, + { slug: 'custom_label' }, + { slug: 'custom_class' }, + ], + }; + }; + const v = new ViewValidator(client); + + // Typo on a base-schema slug → rejected. + await TestAssert.throwsAsync( + () => + v.validateAgainstSchemas({ + fields: { + 'directory_list-title': [{ field_id: '1', custom_lable: 'typo' }], + }, + }), + 'unknown setting "custom_lable"' + ); +}); + +suite.run(); diff --git a/test/wp-client.test.js b/test/wp-client.test.js new file mode 100644 index 0000000..2d287cb --- /dev/null +++ b/test/wp-client.test.js @@ -0,0 +1,32 @@ +/** + * WordPressClient refuses to send Basic auth over a remote plain-HTTP URL + * (credentials would be exposed) unless explicitly opted in — matching the + * Gravity Forms plane's guard. + */ + +import test from 'node:test'; +import assert from 'node:assert'; +import { WordPressClient } from '../src/wp-client.js'; + +const creds = { GRAVITYKIT_WP_USERNAME: 'admin', GRAVITYKIT_WP_APP_PASSWORD: 'pw' }; +const make = (extra) => () => new WordPressClient({ ...creds, ...extra }); + +test('allows HTTPS remote URLs', () => { + assert.doesNotThrow(make({ GRAVITYKIT_WP_URL: 'https://remote.example.com' })); +}); + +test('allows local plain-HTTP URLs (localhost, *.test)', () => { + assert.doesNotThrow(make({ GRAVITYKIT_WP_URL: 'http://localhost:8892' })); + assert.doesNotThrow(make({ GRAVITYKIT_WP_URL: 'http://mysite.test' })); +}); + +test('refuses Basic auth over a remote plain-HTTP URL by default', () => { + assert.throws(make({ GRAVITYKIT_WP_URL: 'http://remote.example.com' }), /http/i); +}); + +test('allows remote plain-HTTP Basic when explicitly opted in', () => { + assert.doesNotThrow(make({ + GRAVITYKIT_WP_URL: 'http://remote.example.com', + GRAVITY_FORMS_ALLOW_HTTP_BASIC_AUTH: 'true', + })); +});