From 0b55a066ba03fce0c792bc982abb6ceb3d798078 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 07:36:09 +0000 Subject: [PATCH 1/2] feat(abilities-api): add AbilitiesPolicy, AbilitiesApi, policy delegates, and MCP guide Agent-Logs-Url: https://github.com/weDevsOfficial/wedocs-plugin/sessions/3697e186-f7d9-4519-8840-6e107565d440 Co-authored-by: iftakharul-islam <88052038+iftakharul-islam@users.noreply.github.com> --- docs/mcp-integration.md | 537 +++++++++++++++++++++++++++++++ includes/API/API.php | 16 +- includes/API/AbilitiesApi.php | 280 ++++++++++++++++ includes/API/AbilitiesPolicy.php | 248 ++++++++++++++ includes/API/SettingsApi.php | 4 +- 5 files changed, 1077 insertions(+), 8 deletions(-) create mode 100644 docs/mcp-integration.md create mode 100644 includes/API/AbilitiesApi.php create mode 100644 includes/API/AbilitiesPolicy.php diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md new file mode 100644 index 00000000..b0c4041c --- /dev/null +++ b/docs/mcp-integration.md @@ -0,0 +1,537 @@ +# weDocs MCP Integration Guide + +**For:** Developers building Model Context Protocol (MCP) clients on top of weDocs +**Since:** weDocs 2.2.2 +**Namespace:** `wp/v2/docs` + +--- + +## Table of Contents + +1. [What is the weDocs Abilities API?](#1-what-is-the-wedocs-abilities-api) +2. [Abilities Matrix](#2-abilities-matrix) +3. [Authentication for MCP Clients](#3-authentication-for-mcp-clients) +4. [Endpoint Reference](#4-endpoint-reference) +5. [MCP Onboarding Flow](#5-mcp-onboarding-flow) +6. [Mapping MCP Actions to Abilities](#6-mapping-mcp-actions-to-abilities) +7. [Graceful Denial Handling](#7-graceful-denial-handling) +8. [Public vs Authenticated Flows](#8-public-vs-authenticated-flows) +9. [Extending Abilities](#9-extending-abilities) +10. [Migration Notes for Existing Integrations](#10-migration-notes-for-existing-integrations) + +--- + +## 1. What is the weDocs Abilities API? + +The **Abilities API** is a lightweight discovery layer that tells an MCP client exactly what the currently authenticated user is allowed to do *before* making any mutating API calls. This avoids the pattern of speculatively attempting an action and relying on a `403 Forbidden` error. + +Two REST endpoints are exposed: + +| Method | URL | Description | +|--------|-----|-------------| +| `GET` | `/wp/v2/docs/abilities` | Global abilities for the current user | +| `GET` | `/wp/v2/docs/{id}/abilities` | Per-doc abilities for the current user | + +Both endpoints are **always publicly accessible** and return `false` for every protected ability when called without credentials. + +--- + +## 2. Abilities Matrix + +### 2.1 Global abilities (`/wp/v2/docs/abilities`) + +| Ability key | `true` when… | Required WordPress capability | +|---|---|---| +| `docs.read` | Always | _(public)_ | +| `docs.read_private` | User can read private docs | `read_private_docs` | +| `docs.create` | User can author new docs | `edit_docs` | +| `docs.edit` | User can edit own docs | `edit_docs` | +| `docs.edit_others` | User can edit other authors' docs | `edit_others_docs` | +| `docs.delete` | User can delete docs | `edit_docs` | +| `docs.publish` | User can publish docs | `publish_docs` | +| `docs.sort` | User can reorder / sort docs | `edit_docs` | +| `docs.search` | Always | _(public)_ | +| `settings.read` | User can view plugin settings | `manage_options` | +| `settings.write` | User can save plugin settings | `manage_options` | +| `ai.generate` | User can generate AI content | `edit_docs` | +| `ai.upload_image` | User can upload images for AI (Pro + upload cap) | `edit_docs` + `upload_files` + Pro active | +| `ai.configure` | User can configure AI providers | `manage_options` | +| `summary.read` | Always | _(public)_ | +| `summary.save` | User can persist AI summaries | `edit_docs` | +| `summary.delete` | User can delete AI summaries | `edit_docs` | +| `summary.generate` | Always (errors if AI not configured) | _(public)_ | +| `notices.manage` | User can view / dismiss admin notices | `manage_options` | +| `helpfulness.vote` | Logged-in users only | _(is_user_logged_in)_ | +| `feedback.submit` | Always | _(public)_ | +| `contributors.read` | Always | _(public)_ | +| `pro_active` | weDocs Pro is installed and active | _(plugin check)_ | + +### 2.2 Per-doc abilities (`/wp/v2/docs/{id}/abilities`) + +Per-doc abilities take the **post status** into account: + +| Status | `read` | +|---|---| +| `publish` | Everyone | +| `private` | Users with `read_private_docs` | +| `draft` / `pending` / `future` | Users with `edit_docs` **or** the post author | +| `trash` | Nobody | + +| Ability key | Description | +|---|---| +| `read` | Can read this specific doc | +| `edit` | `current_user_can('edit_post', $id)` | +| `delete` | `current_user_can('delete_post', $id)` | +| `publish` | Has `publish_docs` capability | +| `summary.read` | Same as `read` | +| `summary.save` | `edit` + `edit_docs` | +| `summary.delete` | `edit` + `edit_docs` | +| `summary.generate` | Same as `read` | + +--- + +## 3. Authentication for MCP Clients + +weDocs uses **WordPress REST API authentication**. Choose the method that fits your MCP host: + +### 3.1 Application Passwords (recommended) + +WordPress 5.6+ includes Application Passwords which are ideal for server-to-server integrations. + +1. Log in to WordPress admin → **Users → Profile → Application Passwords**. +2. Create a new password, e.g. `My MCP Client`. +3. Send the credentials as HTTP Basic Auth on every request: + +``` +Authorization: Basic base64(username:application_password) +``` + +```http +GET /wp-json/wp/v2/docs/abilities HTTP/1.1 +Host: example.com +Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ= +``` + +> **Security note:** Application Passwords only work over HTTPS. Never send them over plain HTTP. + +### 3.2 Cookie + Nonce (browser-based MCP clients) + +For MCP clients running inside a browser context (e.g. a JavaScript agent embedded in the WordPress admin): + +```js +// WordPress injects wp.apiFetch with nonce automatically when running in admin context. +wp.apiFetch({ path: '/wp/v2/docs/abilities' }).then( console.log ); +``` + +If constructing requests manually, include the nonce: + +```http +GET /wp-json/wp/v2/docs/abilities HTTP/1.1 +X-WP-Nonce: +Cookie: wordpress_logged_in_...=... +``` + +### 3.3 JWT Authentication (third-party plugin) + +If the site uses a JWT plugin (e.g. [JWT Authentication for WP REST API](https://wordpress.org/plugins/jwt-authentication-for-wp-rest-api/)): + +```http +GET /wp-json/wp/v2/docs/abilities HTTP/1.1 +Authorization: Bearer +``` + +--- + +## 4. Endpoint Reference + +### 4.1 `GET /wp/v2/docs/abilities` — Global abilities + +Returns the current user's global abilities across all weDocs features. + +**Request** + +```http +GET /wp-json/wp/v2/docs/abilities HTTP/1.1 +Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ= +``` + +**Response (authenticated editor)** + +```json +{ + "docs.read": true, + "docs.read_private": true, + "docs.create": true, + "docs.edit": true, + "docs.edit_others": true, + "docs.delete": true, + "docs.publish": true, + "docs.sort": true, + "docs.search": true, + "settings.read": false, + "settings.write": false, + "ai.generate": true, + "ai.upload_image": false, + "ai.configure": false, + "summary.read": true, + "summary.save": true, + "summary.delete": true, + "summary.generate": true, + "notices.manage": false, + "helpfulness.vote": true, + "feedback.submit": true, + "contributors.read": true, + "pro_active": false +} +``` + +**Response (unauthenticated)** + +```json +{ + "docs.read": true, + "docs.read_private": false, + "docs.create": false, + "docs.edit": false, + "docs.edit_others": false, + "docs.delete": false, + "docs.publish": false, + "docs.sort": false, + "docs.search": true, + "settings.read": false, + "settings.write": false, + "ai.generate": false, + "ai.upload_image": false, + "ai.configure": false, + "summary.read": true, + "summary.save": false, + "summary.delete": false, + "summary.generate": true, + "notices.manage": false, + "helpfulness.vote": false, + "feedback.submit": true, + "contributors.read": true, + "pro_active": false +} +``` + +--- + +### 4.2 `GET /wp/v2/docs/{id}/abilities` — Per-doc abilities + +Returns the current user's abilities for a specific doc post. + +**Request** + +```http +GET /wp-json/wp/v2/docs/42/abilities HTTP/1.1 +Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ= +``` + +**Response (editor, doc is published)** + +```json +{ + "read": true, + "edit": true, + "delete": true, + "publish": true, + "summary.read": true, + "summary.save": true, + "summary.delete": true, + "summary.generate": true +} +``` + +**Response (404 — doc does not exist)** + +```json +{ + "code": "wedocs_invalid_doc", + "message": "Invalid documentation post.", + "data": { "status": 404 } +} +``` + +--- + +## 5. MCP Onboarding Flow + +The recommended flow for an MCP client connecting to a weDocs installation: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ MCP Client │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. Authenticate (App Password / JWT / Cookie+Nonce) │ +│ │ +│ 2. GET /wp/v2/docs/abilities │ +│ → Store the returned abilities map │ +│ │ +│ 3. Enable / disable tools based on abilities: │ +│ - docs.search = true → expose "search docs" tool │ +│ - docs.create = true → expose "create doc" tool │ +│ - ai.generate = true → expose "generate AI content" tool │ +│ - settings.read = true → expose "get settings" tool │ +│ - pro_active = true → expose "upload image" tool │ +│ ... │ +│ │ +│ 4. Before each mutating call, optionally re-check per-doc │ +│ abilities via GET /wp/v2/docs/{id}/abilities if resource │ +│ IDs are known in advance. │ +│ │ +│ 5. On 403 from any endpoint, refresh abilities and re-evaluate │ +│ (the user's role may have changed mid-session). │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Pseudo-code (Node.js / TypeScript) + +```typescript +interface WeDocsAbilities { + [key: string]: boolean; +} + +async function initMcpTools(apiBase: string, auth: string): Promise { + const response = await fetch(`${apiBase}/wp-json/wp/v2/docs/abilities`, { + headers: { Authorization: auth }, + }); + const abilities: WeDocsAbilities = await response.json(); + + const tools = []; + + if (abilities['docs.search']) { + tools.push(searchDocsTool); + } + if (abilities['docs.create']) { + tools.push(createDocTool); + } + if (abilities['docs.edit']) { + tools.push(updateDocTool); + } + if (abilities['docs.delete']) { + tools.push(deleteDocTool); + } + if (abilities['ai.generate']) { + tools.push(generateAiContentTool); + } + if (abilities['ai.upload_image']) { + tools.push(uploadImageTool); // Pro only + } + if (abilities['settings.read']) { + tools.push(getSettingsTool); + } + if (abilities['settings.write']) { + tools.push(saveSettingsTool); + } + + registerTools(tools); +} +``` + +### Pseudo-code (Python) + +```python +import requests + +def init_mcp_tools(api_base: str, auth: tuple) -> list: + resp = requests.get(f"{api_base}/wp-json/wp/v2/docs/abilities", auth=auth) + resp.raise_for_status() + abilities = resp.json() + + tools = [] + if abilities.get("docs.search"): + tools.append("search_docs") + if abilities.get("docs.create"): + tools.append("create_doc") + if abilities.get("docs.edit"): + tools.append("update_doc") + if abilities.get("docs.delete"): + tools.append("delete_doc") + if abilities.get("ai.generate"): + tools.append("generate_ai_content") + if abilities.get("settings.read"): + tools.append("get_settings") + if abilities.get("settings.write"): + tools.append("save_settings") + return tools +``` + +--- + +## 6. Mapping MCP Actions to Abilities + +| MCP Tool / Action | Required Ability | Endpoint(s) | +|---|---|---| +| List all docs | `docs.read` | `GET /wp/v2/docs` | +| Get a single doc | `docs.read` | `GET /wp/v2/docs/{id}` | +| Get a private doc | `docs.read_private` | `GET /wp/v2/docs/{id}` | +| Search docs | `docs.search` | `GET /wp/v2/docs/search` | +| Get doc contributors | `contributors.read` | `GET /wp/v2/docs/contributors` | +| Create a doc | `docs.create` | `POST /wp/v2/docs` | +| Update a doc | `docs.edit` or per-doc `edit` | `PUT /wp/v2/docs/{id}` | +| Delete a doc | `docs.delete` or per-doc `delete` | `DELETE /wp/v2/docs/{id}` | +| Sort / reorder docs | `docs.sort` | `POST /wp/v2/docs/sortable` | +| Get settings | `settings.read` | `GET /wp/v2/docs/settings` | +| Save settings | `settings.write` | `POST /wp/v2/docs/settings` | +| Generate AI content | `ai.generate` | `POST /wp/v2/docs/ai/generate` | +| Upload image for AI | `ai.upload_image` | `POST /wp/v2/docs/ai/upload-image` | +| List AI models | `ai.generate` | `GET /wp/v2/docs/ai/models/{provider}` | +| Read AI summary | `summary.read` | `GET /wp/v2/docs/{id}/ai-summary` | +| Save AI summary | `summary.save` or per-doc `summary.save` | `POST /wp/v2/docs/{id}/ai-summary` | +| Delete AI summary | `summary.delete` or per-doc `summary.delete` | `DELETE /wp/v2/docs/{id}/ai-summary` | +| Generate AI summary | `summary.generate` | `POST /wp/v2/docs/{id}/ai-summary/generate` | +| Submit helpful vote | `helpfulness.vote` | `POST /wp/v2/docs/{id}/helpful` | +| Submit feedback | `feedback.submit` | `POST /wp/v2/docs/{id}/feedback` | +| View admin notices | `notices.manage` | `GET /wp/v2/docs/promo-notices` | +| Dismiss admin notice | `notices.manage` | `POST /wp/v2/docs/promo-notices/{key}/dismiss` | + +--- + +## 7. Graceful Denial Handling + +When a tool is invoked despite the corresponding ability being `false`, the REST endpoint will return a `403 Forbidden` or `401 Unauthorized` response. Your MCP client should handle this gracefully: + +```typescript +async function callDocsTool( + name: string, + abilities: WeDocsAbilities, + requiredAbility: string, + fn: () => Promise +): Promise { + if (!abilities[requiredAbility]) { + return { + error: 'permission_denied', + message: `The current user does not have the '${requiredAbility}' ability. ` + + `Tip: check GET /wp-json/wp/v2/docs/abilities for the full list.`, + }; + } + + try { + return await fn(); + } catch (err: any) { + if (err.status === 403 || err.status === 401) { + // Refresh abilities — role may have changed mid-session. + abilities = await fetchAbilities(); + return { + error: 'permission_denied_server', + message: err.message, + abilities_refreshed: true, + }; + } + throw err; + } +} +``` + +**Key rule:** Never surface raw WordPress `WP_Error` objects to end users. Always translate permission errors into actionable messages (e.g. *"You need Editor role or higher to create docs."*). + +--- + +## 8. Public vs Authenticated Flows + +Some abilities are always `true` and do not require authentication. This enables unauthenticated MCP agents (e.g. a read-only documentation chatbot) to function without credentials: + +| Ability | Auth required? | +|---|---| +| `docs.read` | No | +| `docs.search` | No | +| `summary.read` | No | +| `summary.generate` | No (requires AI to be configured on the server) | +| `feedback.submit` | No (requires `name` + `email` fields for guests) | +| `contributors.read` | No | +| All other abilities | Yes | + +An unauthenticated MCP agent can therefore provide a **read-only documentation experience** without any credentials. To perform write operations, it must authenticate first. + +--- + +## 9. Extending Abilities + +Third-party plugins (including weDocs Pro) can add or override abilities using WordPress filters: + +### Extending global abilities + +```php +add_filter( 'wedocs_global_abilities', function( array $abilities ): array { + // Add a custom Pro ability. + $abilities['my_plugin.custom_action'] = current_user_can( 'my_custom_capability' ); + + // Override an existing ability. + if ( my_plugin_has_extended_permissions() ) { + $abilities['docs.delete'] = current_user_can( 'delete_docs' ); + } + + return $abilities; +} ); +``` + +### Extending per-doc abilities + +```php +add_filter( 'wedocs_doc_abilities', function( array $abilities, int $doc_id, WP_Post $post ): array { + // Allow users with a custom role to always export. + $abilities['export'] = current_user_can( 'export_docs' ); + + return $abilities; +}, 10, 3 ); +``` + +MCP clients that consume these extended abilities should enumerate all keys of the returned object rather than checking a hardcoded list: + +```typescript +// Safe: iterate all returned abilities. +for (const [key, value] of Object.entries(abilities)) { + if (value && toolRegistry.has(key)) { + tools.push(toolRegistry.get(key)); + } +} +``` + +--- + +## 10. Migration Notes for Existing Integrations + +### 10.1 What changed in 2.2.2 + +| Area | Before 2.2.2 | 2.2.2+ | +|---|---|---| +| Permission logic | Scattered `current_user_can()` calls inline in each permission_callback | Centralized in `WeDevs\WeDocs\API\AbilitiesPolicy` | +| Abilities discovery | Not available | `GET /wp/v2/docs/abilities` + `GET /wp/v2/docs/{id}/abilities` | +| Callback behavior | Unchanged (same capability checks, same HTTP status codes) | Identical — delegates to `AbilitiesPolicy` | + +### 10.2 No breaking changes + +The behavior of all existing REST endpoints is **fully backward compatible**: + +- Endpoints that previously checked `current_user_can('edit_docs')` still check the same capability — `AbilitiesPolicy::can('docs.edit')` resolves to the same `current_user_can('edit_docs')` call under the hood. +- HTTP status codes (`401`, `403`, `404`) are unchanged. +- Response payloads are unchanged. + +### 10.3 Filter-based migration + +If you previously hooked into individual endpoint permission_callbacks to alter access, migrate to the `wedocs_global_abilities` filter instead for cleaner integration: + +```php +// Before (fragile — tied to internal method names) +add_filter( 'wedocs_ai_generate_permissions', '__return_true' ); + +// After (stable — uses the abilities contract) +add_filter( 'wedocs_global_abilities', function( $abilities ) { + $abilities['ai.generate'] = true; + return $abilities; +} ); +``` + +--- + +## Quick-Start Checklist for MCP Developers + +- [ ] Choose an authentication method (Application Passwords recommended for server-to-server) +- [ ] On startup, call `GET /wp/v2/docs/abilities` and store the result +- [ ] Enable / disable MCP tools based on the returned abilities +- [ ] For resource-specific operations, call `GET /wp/v2/docs/{id}/abilities` before mutating +- [ ] Handle `403` responses by refreshing the abilities map +- [ ] Never hard-code capability strings — always check abilities dynamically +- [ ] Subscribe to the `wedocs_global_abilities` filter if you need to expose custom Pro abilities +- [ ] Test with both authenticated and unauthenticated requests to verify graceful degradation diff --git a/includes/API/API.php b/includes/API/API.php index 415f2bee..ee286281 100644 --- a/includes/API/API.php +++ b/includes/API/API.php @@ -39,6 +39,10 @@ public function __construct( $api ) { // Register upgrader api. $upgrader_api = new UpgraderApi( $api ); $upgrader_api->register_api(); + + // Register abilities api. + $abilities_api = new AbilitiesApi(); + $abilities_api->register_routes(); } /** @@ -613,7 +617,7 @@ public function update_docs_status( $request ) { * @return \WP_Error|bool */ public function sortable_item_permissions_check() { - if ( ! current_user_can( 'edit_docs' ) ) { + if ( ! AbilitiesPolicy::can( 'docs.sort' ) ) { return new WP_Error( 'wedocs_permission_failure', __( 'Unauthorized permission error', 'wedocs' ) @@ -1096,7 +1100,7 @@ protected function get_doc( $id ) { * @return bool|WP_Error */ public function delete_item_permissions_check( $request ) { - if ( ! current_user_can( 'edit_docs' ) ) { + if ( ! AbilitiesPolicy::can( 'docs.delete' ) ) { return new WP_Error( 'wedocs_permission_failure', __( 'You cannot delete the documentation resource.', 'wedocs' ) @@ -1161,7 +1165,7 @@ public function remove_child_docs( $parent_id ) { * @return bool|WP_Error */ public function get_promotional_notice_check( $request ) { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! AbilitiesPolicy::can( 'notices.manage' ) ) { return new WP_Error( 'wedocs_permission_failure', __( 'You cannot see promotion notices.', 'wedocs' ) @@ -1179,7 +1183,7 @@ public function get_promotional_notice_check( $request ) { * @return bool|WP_Error|WP_REST_Response response object on success, or WP_Error object on failure. */ public function get_promotional_notice() { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! AbilitiesPolicy::can( 'notices.manage' ) ) { return false; } @@ -1254,7 +1258,7 @@ public function handle_hide_promotion_notice( $request ) { * @return bool|WP_Error */ public function ai_generate_permissions_check( $request ) { - if ( ! current_user_can( 'edit_docs' ) ) { + if ( ! AbilitiesPolicy::can( 'ai.generate' ) ) { return new WP_Error( 'wedocs_permission_failure', __( 'You do not have permission to generate AI content.', 'wedocs' ), @@ -1284,7 +1288,7 @@ public function ai_upload_permissions_check( $request ) { ); } - if ( ! current_user_can( 'edit_docs' ) ) { + if ( ! AbilitiesPolicy::can( 'ai.generate' ) ) { return new WP_Error( 'wedocs_permission_failure', __( 'You do not have permission to upload images.', 'wedocs' ), diff --git a/includes/API/AbilitiesApi.php b/includes/API/AbilitiesApi.php new file mode 100644 index 00000000..92165e0c --- /dev/null +++ b/includes/API/AbilitiesApi.php @@ -0,0 +1,280 @@ +namespace, + '/' . $this->rest_base . '/abilities', + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_global_abilities' ], + 'permission_callback' => '__return_true', + ], + 'schema' => [ $this, 'get_global_abilities_schema' ], + ] + ); + + // Per-doc abilities: GET /wp/v2/docs/{id}/abilities + register_rest_route( + $this->namespace, + '/' . $this->rest_base . '/(?P[\d]+)/abilities', + [ + [ + 'methods' => WP_REST_Server::READABLE, + 'callback' => [ $this, 'get_doc_abilities' ], + 'permission_callback' => '__return_true', + 'args' => [ + 'id' => [ + 'description' => __( 'Unique identifier for the doc.', 'wedocs' ), + 'type' => 'integer', + 'required' => true, + 'sanitize_callback' => 'absint', + 'validate_callback' => 'rest_validate_request_arg', + ], + ], + ], + 'schema' => [ $this, 'get_doc_abilities_schema' ], + ] + ); + } + + /** + * Return current user's global weDocs abilities. + * + * @since 2.2.2 + * + * @return \WP_REST_Response + */ + public function get_global_abilities() { + return rest_ensure_response( AbilitiesPolicy::global_abilities() ); + } + + /** + * Return current user's abilities for a specific doc post. + * + * @since 2.2.2 + * + * @param \WP_REST_Request $request + * + * @return \WP_REST_Response|\WP_Error + */ + public function get_doc_abilities( $request ) { + $abilities = AbilitiesPolicy::doc_abilities( (int) $request['id'] ); + + if ( is_wp_error( $abilities ) ) { + return $abilities; + } + + return rest_ensure_response( $abilities ); + } + + /** + * JSON Schema for the global abilities response. + * + * @since 2.2.2 + * + * @return array + */ + public function get_global_abilities_schema() { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'wedocs-global-abilities', + 'type' => 'object', + 'properties' => [ + 'docs.read' => [ + 'type' => 'boolean', + 'description' => __( 'Can read published docs (always true).', 'wedocs' ), + ], + 'docs.read_private' => [ + 'type' => 'boolean', + 'description' => __( 'Can read private docs.', 'wedocs' ), + ], + 'docs.create' => [ + 'type' => 'boolean', + 'description' => __( 'Can create new docs.', 'wedocs' ), + ], + 'docs.edit' => [ + 'type' => 'boolean', + 'description' => __( 'Can edit own docs.', 'wedocs' ), + ], + 'docs.edit_others' => [ + 'type' => 'boolean', + 'description' => __( 'Can edit docs authored by other users.', 'wedocs' ), + ], + 'docs.delete' => [ + 'type' => 'boolean', + 'description' => __( 'Can delete docs.', 'wedocs' ), + ], + 'docs.publish' => [ + 'type' => 'boolean', + 'description' => __( 'Can publish docs.', 'wedocs' ), + ], + 'docs.sort' => [ + 'type' => 'boolean', + 'description' => __( 'Can reorder / sort docs.', 'wedocs' ), + ], + 'docs.search' => [ + 'type' => 'boolean', + 'description' => __( 'Can search published docs (always true).', 'wedocs' ), + ], + 'settings.read' => [ + 'type' => 'boolean', + 'description' => __( 'Can read plugin settings.', 'wedocs' ), + ], + 'settings.write' => [ + 'type' => 'boolean', + 'description' => __( 'Can update plugin settings.', 'wedocs' ), + ], + 'ai.generate' => [ + 'type' => 'boolean', + 'description' => __( 'Can generate AI-written documentation content.', 'wedocs' ), + ], + 'ai.upload_image' => [ + 'type' => 'boolean', + 'description' => __( 'Can upload images for AI vision analysis (Pro only).', 'wedocs' ), + ], + 'ai.configure' => [ + 'type' => 'boolean', + 'description' => __( 'Can configure AI provider credentials.', 'wedocs' ), + ], + 'summary.read' => [ + 'type' => 'boolean', + 'description' => __( 'Can read AI-generated summaries (always true).', 'wedocs' ), + ], + 'summary.save' => [ + 'type' => 'boolean', + 'description' => __( 'Can save AI summaries.', 'wedocs' ), + ], + 'summary.delete' => [ + 'type' => 'boolean', + 'description' => __( 'Can delete AI summaries.', 'wedocs' ), + ], + 'summary.generate' => [ + 'type' => 'boolean', + 'description' => __( 'Can trigger on-demand AI summary generation (always true).', 'wedocs' ), + ], + 'notices.manage' => [ + 'type' => 'boolean', + 'description' => __( 'Can view and dismiss admin promotional notices.', 'wedocs' ), + ], + 'helpfulness.vote' => [ + 'type' => 'boolean', + 'description' => __( 'Can cast helpful/not-helpful votes on docs (requires login).', 'wedocs' ), + ], + 'feedback.submit' => [ + 'type' => 'boolean', + 'description' => __( 'Can submit feedback on docs (always true).', 'wedocs' ), + ], + 'contributors.read' => [ + 'type' => 'boolean', + 'description' => __( 'Can read contributor data (always true).', 'wedocs' ), + ], + 'pro_active' => [ + 'type' => 'boolean', + 'description' => __( 'Whether weDocs Pro is active on this installation.', 'wedocs' ), + ], + ], + ]; + } + + /** + * JSON Schema for the per-doc abilities response. + * + * @since 2.2.2 + * + * @return array + */ + public function get_doc_abilities_schema() { + return [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => 'wedocs-doc-abilities', + 'type' => 'object', + 'properties' => [ + 'read' => [ + 'type' => 'boolean', + 'description' => __( 'Can read this doc (depends on post status).', 'wedocs' ), + ], + 'edit' => [ + 'type' => 'boolean', + 'description' => __( 'Can edit this doc.', 'wedocs' ), + ], + 'delete' => [ + 'type' => 'boolean', + 'description' => __( 'Can delete this doc.', 'wedocs' ), + ], + 'publish' => [ + 'type' => 'boolean', + 'description' => __( 'Can publish this doc.', 'wedocs' ), + ], + 'summary.read' => [ + 'type' => 'boolean', + 'description' => __( 'Can read the AI summary for this doc.', 'wedocs' ), + ], + 'summary.save' => [ + 'type' => 'boolean', + 'description' => __( 'Can save the AI summary for this doc.', 'wedocs' ), + ], + 'summary.delete' => [ + 'type' => 'boolean', + 'description' => __( 'Can delete the AI summary for this doc.', 'wedocs' ), + ], + 'summary.generate' => [ + 'type' => 'boolean', + 'description' => __( 'Can trigger AI summary generation for this doc.', 'wedocs' ), + ], + ], + ]; + } +} diff --git a/includes/API/AbilitiesPolicy.php b/includes/API/AbilitiesPolicy.php new file mode 100644 index 00000000..3de3c71b --- /dev/null +++ b/includes/API/AbilitiesPolicy.php @@ -0,0 +1,248 @@ + Keyed ability map. + */ + public static function global_abilities() { + $abilities = [ + // ── Docs ──────────────────────────────────────────────────────── + // Published docs are always readable by anyone. + 'docs.read' => true, + // Reading docs that are in private status. + 'docs.read_private' => current_user_can( 'read_private_docs' ), + // Creating new doc posts (requires edit_docs which admins/editors get). + 'docs.create' => current_user_can( 'edit_docs' ), + // Editing own doc posts. + 'docs.edit' => current_user_can( 'edit_docs' ), + // Editing doc posts authored by other users. + 'docs.edit_others' => current_user_can( 'edit_others_docs' ), + // Deleting doc posts (uses the same capability gate as the delete endpoint). + 'docs.delete' => current_user_can( 'edit_docs' ), + // Publishing doc posts. + 'docs.publish' => current_user_can( 'publish_docs' ), + // Reordering / sorting docs via drag-and-drop. + 'docs.sort' => current_user_can( 'edit_docs' ), + // Searching published docs (always allowed). + 'docs.search' => true, + + // ── Settings ──────────────────────────────────────────────────── + // Reading plugin settings. + 'settings.read' => current_user_can( 'manage_options' ), + // Saving plugin settings. + 'settings.write' => current_user_can( 'manage_options' ), + + // ── AI ────────────────────────────────────────────────────────── + // Generating AI-written documentation content. + 'ai.generate' => current_user_can( 'edit_docs' ), + // Uploading images for AI vision analysis (Pro feature). + 'ai.upload_image' => wedocs_is_pro_active() + && current_user_can( 'edit_docs' ) + && current_user_can( 'upload_files' ), + // Configuring AI provider credentials. + 'ai.configure' => current_user_can( 'manage_options' ), + + // ── Summary ───────────────────────────────────────────────────── + // Reading an AI-generated summary (publicly accessible). + 'summary.read' => true, + // Saving a manually provided AI summary. + 'summary.save' => current_user_can( 'edit_docs' ), + // Deleting a stored AI summary. + 'summary.delete' => current_user_can( 'edit_docs' ), + // Triggering on-demand AI summary generation (publicly accessible; + // the underlying generate endpoint returns an error if AI is not configured). + 'summary.generate' => true, + + // ── Admin notices ─────────────────────────────────────────────── + // Viewing and dismissing promotional / admin notices. + 'notices.manage' => current_user_can( 'manage_options' ), + + // ── Social / interaction ───────────────────────────────────────── + // Casting a helpful/not-helpful vote on a doc. + 'helpfulness.vote' => is_user_logged_in(), + // Submitting feedback (open to guests with name + email). + 'feedback.submit' => true, + // Reading documentation contributor data. + 'contributors.read' => true, + + // ── Meta ──────────────────────────────────────────────────────── + // Whether weDocs Pro is active on this installation. + 'pro_active' => wedocs_is_pro_active(), + ]; + + /** + * Filters the global abilities map. + * + * Use this filter to add, remove, or override abilities. weDocs Pro uses + * it to expose additional permission-gated features. + * + * @since 2.2.2 + * + * @param array $abilities Keyed ability map. + */ + return apply_filters( 'wedocs_global_abilities', $abilities ); + } + + /** + * Returns abilities for the current user scoped to a specific doc post. + * + * Per-doc abilities take the post status into account so MCP clients can + * determine fine-grained access before attempting an operation. + * + * @since 2.2.2 + * + * @param int $doc_id The doc post ID. + * + * @return array|\WP_Error Abilities map, or WP_Error if invalid. + */ + public static function doc_abilities( $doc_id ) { + $doc_id = absint( $doc_id ); + $post = get_post( $doc_id ); + + if ( ! $post || 'docs' !== $post->post_type ) { + return new \WP_Error( + 'wedocs_invalid_doc', + __( 'Invalid documentation post.', 'wedocs' ), + [ 'status' => 404 ] + ); + } + + // Read access is the base for most other abilities. + $can_read = self::resolve_read_access( $post ); + + // edit_post / delete_post map through map_meta_cap so WordPress applies + // the custom capability_type registered for 'docs'. + $can_edit = current_user_can( 'edit_post', $doc_id ); + $can_delete = current_user_can( 'delete_post', $doc_id ); + + $abilities = [ + 'read' => $can_read, + 'edit' => $can_edit, + 'delete' => $can_delete, + 'publish' => current_user_can( 'publish_docs' ), + + // AI summary abilities for this specific doc. + 'summary.read' => $can_read, + 'summary.save' => $can_edit && current_user_can( 'edit_docs' ), + 'summary.delete' => $can_edit && current_user_can( 'edit_docs' ), + // Summary generation shares public access (same as the /generate endpoint). + 'summary.generate' => $can_read, + ]; + + /** + * Filters the per-doc abilities map. + * + * @since 2.2.2 + * + * @param array $abilities Keyed ability map. + * @param int $doc_id The doc post ID. + * @param \WP_Post $post The doc post object. + */ + return apply_filters( 'wedocs_doc_abilities', $abilities, $doc_id, $post ); + } + + /** + * Resolves whether the current user can read a specific doc post. + * + * Mirrors the status-aware logic in `API::get_parents_permissions_check()`. + * + * | Post status | Who can read? | + * |------------------------------------|--------------------------------------------------| + * | publish | Everyone | + * | private | Users with `read_private_docs` | + * | draft / pending / future | Users with `edit_docs` **or** the post author | + * | trash | Nobody via the REST API | + * + * @since 2.2.2 + * + * @param \WP_Post $post The doc post object. + * + * @return bool + */ + public static function resolve_read_access( $post ) { + $status = get_post_status( $post ); + + if ( 'trash' === $status ) { + return false; + } + + if ( 'publish' === $status ) { + return true; + } + + if ( ! is_user_logged_in() ) { + return false; + } + + if ( 'private' === $status ) { + return (bool) current_user_can( 'read_private_docs' ); + } + + // draft, pending, future – editors or the post author. + if ( current_user_can( 'edit_docs' ) ) { + return true; + } + + return (int) get_current_user_id() === (int) $post->post_author; + } + + /** + * Helper to check a single named global ability for the current user. + * + * Useful inside `permission_callback` functions so that the REST endpoint + * enforcement always delegates to the same logic that is *reported* by the + * abilities API: + * + * ```php + * if ( ! AbilitiesPolicy::can( 'ai.generate' ) ) { + * return new WP_Error( 'wedocs_permission_failure', ... ); + * } + * ``` + * + * @since 2.2.2 + * + * @param string $ability An ability key returned by `global_abilities()`. + * + * @return bool `true` if the current user has the ability, `false` otherwise. + */ + public static function can( $ability ) { + $abilities = self::global_abilities(); + return isset( $abilities[ $ability ] ) ? (bool) $abilities[ $ability ] : false; + } +} diff --git a/includes/API/SettingsApi.php b/includes/API/SettingsApi.php index 9ed93bc8..d065abab 100644 --- a/includes/API/SettingsApi.php +++ b/includes/API/SettingsApi.php @@ -113,7 +113,7 @@ public function register_api() { * @return bool|WP_Error */ public function get_items_permissions_check( $request ) { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! AbilitiesPolicy::can( 'settings.read' ) ) { return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to do that.', 'wedocs' ), array( 'status' => rest_authorization_required_code() ) ); } @@ -130,7 +130,7 @@ public function get_items_permissions_check( $request ) { * @return bool|WP_Error */ public function create_item_permissions_check( $request ) { - if ( ! current_user_can( 'manage_options' ) ) { + if ( ! AbilitiesPolicy::can( 'settings.write' ) ) { return new \WP_Error( 'rest_forbidden', __( 'Sorry, you are not allowed to do that.', 'wedocs' ), array( 'status' => rest_authorization_required_code() ) ); } From fc9b0a4d7db0ee2651e34c019128455991563b37 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 12 May 2026 07:37:09 +0000 Subject: [PATCH 2/2] fix: address code review feedback in AbilitiesPolicy Agent-Logs-Url: https://github.com/weDevsOfficial/wedocs-plugin/sessions/3697e186-f7d9-4519-8840-6e107565d440 Co-authored-by: iftakharul-islam <88052038+iftakharul-islam@users.noreply.github.com> --- includes/API/AbilitiesPolicy.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/includes/API/AbilitiesPolicy.php b/includes/API/AbilitiesPolicy.php index 3de3c71b..d0f6d300 100644 --- a/includes/API/AbilitiesPolicy.php +++ b/includes/API/AbilitiesPolicy.php @@ -151,6 +151,11 @@ public static function doc_abilities( $doc_id ) { $can_edit = current_user_can( 'edit_post', $doc_id ); $can_delete = current_user_can( 'delete_post', $doc_id ); + // Summary write operations require both being able to edit this specific + // post AND having the global edit_docs capability (e.g. an editor who + // lost the role mid-session is denied at both levels). + $can_edit_summaries = $can_edit && current_user_can( 'edit_docs' ); + $abilities = [ 'read' => $can_read, 'edit' => $can_edit, @@ -159,8 +164,8 @@ public static function doc_abilities( $doc_id ) { // AI summary abilities for this specific doc. 'summary.read' => $can_read, - 'summary.save' => $can_edit && current_user_can( 'edit_docs' ), - 'summary.delete' => $can_edit && current_user_can( 'edit_docs' ), + 'summary.save' => $can_edit_summaries, + 'summary.delete' => $can_edit_summaries, // Summary generation shares public access (same as the /generate endpoint). 'summary.generate' => $can_read, ]; @@ -211,7 +216,7 @@ public static function resolve_read_access( $post ) { } if ( 'private' === $status ) { - return (bool) current_user_can( 'read_private_docs' ); + return current_user_can( 'read_private_docs' ); } // draft, pending, future – editors or the post author.