docs: mapeamento completo do EMCP Tools (11 docs, ~6070 linhas)
Especificação funcional do plugin emcp-tools v3.12.1 (msrbuilds/elementor-mcp, GPL-2.0-or-later) via leitura directa do código-fonte em emanuelalmeida.pt. - 00: arquitectura (bootstrap, ability registrar, dispatcher, MCP adapter) - 01: Elementor classico (paginas, layout, widgets, templates, globals) - 02: Elementor Atomic v4 + Gutenberg - 03: WordPress core (conteudo, media, settings, temas) - 04: Themer (CPT, condicoes, render, PHP templates) - 05: Redirects + change ledger unificado (rollback) - 06: Sandbox PHP snippets + custom widgets - 07: Filesystem/DB/WP-CLI/Security/Performance (maior risco) - 08: Integracoes terceiros (ACF, Meta Box, forms, SEO) - 09: Stock images + Cloud + OAuth - 10: Sistema de modulos + inventario Pro-only (30 classes) - INDEX: sintese, sequencia de construcao, tabela de risco Produzido por 10 subagentes code-explorer em paralelo + revisao cruzada de consistencia. Cada doc inclui blueprint de replica (copiar/simplificar/omitir).
This commit is contained in:
@@ -0,0 +1,654 @@
|
||||
# 09 — Stock Images, EMCP Cloud e Servidor OAuth
|
||||
|
||||
Fonte: leitura directa do código-fonte `emcp-tools` v3.12.1 (build Free), instalado em
|
||||
`emanuelalmeida.pt` (`/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`),
|
||||
19-08-2026. Cobre 3 subsistemas relacionados mas independentes entre si: (1) busca e
|
||||
sideload de imagens de stock, (2) sincronização com o serviço SaaS "EMCP Cloud"
|
||||
(`emcptools.com`), e (3) um servidor de autorização OAuth 2.1 completo, próprio do plugin,
|
||||
que serve de mecanismo de autenticação alternativo ao Application Password para clientes
|
||||
MCP remotos. Este terceiro subsistema **não é exclusivo do Cloud** — é infra-estrutura MCP
|
||||
genérica, e o Cloud é apenas um dos consumidores (via `EMCP_Tools_Gateway_Credential`, ver
|
||||
§2.2.6).
|
||||
|
||||
---
|
||||
|
||||
## 1. Stock Images — busca e sideload de imagens (Unsplash / Pexels / Pixabay)
|
||||
|
||||
### 1.1 `EMCP_Tools_Stock_Image_Abilities` — `includes/abilities/class-stock-image-abilities.php`
|
||||
|
||||
**Condição de registo** (excerto exacto de `class-ability-registrar.php`) — as 2 tools de
|
||||
provider registam-se sempre (não dependem do Elementor); a 3ª só quando o Elementor está
|
||||
activo:
|
||||
|
||||
```php
|
||||
// Stock-image provider tools (search-images + sideload-image) — pure WP core
|
||||
// (a stock-provider search + a Media Library sideload), no Elementor needed,
|
||||
// so they register on any site. add-stock-image (adds a widget) is gated below.
|
||||
$stock_images = new EMCP_Tools_Stock_Image_Abilities( $this->data, $this->factory );
|
||||
$stock_images->register_provider_tools();
|
||||
$this->ability_names = array_merge( $this->ability_names, $stock_images->provider_tool_names() );
|
||||
|
||||
// ---- Elementor-dependent groups: only when Elementor is active ----
|
||||
if ( $elementor_active ) {
|
||||
// ... (outros grupos) ...
|
||||
// Stock images: the add-stock-image widget tool (provider search + sideload
|
||||
// registered unconditionally above); this one adds an image widget so it
|
||||
// needs Elementor.
|
||||
$stock_images->register_widget_tool();
|
||||
$this->ability_names[] = 'emcp-tools/add-stock-image';
|
||||
}
|
||||
```
|
||||
|
||||
| Ability | `input_schema` (resumido) | O que faz | `permission_callback` | Anotações |
|
||||
|---|---|---|---|---|
|
||||
| `search-images` | `query` (str, obrig.), `provider` (enum `unsplash`\|`pexels`\|`pixabay`, opcional — omitido usa o 1º provider com chave configurada), `page` (int), `page_size` (int), `aspect_ratio` (enum `tall`\|`wide`\|`square`) | Pesquisa um provider de stock e devolve resultados normalizados: `{provider, result_count, page, page_count, results:[{id,title,url,thumbnail,width,height,creator,creator_url,license,license_url,attribution,source,foreign_landing_url}]}`. Delega em `EMCP_Tools_Stock_Image_Providers::resolve()` para escolher o cliente, depois num `run_search()` privado partilhado com `add-stock-image` que mapeia o resultado do provider para a forma estável do output_schema. | `check_read_permission()` → `current_user_can('edit_posts')` | `readonly:true, destructive:false, idempotent:true` |
|
||||
| `sideload-image` | `url` (str, obrig. — deve ser o URL **exacto** devolvido por `search-images`, nunca construído/editado à mão), `title`, `alt_text`, `caption`, `attribution`, `convert_webp` (bool) | Descarrega um URL externo para a Media Library via `EMCP_Tools_Url_Guard::safe_download()` (SSRF-guarded — ver §4.2) e devolve `{attachment_id, url, title}`. Antes de descarregar, se o URL for o endpoint de tracking do Unsplash (`api.unsplash.com/.../download`), resolve-o automaticamente para o URL de imagem real via `EMCP_Tools_Unsplash_Client::resolve_download()` — ver gotcha nº1 no blueprint. Em caso de falha, a mensagem de erro inclui uma sugestão accionável específica (URL da API do Unsplash / 404 / 401-403) para corrigir o comportamento de um agente que insista no mesmo URL errado. `convert_webp:false` desactiva a compressão/WebP do módulo Image Optimization só para este upload (via filtro `emcp_tools_optimize_attachment`). | `check_upload_permission()` → `current_user_can('upload_files')` | `readonly:false, destructive:false, idempotent:false` |
|
||||
| `add-stock-image` | `post_id` (int, obrig.), `parent_id` (str, obrig. — ID do container Elementor), `query` (str, obrig.), `provider`, `index` (int, 0=melhor resultado), `position` (int, -1=append), `image_size` (enum), `align` (enum `left`\|`center`\|`right`), `caption`, `aspect_ratio` (default `wide`), `alt_text`, `link_to` (enum `none`\|`file`\|`custom`), `convert_webp` | Compõe `search-images` → escolhe o resultado no `index` → dispara `trigger_download()` do provider (guideline Unsplash) → `sideload-image` → cria um widget `image` Elementor e insere-o em `parent_id` via `EMCP_Tools_Data::insert_element()` + `save_page_data()`. Devolve `{attachment_id, image_url, element_id, original_url, attribution, provider}`. Por omissão filtra por `aspect_ratio=wide` (paisagem) — melhor compatibilidade de layout. | `check_combined_permission()` → `edit_posts` **e** `upload_files` **e** (se `post_id` presente) `edit_post($post_id)` | `readonly:false, destructive:false, idempotent:false` |
|
||||
|
||||
### 1.2 `EMCP_Tools_Stock_Image_Providers` — `includes/class-stock-image-providers.php`
|
||||
|
||||
Registry + resolver estático, sem estado próprio. `map()` define os 3 providers e a
|
||||
**ordem de prioridade de fallback** (`unsplash` → `pexels` → `pixabay`) quando nenhum é
|
||||
pedido explicitamente. Cada entrada do mapa só tem `label` + nome da classe cliente.
|
||||
|
||||
- `has_key($id)` → delega em `{Client}::has_key()` estático.
|
||||
- `available()` → lista de ids com chave configurada, pela ordem do mapa.
|
||||
- `resolve($requested = '')` → devolve `[id, client]` ou `WP_Error`:
|
||||
- se `$requested` não vazio: valida que existe no mapa e que tem chave, senão erro
|
||||
`unknown_provider` / `no_api_key` (mensagem inclui o link para obter chave grátis).
|
||||
- se vazio: usa o primeiro de `available()`, ou `no_api_key` se nenhum estiver configurado.
|
||||
|
||||
Este é o único ponto de acoplamento entre as 3 tools e os 3 clientes — trocar/adicionar um
|
||||
provider é adicionar uma entrada ao `map()`.
|
||||
|
||||
### 1.3 Clientes HTTP dos 3 providers — `includes/class-{unsplash,pexels,pixabay}-client.php`
|
||||
|
||||
Todos seguem o mesmo contrato implícito (duck-typed, sem interface PHP formal):
|
||||
`const OPTION` (nome da option em `wp_options`), `access_key()` estático (constante PHP
|
||||
vence, senão a option — sempre decifrada via `EMCP_Tools_Secret::decrypt_if_needed()`,
|
||||
ver §4.1), `has_key()`, instância `search_images(array $params)`, instância
|
||||
`trigger_download(string $download_location)`.
|
||||
|
||||
| Cliente | Endpoint base | Auth header | Opção wp_options / constante PHP | `orientation`/aspect_ratio mapping | Particularidades |
|
||||
|---|---|---|---|---|---|
|
||||
| `EMCP_Tools_Unsplash_Client` | `https://api.unsplash.com` | `Authorization: Client-ID <key>` | `emcp_tools_unsplash_access_key` / `EMCP_TOOLS_UNSPLASH_ACCESS_KEY` | `wide→landscape`, `tall→portrait`, `square→squarish` | Único com `trigger_download()` REAL (guideline obrigatória da API Unsplash: disparar o `links.download_location` do resultado escolhido). `resolve_download()` resolve o endpoint de tracking `api.unsplash.com/photos/<id>/download` para o URL de imagem real (ver gotcha §Blueprint). `url` devolvido é `urls.regular` (~1080px), não `full`/`raw`. Attribution obrigatória: `creator_url` recebe sempre `?utm_source=emcp_tools&utm_medium=referral` (guideline Unsplash). 401→`invalid_key`; 403→`rate_limited` ("demo apps allow 50 requests/hour"). |
|
||||
| `EMCP_Tools_Pexels_Client` | `https://api.pexels.com/v1` | `Authorization: <key>` (SEM prefixo `Bearer`) | `emcp_tools_pexels_api_key` / `EMCP_TOOLS_PEXELS_API_KEY` | `wide→landscape`, `tall→portrait`, `square→square` | `trigger_download()` é no-op (Pexels não tem endpoint de tracking). `url` = `src.large2x` (fallback `large`/`original`). 401/403→`invalid_key`; 429→`rate_limited` ("200 requests/hour on the free tier"). |
|
||||
| `EMCP_Tools_Pixabay_Client` | `https://pixabay.com/api/` | **chave na query string** (`key=`), não em header | `emcp_tools_pixabay_api_key` / `EMCP_TOOLS_PIXABAY_API_KEY` | `wide→horizontal`, `tall→vertical` (sem `square` — Pixabay não tem essa orientação) | `per_page` restrito a 3-200 (min 3, diferente dos outros 2). Fixa sempre `image_type=photo&safesearch=true`. `trigger_download()` no-op; nota no cabeçalho do ficheiro: "Pixabay's terms require downloading/caching images rather than hotlinking — the sideload step satisfies that." 400/401→`invalid_key`; 429→`rate_limited` ("100 requests/minute"). |
|
||||
|
||||
Todos: `TIMEOUT=15`, `user-agent: Elementor-MCP/<versão> (WordPress/<versão>)`, resposta
|
||||
normalizada para a mesma forma de campo (`normalize_photo()` privado por classe) antes de
|
||||
chegar às abilities — as abilities nunca lidam com a forma nativa de nenhuma API.
|
||||
|
||||
---
|
||||
|
||||
## 2. EMCP Cloud — sincronização remota com `emcptools.com`
|
||||
|
||||
### 2.1 `EMCP_Tools_Cloud_Abilities` — `includes/abilities/class-cloud-abilities.php`
|
||||
|
||||
Cabeçalho do próprio ficheiro: *"Free tree. Registered only when the site is connected to
|
||||
EMCP Cloud (the Cloud module is active and a token bundle is stored)."* — ou seja, ao
|
||||
contrário da maioria dos outros grupos Pro-only deste plugin, **isto é uma feature Free**,
|
||||
só gated pelo estado de ligação, não por licença.
|
||||
|
||||
**Condição de registo** (excerto exacto):
|
||||
|
||||
```php
|
||||
// EMCP Cloud sync tools — only when the site is connected to a cloud account.
|
||||
if ( class_exists( 'EMCP_Tools_Cloud_Abilities' ) && class_exists( 'EMCP_Tools_Cloud_Module' )
|
||||
&& EMCP_Tools_Cloud_Module::is_enabled() && EMCP_Tools_Cloud::is_connected() ) {
|
||||
$cloud_sync = new EMCP_Tools_Cloud_Abilities();
|
||||
$cloud_sync->register();
|
||||
$this->ability_names = array_merge( $this->ability_names, $cloud_sync->get_ability_names() );
|
||||
}
|
||||
```
|
||||
|
||||
`EMCP_Tools_Cloud_Module::is_enabled()` lê `emcp_tools_active_modules` (módulo `cloud`,
|
||||
`default_active()=true` — activo por omissão em todos os sites). `EMCP_Tools_Cloud::is_connected()`
|
||||
exige um token bundle guardado (ver §2.2.1) — nos 3 sites do ecossistema Descomplicar
|
||||
verificados em `skill://emcp-tools`, nenhum está ligado a uma conta Cloud, logo estas 7
|
||||
tools nunca aparecem na prática nesse ambiente.
|
||||
|
||||
| Ability | `input_schema` (resumido) | O que faz | Anotações |
|
||||
|---|---|---|---|
|
||||
| `cloud-status` | `{}` | `GET /api/cloud/v1/me` — plano, limites e uso da conta ligada. | `readonly:true, destructive:false` |
|
||||
| `cloud-backup` | `kind` (enum `block`\|`widget`\|`snippet`, obrig.), `id` (int, obrig.) | Serializa um artefacto de sandbox local (ver doc 06) num "bundle" com checksum e faz `PUT /api/cloud/v1/artifacts`. | `readonly:false, destructive:false, idempotent:true` |
|
||||
| `cloud-list` | `kind` (opcional) | `GET /api/cloud/v1/artifacts[?kind=]` — lista artefactos já guardados na conta. | `readonly:true, destructive:false` |
|
||||
| `cloud-pull` | `artifact_uuid` (str, obrig.), `kind` (opcional) | `GET /api/cloud/v1/artifacts/{uuid}`, decodifica o bundle e importa-o como **novo draft local inactivo** (nunca substitui um artefacto existente). | `readonly:false, destructive:false` |
|
||||
| `cloud-config-sync` | `type` (enum `settings`\|`brand_kit`\|`tool_toggles`, obrig.), `direction` (enum `push`\|`pull`, obrig.), `data` (objecto, só para `push`) | Push/pull de um blob de configuração arbitrário via `/api/cloud/v1/config/{type}`. Usado internamente também por `EMCP_Tools_Settings_Sync` (§2.2.7) com `type=settings`. | `readonly:false, destructive:false` |
|
||||
| `cloud-marketplace-list` | `category` (opcional) | `GET /api/cloud/v1/marketplace[?category=]` — navega listings publicados (públicos, não exige ligação embora a tool em si exija `manage_options`). | `readonly:true, destructive:false` |
|
||||
| `cloud-marketplace-install` | `slug` (str, obrig.) | `POST /api/cloud/v1/marketplace/{slug}/install`, decodifica o bundle devolvido e importa como novo draft local. | `readonly:false, destructive:false` |
|
||||
|
||||
Todas as 7 exigem `current_user_can('manage_options')`. Todas passam pelo padrão comum
|
||||
`execute_*($input) → EMCP_Tools_Cloud_Sync::<método>() → is_wp_error() ? $r : (array) $r`.
|
||||
|
||||
### 2.2 Serviços de suporte (`includes/cloud/`)
|
||||
|
||||
#### 2.2.1 `EMCP_Tools_Cloud` — `class-cloud.php` (config + storage estático, sem rede)
|
||||
|
||||
- `base_url()` — constante `EMCP_TOOLS_CLOUD_URL` > option `emcp_tools_cloud_base_url` >
|
||||
default `https://emcptools.com`; filtrável (`emcp_tools_cloud_base_url`) para
|
||||
staging/self-host.
|
||||
- `site_uuid()` — UUID v4 estável por site, `wp_generate_uuid4()`, mintado lazy no
|
||||
primeiro `get_option()` e persistido em `emcp_tools_site_uuid`.
|
||||
- `save_connection(array $bundle)` / `get_connection()` — o bundle `{access_token,
|
||||
refresh_token, access_expires_at, client_id, connected_at}` é serializado em JSON e
|
||||
guardado **cifrado** (`EMCP_Tools_Secret::encrypt()`, §4.1) na option
|
||||
`emcp_tools_cloud_connection`.
|
||||
- `is_connected()` — `!empty(access_token) || !empty(refresh_token)`.
|
||||
- `SCOPES = 'openid cloud offline_access'` — pedidos ao IdP da Cloud (não confundir com o
|
||||
`SCOPE='mcp'` do servidor OAuth deste plugin, §3 — são dois sistemas OAuth distintos: um
|
||||
em que este site é **cliente** do IdP da Cloud, outro em que este site **é** o servidor).
|
||||
|
||||
#### 2.2.2 `EMCP_Tools_Cloud_Connect` — `class-cloud-connect.php` (cliente OAuth do site contra a Cloud como IdP)
|
||||
|
||||
Este site actua como **cliente PKCE público** contra o servidor OAuth de `emcptools.com`
|
||||
(o mesmo padrão — DCR → authorize → PKCE S256 → token — usado por `EMCP_Tools_OAuth_*`
|
||||
quando o papel é o inverso, ver §3). Sequência completa:
|
||||
|
||||
1. `register_client()` — DCR: `POST {cloud}/api/auth/oauth2/register` com
|
||||
`redirect_uris=[redirect_uri()]` (= `admin-post.php?action=emcp_tools_cloud_callback`),
|
||||
`token_endpoint_auth_method=none`, `client_name=bloginfo('name')`.
|
||||
2. `authorize_url($client_id, $verifier, $csrf)` — constrói o URL de autorização com
|
||||
`code_challenge` S256 derivado do `$verifier`, e `state` = base64url de um JSON
|
||||
`{site_uuid, name, csrf}` (o `$csrf` embutido no state protege contra CSRF sem precisar
|
||||
de um cookie de sessão — porque o browser navega para outro domínio e volta).
|
||||
3. `handle_connect()` (admin-post, nonce-protegido) — faz DCR, gera verifier+csrf, guarda
|
||||
num **transient de 600s** (`emcp_tools_cloud_pending`), e redireciona o browser. Como
|
||||
`wp_safe_redirect()` bloqueia hosts externos por omissão, adiciona o host da Cloud a
|
||||
`allowed_redirect_hosts` só para este redirect deliberado.
|
||||
4. `handle_callback()` (admin-post) — valida o `state` devolvido em **tempo constante**
|
||||
(`EMCP_Tools_OAuth_Util::secure_equals`) contra o CSRF guardado, troca o `code` por
|
||||
tokens (`exchange_code()`), e — se o utilizador marcou o opt-in de gateway no formulário
|
||||
— provisiona automaticamente uma `EMCP_Tools_Gateway_Credential` (§2.2.6), best-effort
|
||||
(uma falha aqui nunca transforma a ligação Cloud, já bem-sucedida, num erro visível).
|
||||
5. `refresh()` — ver bloco dedicado abaixo, é a peça mais elaborada do ficheiro.
|
||||
6. `handle_disconnect()` — desprovisiona o gateway, revoga remotamente
|
||||
(`revoke_remote()`), limpa a ligação local.
|
||||
|
||||
**`refresh()` — mitigação de corrida em rotação de refresh token.** Comentário extenso no
|
||||
próprio código explica o problema: o IdP da Cloud (Better Auth) **rota** o refresh token a
|
||||
cada uso — cada sucesso emite um novo refresh token e invalida o anterior. Duas requests
|
||||
WordPress concorrentes (segundo separador de admin, um heartbeat, uma chamada MCP) que
|
||||
ambas vejam o access token expirado apresentariam o MESMO refresh token; a primeira ganha e
|
||||
rota-o, a segunda é rejeitada com `invalid_grant` — e ingenuamente marcaria a ligação como
|
||||
"unhealthy", sobrescrevendo o bundle recém-rodado com o token morto (bug real que se
|
||||
manifestaria como "Reconnect needed" espúrio). Mitigação em 4 camadas:
|
||||
(1) mutex best-effort via `SELECT GET_LOCK()` do MySQL (`db_lock`/`db_unlock`, degradação
|
||||
graciosa para no-op se `$wpdb` ausente — testes unitários); (2) double-checked locking —
|
||||
volta a ler o bundle depois de obter o lock e sai cedo se outra request já refrescou;
|
||||
(3) uma rejeição de auth que coincide com uma rotação concorrente (refresh_token guardado
|
||||
mudou, ou o access token voltou a estar fresco) é tratada como **sucesso**, nunca
|
||||
sobrescreve o bundle bom; (4) falhas de rede/5xx são tratadas como transitórias e NUNCA
|
||||
marcam a ligação como unhealthy.
|
||||
|
||||
#### 2.2.3 `EMCP_Tools_Cloud_Http` — `class-cloud-http.php` (transporte fino)
|
||||
|
||||
`post_json()`, `post_form()`, `request($method,...)` — todos delegam num `send()` privado
|
||||
que usa `wp_remote_post`/`wp_remote_request` (timeout=20, sslverify=true). Tem um **seam de
|
||||
teste injectável** (`set_transport(callable)`) que permite mockar toda a rede em testes
|
||||
unitários sem tocar em `wp_remote_*`.
|
||||
|
||||
#### 2.2.4 `EMCP_Tools_Cloud_Sync` — `class-cloud-sync.php` (camada aplicacional)
|
||||
|
||||
Traduz operações de negócio para chamadas ao `Cloud_Client` autenticado (§2.2.5):
|
||||
`status()`, `list_remote()`, `backup()`, `pull()`, `push_config()`, `pull_config()`,
|
||||
`marketplace_list()`, `marketplace_install()`, `marketplace_publish()`,
|
||||
`marketplace_state()`, `push_update()`. Note-se `abilities(): EMCP_Tools_Sandbox_Cloud_Abilities`
|
||||
— um nome **confuso por semelhança**: esta classe (documentada em detalhe no doc 06, não
|
||||
aqui) não é uma ability MCP, é o helper local de serialização de bundle (`to_bundle()`,
|
||||
`apply_bundle()`, `uuid()`) partilhado entre as tools locais `export-sandbox-artifact`/
|
||||
`import-sandbox-artifact` (sem rede, doc 06) E as tools de rede `cloud-backup`/`cloud-pull`
|
||||
(este doc) — o mesmo formato de bundle serve os dois casos de uso.
|
||||
|
||||
`bulk_backup(array $kinds = [])` — o contraparte em massa de `backup()`; itera todos os
|
||||
posts das 3 CPTs de sandbox (`kind_post_types()`: snippet/widget/block →
|
||||
`emcp_php_snippet`/`emcp_widget`/`emcp_block`) e chama `backup()` um a um. **Não está
|
||||
exposta como MCP tool** (não há `cloud-bulk-backup` em `Cloud_Abilities`) — só é usada pela
|
||||
UI de admin.
|
||||
|
||||
`marketplace_publish()`/`marketplace_state()`/`push_update()` também não estão expostas
|
||||
como MCP tools — só `marketplace_list`/`marketplace_install` o estão; o resto é
|
||||
funcionalidade de admin (submissão de listings ao marketplace).
|
||||
|
||||
#### 2.2.5 `EMCP_Tools_Cloud_Client` — `class-cloud-client.php` (REST autenticado)
|
||||
|
||||
`valid_access_token()` — devolve o access token guardado, refrescando primeiro
|
||||
(`Cloud_Connect::refresh()`) se estiver a menos de `LEEWAY=60s` de expirar. `get/put/delete/
|
||||
request()` genéricos, todos `Authorization: Bearer <token>` + JSON. Nota no código: "Astro's
|
||||
form-CSRF guard exempts JSON, so no Origin header is needed here (unlike the token
|
||||
endpoint)" — ou seja o `Cloud_Connect` usa `Origin` header nos POSTs form-encoded ao token
|
||||
endpoint, mas este cliente usa corpo JSON e não precisa. `put_gateway_credential()`/
|
||||
`delete_gateway_credential()` são específicos do fluxo Gateway (§2.2.6).
|
||||
|
||||
#### 2.2.6 `EMCP_Tools_Gateway_Credential` — `class-gateway-credential.php` ("Phase 1 hosted multi-site gateway")
|
||||
|
||||
**Esta é a ponte directa entre o subsistema Cloud e o subsistema OAuth (§3).** Comentário no
|
||||
cabeçalho: *"Phase 1 of the hosted multi-site gateway: each site can self-issue a revocable
|
||||
refresh token against its OWN OAuth server, bound to a single, idempotently-provisioned
|
||||
client... Reuses the existing OAuth persistence layer (EMCP_Tools_OAuth_Store)."*
|
||||
|
||||
Mecanismo: o site cria (ou reusa, se já existir por nome+redirect_uris — mesmo padrão de
|
||||
dedup de `create_client()`, §3.3.5) um client OAuth estável chamado `"EMCP Gateway"` **no
|
||||
seu próprio servidor OAuth** (o de §3, não o da Cloud), e **auto-emite** um refresh token de
|
||||
longuíssima duração (`REFRESH_TTL = 315360000` — 10 anos, "efectivamente não-expirante")
|
||||
ligado a um utilizador WordPress específico, através de `EMCP_Tools_OAuth_Store::issue_token()`
|
||||
directamente (sem passar pelo fluxo `/authorize` normal — é um self-issue administrativo).
|
||||
Depois faz upload desse `{client_id, refresh_token, site_uuid, token_endpoint}` para a Cloud
|
||||
via `PUT /api/cloud/v1/gateway/credential`. Objectivo (Phase 2, ainda do lado da Cloud, não
|
||||
implementado neste build): a Cloud poder actuar como um **gateway multi-site** que troca
|
||||
este refresh token pelos seus próprios access tokens contra o servidor OAuth de CADA site
|
||||
ligado, sem o site ter de expor Application Passwords a um serviço terceiro.
|
||||
|
||||
`provision($user_id)` é best-effort e limpo: se o upload para a Cloud falhar, revoga
|
||||
imediatamente o token recém-emitido localmente (não deixa um token órfão vivo). `deprovision()`
|
||||
faz o inverso (delete remoto best-effort + revoke local incondicional — "offline-proof kill
|
||||
switch"). `handle_client_revoked($client_id)` é chamado pelo painel "Authorized Apps" do
|
||||
próprio site (revogação manual de qualquer client OAuth) para também limpar o lado Cloud se
|
||||
o client revogado for justamente o Gateway.
|
||||
|
||||
#### 2.2.7 `EMCP_Tools_Settings_Sync` — `class-settings-sync.php` (feature paga, "Turnkey settings sync")
|
||||
|
||||
`entitled()` — gate por entitlement `syncSettings` do plano Cloud ligado (lê
|
||||
`Cloud_Sync::status()`, cacheado estaticamente por request). `sync_keys()` — **allowlist
|
||||
explícita e filtrável** (`emcp_tools_settings_sync_keys`) de 10 chaves `wp_options`
|
||||
sincronizáveis: `emcp_tools_disabled_tools`, `elementor_mcp_disabled_tools` (chave legacy),
|
||||
`emcp_tools_active_modules`, `emcp_tools_dispatcher_mode`, `emcp_tools_strict_schemas`,
|
||||
`emcp_tools_content_mirror_enabled`, `emcp_tools_context_settings`,
|
||||
`emcp_tools_module_themer_force_render`, `emcp_tools_memory_require_approval`,
|
||||
`emcp_tools_memory_auto_summarize`. Comentário no cabeçalho: **"Never touches secrets or
|
||||
site-specific keys."** — nunca inclui tokens, a ligação Cloud, o UUID do site, logs de
|
||||
auditoria, ou estado de notificações. `apply()` só escreve chaves da allowlist (defesa
|
||||
contra um blob adulterado). `push()`/`pull_and_apply()` delegam em
|
||||
`Cloud_Sync::push_config()`/`pull_config()` com `type='settings'` — reusa o MESMO endpoint
|
||||
genérico que `cloud-config-sync` expõe como MCP tool, mas esta classe **não tem tool MCP
|
||||
própria** (é só UI de admin).
|
||||
|
||||
#### 2.2.8 `EMCP_Tools_Cloud_Module` — `includes/modules/class-cloud-module.php` (gating)
|
||||
|
||||
`id()='cloud'`, `tier()='free'`, `default_active()=true`. `register()` só chama
|
||||
`EMCP_Tools_Cloud_Connect::init()` (regista os 3 handlers `admin_post_*`). `is_enabled()`
|
||||
estático lê directamente a option `emcp_tools_active_modules` (evita depender do boot do
|
||||
módulo em `init:5`, porque as abilities registam-se em `wp_abilities_api_init`, que pode
|
||||
correr antes).
|
||||
|
||||
---
|
||||
|
||||
## 3. Servidor OAuth — infra-estrutura MCP genérica (`includes/oauth/`)
|
||||
|
||||
**Não é uma peça do EMCP Cloud.** É um servidor de autorização OAuth 2.1 completo
|
||||
(Authorization Code + PKCE obrigatório, só clientes públicos — sem client secret),
|
||||
implementado inteiramente dentro do WordPress, que serve de **alternativa ao Application
|
||||
Password** para qualquer cliente MCP remoto (Claude Desktop, VS Code, Cursor, CLIs como
|
||||
OpenClaw) se ligar ao endpoint MCP deste site sem o utilizador ter de gerar e colar uma
|
||||
Application Password manualmente. O único consumidor interno do plugin é
|
||||
`EMCP_Tools_Gateway_Credential` (§2.2.6) — tudo o resto é para clientes MCP externos
|
||||
genéricos.
|
||||
|
||||
### 3.1 Endpoints
|
||||
|
||||
| Documento/endpoint | Método | Rota | Tipo | RFC |
|
||||
|---|---|---|---|---|
|
||||
| Protected Resource Metadata | GET | `/.well-known/oauth-protected-resource` | **não-REST** (`parse_request`) | RFC 9728 |
|
||||
| Authorization Server Metadata | GET | `/.well-known/oauth-authorization-server` | **não-REST** (`parse_request`) | RFC 8414 |
|
||||
| Authorize + consentimento | GET/POST | `/emcp-oauth/authorize` | **não-REST** (`parse_request`, front-end normal) | RFC 6749 §4.1.1 |
|
||||
| Dynamic Client Registration | POST | `/wp-json/emcp-tools/oauth/v1/register` | REST (`permission_callback: __return_true`) | RFC 7591 |
|
||||
| Token (code exchange + refresh) | POST | `/wp-json/emcp-tools/oauth/v1/token` | REST (`__return_true`) | RFC 6749 |
|
||||
| Revoke | POST | `/wp-json/emcp-tools/oauth/v1/revoke` | REST (`__return_true`) | RFC 7009 |
|
||||
|
||||
### 3.2 `EMCP_Tools_OAuth_Server` — `class-oauth-server.php` (orquestrador)
|
||||
|
||||
- `is_available()` — `HTTPS OK` (via `is_ssl()`, `home_url()` a começar por `https://`, ou
|
||||
host local `localhost`/`127.0.0.1`/`::1`/`*.test`/`*.local`/`*.localhost`), filtrável via
|
||||
`emcp_tools_oauth_available` (para hosting atrás de um proxy que termina TLS antes do PHP).
|
||||
- `option_enabled()` — option `emcp_tools_oauth_enabled`: se nunca definida explicitamente,
|
||||
o default é **ON sempre que `is_available()`** (decisão de produto documentada em
|
||||
comentário: "OAuth sign-in is a free, core connectivity feature... enabled wherever it is
|
||||
available").
|
||||
- `is_enabled() = is_available() && option_enabled()` — o gate único que todo o resto do
|
||||
subsistema verifica.
|
||||
- Em `init:20`, se `is_available()`: instala as tabelas (`OAuth_Store::maybe_install()`) e
|
||||
agenda um WP-Cron diário de garbage-collection (`gc_hook`) — corre **mesmo que o toggle
|
||||
esteja OFF**, para limpar tokens residuais de quando esteve ligado. Se `is_enabled()`:
|
||||
regista os documentos de discovery, as rotas REST, o endpoint de authorize, e um filtro
|
||||
em `rest_post_dispatch` que emite o desafio `WWW-Authenticate` (§3.3.6).
|
||||
- `base_url()` usa `EMCP_Tools_Site_Context::rest_endpoint()` (não `rest_url()` cru) — honra
|
||||
um eventual override de "Server URL" no admin, para manter issuer/resource/token
|
||||
consistentes num site atrás de um domínio provisório/proxy.
|
||||
|
||||
### 3.3 Fluxo completo
|
||||
|
||||
#### 3.3.1 Discovery — `class-oauth-metadata.php`
|
||||
|
||||
`issuer()`/`resource()` usam `EMCP_Tools_Site_Context::public_base_url()` (mesmo motivo do
|
||||
`base_url()` acima). `path_matches()` aceita tanto o path exacto do well-known como uma
|
||||
**variante "resource-scoped"** (RFC 9728 §3.1) — ex.
|
||||
`/.well-known/oauth-protected-resource/wp-json/mcp/emcp-tools-server` — porque clientes MCP
|
||||
reais fazem esse pedido específico (comentário: "Match... the resource-scoped variant
|
||||
clients build by appending the resource path"; sem isto o discovery falha silenciosamente
|
||||
para esses clientes). Documento devolvido com `Access-Control-Allow-Origin: *` + cache 1h —
|
||||
é discovery público, sem dados sensíveis.
|
||||
|
||||
#### 3.3.2 Dynamic Client Registration — `class-oauth-clients.php`
|
||||
|
||||
`POST /register` totalmente aberto (`permission_callback: __return_true` — RFC 7591 prevê
|
||||
registo aberto para clientes públicos). Validação de `redirect_uris`: array não vazio, cada
|
||||
URI **https absoluto**, OU **http só em loopback** (`127.0.0.1`/`::1`/`localhost`, RFC 8252
|
||||
§7.3), OU um esquema custom de app privada (ex. `claude://`, RFC 8252 §7.1 — aceite sem
|
||||
restrição adicional, para clientes nativos). Nunca aceita URI com fragment component (RFC
|
||||
6749 §3.1.2). `client_name` default `'MCP Client'` se ausente. Resposta:
|
||||
`{client_id, client_name, redirect_uris, token_endpoint_auth_method:'none', grant_types:
|
||||
['authorization_code','refresh_token'], response_types:['code'], client_id_issued_at}`.
|
||||
|
||||
#### 3.3.3 Authorize + consentimento — `class-oauth-authorize.php`
|
||||
|
||||
**Decisão de design não óbvia**: este endpoint é servido como um pedido **front-end normal**
|
||||
via `parse_request` (prioridade 0) — **deliberadamente NÃO é uma rota REST**. Motivo (do
|
||||
próprio código): uma rota REST precisaria de um nonce que o browser do cliente MCP (que abre
|
||||
este URL numa aba/janela) não tem forma de fornecer, e a autenticação por cookie de sessão
|
||||
WordPress só funciona no fluxo normal de página.
|
||||
|
||||
`GET`: valida `client_id`+`redirect_uri` **PRIMEIRO**, antes de confiar em `redirect_uri`
|
||||
como alvo de qualquer redirect de erro — protecção contra open-redirect via um `client_id`
|
||||
malicioso ou desconhecido. Só depois valida `response_type=code` e
|
||||
`code_challenge_method=S256` (obrigatório; `plain` nunca é aceite). Se não autenticado,
|
||||
redirige para `wp_login_url()` com retorno para si mesmo. Exige `current_user_can('manage_options')`
|
||||
(filtrável via `emcp_tools_oauth_authorize_cap`) para poder aprovar — só administradores
|
||||
autorizam ligações MCP. Renderiza um ecrã de consentimento HTML autónomo (CSS inline, sem
|
||||
dependências do tema), mostrando o nome do client, o site, e o utilizador autenticado.
|
||||
|
||||
`POST`: valida nonce `_emcp_oauth_nonce` (acção `emcp_oauth_consent`), revalida
|
||||
client+redirect, se `action != 'approve'` redirige com `error=access_denied`. Se aprovado,
|
||||
emite o código via `OAuth_Store::issue_code()` (payload:
|
||||
`{client_id, user_id, redirect_uri, code_challenge, scopes}`) e redirige de volta com
|
||||
`?code=&state=`.
|
||||
|
||||
#### 3.3.4 Token + Revoke — `class-oauth-token.php`
|
||||
|
||||
`POST /token` dispatcher por `grant_type`:
|
||||
|
||||
- **`authorization_code`**: `OAuth_Store::consume_code()` (single-use, apaga o transient no
|
||||
consumo), valida `client_id` match, `redirect_uri` match, e PKCE S256
|
||||
(`hash_equals` sobre o `code_verifier` recomputado) — tudo em `validate_code_exchange()`
|
||||
(pura, testável isoladamente). Emite par access+refresh.
|
||||
- **`refresh_token`**: procura o token, valida `client_id` match, e **rota** o refresh token
|
||||
velho — mas NÃO com apagamento imediato:
|
||||
- `OAuth_Store::rotate_out_refresh($id, self::refresh_grace())` — o access token antigo
|
||||
ligado a esse refresh **não é cascade-deletado** (ao contrário de uma revogação
|
||||
explícita); sobrevive até expirar pela sua própria TTL. Justificação no código: "an
|
||||
in-flight MCP request may still be carrying [it]... invalidating it 401s those requests
|
||||
the instant the client refreshes... surfaced as connections dropping mid-chat" — cita
|
||||
RFC 6749 §1.5, que permite explicitamente este comportamento.
|
||||
- o refresh token rodado fica ainda utilizável por uma **janela de graça**
|
||||
(`REFRESH_GRACE=120s`, filtrável via `emcp_tools_oauth_refresh_grace` ou constante
|
||||
`EMCP_TOOLS_OAUTH_REFRESH_GRACE`) em vez de morrer instantaneamente — cobre o caso de um
|
||||
cliente cuja resposta do refresh anterior se perdeu na rede e reenvia o mesmo refresh
|
||||
token: em vez de `invalid_grant`, roda de novo com sucesso.
|
||||
- `ACCESS_TTL=3600s` (1h, filtrável/constante — útil para testar o fluxo de refresh em
|
||||
minutos em vez de esperar uma hora), `REFRESH_TTL=2592000s` (30 dias).
|
||||
|
||||
`POST /revoke` — **sempre devolve 200**, mesmo para um token desconhecido (RFC 7009,
|
||||
previne enumeração). Procura o token como access OU refresh e revoga a linha encontrada.
|
||||
|
||||
#### 3.3.5 Persistência — `class-oauth-store.php`
|
||||
|
||||
2 tabelas próprias criadas via `dbDelta` (idempotente, `DB_VERSION=2` — v2 mudou os
|
||||
timestamps para `BIGINT` "2038-safe" e adicionou índice em `refresh_of`):
|
||||
|
||||
```sql
|
||||
CREATE TABLE wp_emcp_oauth_clients (
|
||||
client_id VARCHAR(64) NOT NULL,
|
||||
client_name VARCHAR(191) NOT NULL,
|
||||
redirect_uris TEXT NOT NULL,
|
||||
created_by BIGINT UNSIGNED NOT NULL DEFAULT 0,
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (client_id)
|
||||
);
|
||||
CREATE TABLE wp_emcp_oauth_tokens (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
token_hash CHAR(64) NOT NULL, -- SHA-256, ÚNICO
|
||||
token_type VARCHAR(10) NOT NULL, -- 'access' | 'refresh'
|
||||
client_id VARCHAR(64) NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
scopes VARCHAR(191) NOT NULL DEFAULT '',
|
||||
expires_at BIGINT NOT NULL,
|
||||
refresh_of BIGINT UNSIGNED NULL DEFAULT NULL, -- liga o access token ao seu refresh
|
||||
created_at BIGINT NOT NULL,
|
||||
PRIMARY KEY (id), UNIQUE KEY token_hash (token_hash),
|
||||
KEY client_id (client_id), KEY user_id (user_id),
|
||||
KEY expires_at (expires_at), KEY refresh_of (refresh_of)
|
||||
);
|
||||
```
|
||||
|
||||
Tokens **nunca guardados em claro** — só o hash SHA-256 (sem sal; justificado porque os
|
||||
tokens já têm 32 bytes de entropia aleatória, um hash simples é indexável e adequado).
|
||||
Códigos de autorização vivem em **transients**, não na tabela (`CODE_TTL=300s` — 5 min, mais
|
||||
generoso que os ~60s típicos porque clientes CLI como OpenClaw exigem copy-paste manual do
|
||||
código e 60s era fácil de perder).
|
||||
|
||||
`create_client()` é **deduplicado**: `find_client_by_registration()` procura por
|
||||
nome+redirect_uris normalizados antes de criar — comentário: "MCP clients (Claude, Codex)
|
||||
re-run Dynamic Client Registration each time they connect; without this the clients table
|
||||
grows unbounded (one dead row per connect)". Seguro porque são clientes públicos (sem
|
||||
segredo) e os tokens ficam ligados ao utilizador autorizador, não ao client em si.
|
||||
|
||||
`gc()` — apaga tokens expirados, depois clients órfãos (sem nenhum token e criados há mais
|
||||
de `ORPHAN_CLIENT_GRACE=1 dia` — protege um client recém-registado que ainda não terminou o
|
||||
fluxo de autorização). `gc_throttled()` corre `gc()` no máximo 1x por
|
||||
`GC_THROTTLE_INTERVAL=900s` (transient-guarded), e é chamado directamente do **hot-path de
|
||||
validação Bearer** (§3.3.6) — "clean at validation time": qualquer site com tráfego MCP
|
||||
activo mantém as tabelas limpas em minutos, sem depender só do cron diário como backstop.
|
||||
|
||||
#### 3.3.6 Bearer — `class-oauth-bearer.php` (validação no transporte MCP)
|
||||
|
||||
Ligado como `transport_permission_callback` do servidor MCP (`Plugin::register_mcp_server()`,
|
||||
doc 00 §3) — **é aqui que o servidor OAuth se conecta ao resto do plugin.**
|
||||
`permission_callback($request)`:
|
||||
|
||||
1. Extrai o Bearer do header `Authorization` (com fallback para `HTTP_AUTHORIZATION` /
|
||||
`REDIRECT_HTTP_AUTHORIZATION` de `$_SERVER`, para hosts que despem o header antes de
|
||||
chegar ao PHP — comum em CGI/FastCGI).
|
||||
2. Se presente: `gc_throttled()`, procura o access token na store; se válido faz
|
||||
`wp_set_current_user()` e devolve `true`; se inválido/expirado devolve `false`
|
||||
(**401 fail-closed — não cai para outro método de auth**).
|
||||
3. Se **ausente**: cai para o comportamento default do adapter (`Application Password` /
|
||||
cookie), via o filtro `mcp_adapter_default_transport_permission_user_capability` —
|
||||
**os dois métodos de auth coexistem sem se excluir mutuamente.**
|
||||
|
||||
`maybe_challenge()` (hook `rest_post_dispatch`) — em qualquer resposta 401/403 na rota
|
||||
`mcp/emcp-tools-server`, adiciona `WWW-Authenticate: Bearer resource_metadata="<url>"`
|
||||
(RFC 9728 §5.1) — é o mecanismo pelo qual um cliente MCP genérico **descobre
|
||||
automaticamente**, sem configuração manual, que este site suporta OAuth e onde começar o
|
||||
fluxo de discovery.
|
||||
|
||||
#### 3.3.7 Primitivas puras — `class-oauth-util.php`
|
||||
|
||||
Zero dependências de WordPress/BD — testável isoladamente. `base64url_encode/decode`
|
||||
(RFC 4648 §5, sem padding), `generate_token()` (32 bytes aleatórios → 43 chars),
|
||||
`generate_code_verifier()` (mesmo gerador), `code_challenge_s256()`, `generate_client_id()`
|
||||
(`'emcp_' + 24 hex`), `hash_token()` (SHA-256 simples), `verify_pkce()` (só aceita `S256`,
|
||||
nunca `plain`; exige verifier de 43-128 chars RFC 7636 §4.1; comparação em tempo constante),
|
||||
`secure_equals()` (`hash_equals` wrapper), `redirect_uri_matches()` — match exacto OU a
|
||||
**excepção de loopback nativo** (RFC 8252 §7.3): para `http://127.0.0.1`/`http://[::1]`/
|
||||
`http://localhost`, a porta pode diferir entre o registado e o apresentado, porque
|
||||
aplicações nativas fazem bind a uma porta local efémera.
|
||||
|
||||
---
|
||||
|
||||
## 4. Serviços transversais (usados pelos 3 subsistemas)
|
||||
|
||||
### 4.1 `EMCP_Tools_Secret` — `includes/class-secret.php`
|
||||
|
||||
Encriptação simétrica genérica para segredos em repouso — usada pelas 3 chaves API de stock
|
||||
image (§1.3) e pelo bundle de ligação Cloud (§2.2.1). Chave de 32 bytes **derivada por-site**
|
||||
a partir de `AUTH_KEY`+`SECURE_AUTH_KEY` (salts do `wp-config.php`) via
|
||||
`sodium_crypto_generichash` (fallback SHA-256) — **nunca guardada na base de dados**, deriva
|
||||
sempre em runtime. Prefere `libsodium` (`secretbox`, bundled desde PHP 7.2), fallback
|
||||
`OpenSSL AES-256-GCM`. Prefixo `emcps1:` marca valores encriptados, permitindo pass-through
|
||||
transparente de valores legacy/constantes em claro (`decrypt()` devolve o valor original se
|
||||
não tiver o prefixo). `decrypt_if_needed()` desembrulha repetidamente (guard de 8 iterações)
|
||||
para tolerar um bug de dupla-encriptação (ex. um callback de sanitização do Settings API que
|
||||
dispare duas vezes).
|
||||
|
||||
**Consequência prática:** um dump da base de dados sozinho nunca expõe uma chave API de
|
||||
stock-image nem um refresh token Cloud — precisa também do `wp-config.php`.
|
||||
|
||||
### 4.2 `EMCP_Tools_Url_Guard` — `includes/class-url-guard.php`
|
||||
|
||||
Guarda anti-SSRF usada por `sideload-image` (§1.1) e por outras tools que descarregam
|
||||
conteúdo remoto. Duas APIs distintas para dois níveis de rigor:
|
||||
|
||||
- **`is_safe_remote_url()` + `safe_download()`** (versão "leniente", usada no
|
||||
`sideload-image`) — valida esquema http(s), usa `wp_http_validate_url()` do core como
|
||||
primeira camada, e complementa com um `gethostbyname()` + `filter_var(...,
|
||||
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)` porque o core **não cobre**
|
||||
`169.254.0.0/16` (link-local — inclui o endpoint de metadata de cloud
|
||||
`169.254.169.254`, um alvo clássico de SSRF) nem endereços IPv6 internos.
|
||||
`safe_download()` força `reject_unsafe_urls=true` (WP_Http revalida CADA hop de
|
||||
redirect) e limita `redirection` a 2 hops.
|
||||
- **`validate()`** (versão "estrita", adicionada em 3.2.0 para a tool de AI Chat
|
||||
`web_fetch` — não coberta por esta série de docs, é Pro) — resolve **TODOS** os
|
||||
registos A **e** AAAA de um host (não só o primeiro, como `gethostbyname()` faz) e
|
||||
bloqueia se **qualquer** um for privado — cobre o caso de um host que publica um
|
||||
endereço público e um interno em simultâneo. Bloqueia também portas fora de
|
||||
`{80,443}` e URLs com credenciais embutidas (`user:pass@host`). Listas CIDR bloqueadas
|
||||
explícitas para IPv4 (`0.0.0.0/8`, `10/8`, `100.64/10` CGNAT, `127/8`, `169.254/16`,
|
||||
`172.16/12`, `192.168/16`, `224/4` multicast, `240/4` reservado) e IPv6
|
||||
(`::/128`, `::1/128`, `fc00::/7` ULA, `fe80::/10` link-local), incluindo o caso de
|
||||
endereço IPv4-mapped em IPv6 (`::ffff:127.0.0.1`).
|
||||
|
||||
Nota de honestidade técnica no próprio código: mesmo a versão estrita mantém uma **janela
|
||||
TOCTOU** entre a validação DNS e o connect TCP real (DNS rebinding) — WordPress liga por
|
||||
nome de host, não por IP pinado; fechar completamente exigiria `CURLOPT_RESOLVE`.
|
||||
|
||||
---
|
||||
|
||||
## Blueprint para réplica
|
||||
|
||||
**Reutilizar quase 1:1:**
|
||||
|
||||
1. **O servidor OAuth inteiro (§3)** — ~1500 linhas de infra-estrutura MCP genérica, sem
|
||||
NENHUMA dependência do EMCP Cloud. É a peça de maior valor deste documento para uma
|
||||
réplica: dá autenticação Bearer remota sem obrigar o utilizador a gerar/colar
|
||||
Application Passwords manualmente, e resolve sozinho toda a dança de discovery RFC
|
||||
9728/8414 que clientes MCP modernos esperam.
|
||||
2. **`EMCP_Tools_Secret` (§4.1)** — padrão de encriptação at-rest derivado dos salts do
|
||||
próprio site é elegante e trivial de copiar; não precisa de gestão de chave adicional
|
||||
nem de um KMS externo.
|
||||
3. **`EMCP_Tools_Url_Guard` (§4.2)** — a distinção entre modo "leniente" (sideload) e
|
||||
"estrito" (fetch de conteúdo para um modelo) é uma lição de segurança valiosa por si só;
|
||||
copiar tal e qual, incluindo a lista de CIDRs bloqueados e a nota honesta sobre o TOCTOU
|
||||
window de DNS rebinding.
|
||||
4. **`EMCP_Tools_Stock_Image_Providers` (§1.2)** — o padrão registry+resolver com fallback
|
||||
automático por ordem de prioridade é um bom desenho, trivialmente extensível a mais
|
||||
providers (ex. Openverse, que este plugin usava antes da v3.1.0 segundo os comentários
|
||||
dos clientes).
|
||||
5. **O padrão de rotação de refresh token com janela de graça** (`OAuth_Token::REFRESH_GRACE`,
|
||||
§3.3.4) **e o padrão de mutex/anti-corrida** (`Cloud_Connect::refresh()`, §2.2.2) — ambos
|
||||
resolvem bugs reais de concorrência já vividos em produção pelo autor original; aplicáveis
|
||||
a qualquer implementação própria de OAuth, tanto do lado servidor como cliente.
|
||||
|
||||
**Simplificar:**
|
||||
|
||||
- **Todo o subsistema Cloud (§2.2)** está acoplado a um SaaS de terceiros
|
||||
(`emcptools.com`) que uma réplica não vai ter por omissão. Vale a pena extrair só o
|
||||
**padrão arquitectural**, não o código ligado ao domínio: (a) `Cloud_Connect` é um bom
|
||||
template de "como SER cliente PKCE de outro servidor OAuth" (complementar ao §3, que
|
||||
documenta "como SER o servidor"); (b) o padrão de bundle+checksum para export/import de
|
||||
artefactos é reutilizável mesmo sem nuvem nenhuma, só para backup/restore local (ver
|
||||
doc 06). Sem um serviço cloud próprio planeado, esta secção inteira (~1200 linhas) é
|
||||
dispensável.
|
||||
- **`EMCP_Tools_Gateway_Credential` (§2.2.6)** é explicitamente uma feature "Phase 1"
|
||||
incompleta do lado deles (o próprio comentário do código diz "Phase 2 concern" para o
|
||||
lado da Cloud) — não vale a pena replicar sem um caso de uso concreto de "gateway
|
||||
multi-site" próprio.
|
||||
|
||||
**Deixar de fora:**
|
||||
|
||||
- `cloud-marketplace-*` (list/install/publish) — depende inteiramente de um marketplace
|
||||
SaaS de terceiros.
|
||||
- `EMCP_Tools_Settings_Sync` — feature paga ("Turnkey settings sync"), sem sentido numa
|
||||
réplica sem modelo de billing.
|
||||
|
||||
**Gotchas não óbvios (achados de código, não de documentação):**
|
||||
|
||||
1. **Heurística Unsplash download-tracking.** Agentes de IA passam frequentemente a URL
|
||||
`api.unsplash.com/photos/<id>/download` (parece um URL de imagem, mas é o endpoint de
|
||||
tracking que exige API key e devolve 401 sem ela) em vez do URL directo devolvido por
|
||||
`search-images`. O código resolve isto automaticamente (`resolve_download()`, §1.3) e
|
||||
`sideload-image` tem lógica dedicada de mensagem de erro para este caso específico — o
|
||||
próprio comentário chama-lhe "a common weak-model loop". Vale a pena replicar esta
|
||||
heurística de correcção accionável de erro, e generalizar o padrão (detectar a classe
|
||||
de erro mais comum de um agente e devolver uma sugestão específica, não só a mensagem
|
||||
crua da API).
|
||||
2. **Rotação de refresh token que se auto-sabota sem cuidado.** O comentário em
|
||||
`Cloud_Connect::refresh()` explica que um IdP com rotação estrita (Better Auth) invalida
|
||||
TODA a família de tokens se detectar reuso de um refresh token já rodado — o que
|
||||
transforma uma simples corrida entre duas requests concorrentes numa desconexão
|
||||
completa e forçada ("Reconnect needed"). A mitigação de 4 camadas (mutex, double-check,
|
||||
tratar rejeição concorrente como sucesso, nunca marcar unhealthy em falha transitória)
|
||||
é um padrão geral aplicável a qualquer cliente OAuth contra qualquer IdP com rotação.
|
||||
3. **A mesma lição, ao contrário, no servidor próprio.** `OAuth_Store::rotate_out_refresh()`
|
||||
com `REFRESH_GRACE` explicitamente NÃO apaga o access token antigo na rotação (evita 401
|
||||
a meio de uma conversa MCP em curso) e mantém o refresh token rodado utilizável por 120s
|
||||
extra (evita 401 num retry de resposta perdida). Cita RFC 6749 §1.5 como justificação —
|
||||
é o tipo de detalhe que só se aprende com utilizadores reais a queixarem-se de ligações a
|
||||
cair a meio.
|
||||
4. **Dedup de client OAuth em DCR.** `create_client()` reusa um client existente com o
|
||||
mesmo nome+redirect_uris em vez de criar sempre um novo — sem isto, clientes MCP que
|
||||
refazem DCR a cada ligação (confirmado no código: "Claude, Codex re-run Dynamic Client
|
||||
Registration each time they connect") fariam crescer a tabela de clients sem limite.
|
||||
Aplica-se tanto ao servidor OAuth do plugin (§3.3.5) como ao `Gateway_Credential`
|
||||
(§2.2.6), que idem reusa o client `"EMCP Gateway"` por nome.
|
||||
5. **`/authorize` como request front-end, não REST.** Decisão deliberada e não óbvia
|
||||
(§3.3.3) — uma rota REST exigiria um nonce que o browser do cliente MCP não tem como
|
||||
fornecer, e cookie auth de sessão só funciona no fluxo normal de navegação de página.
|
||||
Preservar esta decisão tal e qual numa réplica.
|
||||
6. **Ordem de validação anti-open-redirect.** Em `OAuth_Authorize::handle_get()`,
|
||||
`client_id`+`redirect_uri` são validados **antes** de qualquer outra coisa,
|
||||
especificamente para nunca usar um `redirect_uri` não confiável como alvo de um
|
||||
redirect de erro — protecção directa contra um vector de open-redirect via um
|
||||
`client_id` malicioso ou inexistente.
|
||||
7. **`normalize_result()` (doc 00 §5) protege também as tools Cloud.** O output de
|
||||
`cloud-status`/`cloud-list` (devolvido "as array" directamente do JSON decodificado da
|
||||
API remota) passa pelo mesmo wrapper de normalização de resultado do ability registrar
|
||||
— o que protege contra a mesma classe de bug documentada para o WooCommerce
|
||||
(`report-products-totals` devolve array de topo) caso a API da Cloud alguma vez faça o
|
||||
mesmo. Confirma que a camada de `class-schema-compat.php` deve ser aplicada
|
||||
**universalmente**, mesmo a tools que parecem "seguras" por delegarem numa API JSON
|
||||
bem-comportada.
|
||||
|
||||
---
|
||||
|
||||
## Fonte
|
||||
|
||||
Leitura directa (19-08-2026) de, em `/home/ealmeida/emanuelalmeida.pt/wp-content/plugins/emcp-tools/`:
|
||||
|
||||
- `includes/abilities/class-ability-registrar.php` (gating de todos os grupos)
|
||||
- `includes/abilities/class-stock-image-abilities.php`
|
||||
- `includes/class-stock-image-providers.php`
|
||||
- `includes/class-unsplash-client.php`
|
||||
- `includes/class-pexels-client.php`
|
||||
- `includes/class-pixabay-client.php`
|
||||
- `includes/abilities/class-cloud-abilities.php`
|
||||
- `includes/cloud/class-cloud.php`
|
||||
- `includes/cloud/class-cloud-connect.php`
|
||||
- `includes/cloud/class-cloud-http.php`
|
||||
- `includes/cloud/class-cloud-sync.php`
|
||||
- `includes/cloud/class-gateway-credential.php`
|
||||
- `includes/cloud/class-cloud-client.php`
|
||||
- `includes/cloud/class-settings-sync.php`
|
||||
- `includes/modules/class-cloud-module.php`
|
||||
- `includes/oauth/class-oauth-metadata.php`
|
||||
- `includes/oauth/class-oauth-authorize.php`
|
||||
- `includes/oauth/class-oauth-server.php`
|
||||
- `includes/oauth/class-oauth-store.php`
|
||||
- `includes/oauth/class-oauth-bearer.php`
|
||||
- `includes/oauth/class-oauth-util.php`
|
||||
- `includes/oauth/class-oauth-clients.php`
|
||||
- `includes/oauth/class-oauth-token.php`
|
||||
- `includes/class-secret.php`
|
||||
- `includes/class-url-guard.php`
|
||||
|
||||
Cruzado com `docs/00-ARQUITECTURA.md` (arquitectura geral do plugin, cadeia de arranque,
|
||||
`emcp_tools_register_ability()`) e `skill://emcp-tools` (auditoria de postura de segurança,
|
||||
16-08-2026) para contexto de gating e postura por site.
|
||||
Reference in New Issue
Block a user