discovery hint in the page head. On multisite this resolves * per subsite via home_url(), which is the primary discovery path for subdirectory subsites * (they cannot own the domain-root /.well-known anchor). */ public static function discovery_link(): void { if (!Ai2Web_Settings::get('enabled', true)) { return; } echo '' . "\n"; } /** * Advertise the manifest via an HTTP `Link` header (so non-HTML clients discover it without * parsing a page) and carry the declared usage policy as a Content-Signal header. Uses the * cheap policy stub, never a full manifest build, because this runs on every front-end * request. Both are filterable off. */ public static function send_discovery_headers(): void { if (is_admin() || headers_sent() || !Ai2Web_Settings::get('enabled', true)) { return; } if (!apply_filters('ai2web_send_discovery_headers', true)) { return; } $stub = Ai2Web_Manifest::policy_stub(); header('Link: ' . Ai2Web_Export::discovery_link_header($stub), false); $signals = Ai2Web_Export::content_signals($stub); if ($signals !== null) { header('Content-Signal: ' . $signals); } } /** * Append the AI2Web usage-policy projection to robots.txt. Additive only: the site's own * rules are never touched, and the whole projection can be turned off with the filter. * * @param string $output * @param mixed $public */ public static function robots_txt($output, $public): string { $output = (string) $output; if (!Ai2Web_Settings::get('enabled', true) || !apply_filters('ai2web_project_robots_txt', true)) { return $output; } return rtrim($output, "\n") . "\n\n" . Ai2Web_Export::robots_txt(Ai2Web_Manifest::policy_stub()); } /** True when the caller explicitly prefers Markdown over JSON. */ private static function wants_markdown(): bool { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- content negotiation on a public read. $accept = isset($_SERVER['HTTP_ACCEPT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_ACCEPT'])) : ''; return $accept !== '' && stripos($accept, 'text/markdown') !== false; } public static function add_rewrite_rules(): void { add_rewrite_rule('^ai2w/?$', 'index.php?ai2web_route=manifest', 'top'); add_rewrite_rule('^ai2w/(.+)/?$', 'index.php?ai2web_route=$matches[1]', 'top'); add_rewrite_tag('%ai2web_route%', '([^&]+)'); } /** * We match on the raw path so /.well-known/ai2w works without a rewrite rule. */ private const MAX_BODY = 262144; // 256 KB request-body cap (DoS guard). /** WordPress user id authenticated for this request via an OAuth2 bearer token (0 = anonymous). */ private static int $token_user_id = 0; /** The user authenticated via OAuth2 bearer token for this request, or 0 if anonymous. */ public static function token_user_id(): int { return self::$token_user_id; } public function maybe_handle(WP $wp): void { $uri = isset($_SERVER['REQUEST_URI']) ? sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])) : '/'; $path = '/' . trim((string) wp_parse_url($uri, PHP_URL_PATH), '/'); // Normalise the path relative to this site's home base, so it also works when WordPress // lives in a subdirectory and for subdirectory multisite subsites (e.g. /shop/ai2w). // Domain-root paths like /.well-known/ai2w do not start with the base and pass through. $base = '/' . trim((string) wp_parse_url(home_url('/'), PHP_URL_PATH), '/'); if ($base !== '/' && strpos($path, $base) === 0) { $path = '/' . ltrim(substr($path, strlen($base)), '/'); } $known = $path === '/ai2w' || $path === '/.well-known/ai2w' || $path === '/.well-known/agent.json' || $path === '/agent.json' || $path === '/llms.txt' || $path === '/.well-known/oauth-authorization-server' || $path === '/.well-known/oauth-protected-resource' || ($path === '/.well-known/agent-card.json' && Ai2Web_AP2::enabled()) || strncmp($path, '/ai2w/', 6) === 0; if (!$known) { return; } if (!Ai2Web_Settings::get('enabled', true)) { return; // endpoints disabled in settings; let WordPress serve normally } $method = isset($_SERVER['REQUEST_METHOD']) ? strtoupper(sanitize_key(wp_unslash($_SERVER['REQUEST_METHOD']))) : 'GET'; // Authenticate via an AI2Web OAuth2 bearer token if one is presented. This is additive: // requests without a token still work under the anonymous ownership + approval model. // We record the authenticated user id but deliberately do NOT call wp_set_current_user(): // that would broaden the whole request to the token owner's capabilities. User-scoped // actions should read self::token_user_id() and enforce their own per-resource checks. $bearer = self::bearer_token(); if ($bearer !== '') { self::$token_user_id = Ai2Web_OAuth::user_for_token($bearer); } $body = null; if (in_array($method, ['POST', 'PUT', 'PATCH'], true)) { $raw = file_get_contents('php://input'); if ($raw !== false && strlen($raw) > self::MAX_BODY) { status_header(413); wp_send_json(['error' => ['code' => 'payload_too_large', 'retryable' => false]], 413); } // json_decode does not sanitize; recursively clean keys and scalar values before the // body is routed or forwarded to action filters (ai2web_acp_*, ai2web_ap2_*). Well-formed // JSON/base64url payloads (incl. signed AP2 mandates) pass through unchanged. $body = $raw ? self::sanitize_body(json_decode($raw, true)) : null; } // MCP uses its own transport (SSE); it emits the response and exits. if ($path === '/ai2w/mcp') { if (Ai2Web_MCP::enabled()) { Ai2Web_MCP::handle($method, $body); } status_header(404); wp_send_json(['error' => ['code' => 'not_found', 'message' => 'MCP endpoint is disabled.']], 404); } // OAuth2 authorize endpoint: renders a consent screen or redirects, then exits. if ($path === '/ai2w/oauth/authorize') { if (Ai2Web_OAuth::available()) { Ai2Web_OAuth::authorize($method); } status_header(404); wp_send_json(['error' => ['code' => 'not_found', 'message' => 'OAuth is disabled.']], 404); } $manifest = Ai2Web_Manifest::build(); // llms.txt is a plain-text projection (not JSON), so it is emitted directly. if ($path === '/llms.txt') { if ($method !== 'GET') { status_header(405); wp_send_json(['error' => ['code' => 'invalid_request', 'message' => 'Use GET for llms.txt.']], 405); } status_header(200); header('content-type: text/plain; charset=utf-8'); header('access-control-allow-origin: *'); echo Ai2Web_Export::llms_txt($manifest); // phpcs:ignore WordPress.Security.EscapeOutput exit; } // Markdown content negotiation: agents that ask for text/markdown get the content // projection as Markdown instead of JSON. `vary: accept` keeps caches honest. if ($path === '/ai2w/content' && $method === 'GET' && self::wants_markdown()) { status_header(200); header('content-type: text/markdown; charset=utf-8'); header('access-control-allow-origin: *'); header('vary: accept'); echo Ai2Web_Export::content_markdown(Ai2Web_Manifest::content(), $manifest); // phpcs:ignore WordPress.Security.EscapeOutput -- text/markdown projection of public content, not HTML. exit; } $res = $this->route($manifest, $method, $path, $body); // Analytics (RFC-0016): personal-data-free, server-side event per interaction. Local-first. Ai2Web_Analytics::record_for($path, $method, is_array($body) ? $body : [], $res); // Emit CORS/custom headers; wp_send_json sets status + JSON content-type and exits. foreach ($res['headers'] as $k => $v) { if (stripos($k, 'content-type') === 0) { continue; } header($k . ': ' . $v); } if ($res['body'] === null) { status_header($res['status']); exit; } wp_send_json($res['body'], $res['status']); } /** * Recursively sanitize a decoded request body. Sanitizes string keys and scalar string values * with sanitize_text_field; leaves ints, floats, bools and null untouched (so amounts and flags * keep their type). Compact base64url tokens and signed mandates contain no tags/whitespace, so * they are unchanged; if a signed field were tampered with HTML, sanitizing it makes the * signature check fail, which is the correct outcome. * * @param mixed $data * @return mixed */ private static function sanitize_body(mixed $data): mixed { if (is_array($data)) { $out = []; foreach ($data as $k => $v) { $key = is_string($k) ? sanitize_text_field($k) : $k; $out[$key] = self::sanitize_body($v); } return $out; } if (is_string($data)) { return sanitize_text_field($data); } return $data; // int, float, bool, null preserved as-is } /** Extract a Bearer token from the Authorization header (handles Apache's redirect variant). */ private static function bearer_token(): string { $header = ''; if (isset($_SERVER['HTTP_AUTHORIZATION'])) { $header = sanitize_text_field(wp_unslash($_SERVER['HTTP_AUTHORIZATION'])); } elseif (isset($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])) { $header = sanitize_text_field(wp_unslash($_SERVER['REDIRECT_HTTP_AUTHORIZATION'])); } elseif (function_exists('getallheaders')) { foreach ((array) getallheaders() as $k => $v) { if (strtolower((string) $k) === 'authorization') { $header = sanitize_text_field((string) $v); break; } } } return stripos($header, 'Bearer ') === 0 ? trim(substr($header, 7)) : ''; } /** * Minimal router mirroring Ai2Web\Server (kept self-contained so the plugin needs no Composer). * * @param array $manifest * @return array{status:int,headers:array,body:mixed} */ private function route(array $manifest, string $method, string $path, mixed $body): array { $cors = [ 'access-control-allow-origin' => '*', 'access-control-allow-methods' => 'GET, POST, OPTIONS', 'access-control-allow-headers' => 'content-type, authorization', ]; $json = static fn(int $s, mixed $b): array => ['status' => $s, 'headers' => array_merge(['content-type' => 'application/json; charset=utf-8'], $cors), 'body' => $b]; $error = static fn(int $s, string $c, string $m): array => $json($s, ['error' => ['code' => $c, 'message' => $m, 'retryable' => false]]); // Emit a {status,body} result (from a sub-handler) as a router response. $emit = static fn(array $r): array => $json((int) $r['status'], $r['body']); if ($method === 'OPTIONS') { return ['status' => 204, 'headers' => $cors, 'body' => null]; } if ($path === '/.well-known/ai2w') { // Derive from the site's configured URL, never the request Host header (host-spoofing guard). return $json(200, ['ai2w' => home_url('/ai2w')]); } if ($path === '/.well-known/oauth-authorization-server') { return Ai2Web_OAuth::available() ? $json(200, Ai2Web_OAuth::metadata()) : $error(404, 'not_found', 'OAuth is not enabled.'); } // RFC 9728: tells an MCP client which authorization server guards this resource. if ($path === '/.well-known/oauth-protected-resource') { $doc = Ai2Web_Export::oauth_protected_resource($manifest); return $doc === null ? $error(404, 'not_found', 'OAuth is not enabled.') : $json(200, $doc); } if ($path === '/.well-known/agent.json' || $path === '/agent.json') { if ($method !== 'GET') { return $error(405, 'invalid_request', 'Use GET for agent.json.'); } return $json(200, Ai2Web_Export::agent_json($manifest)); } // A2A agent-card discovery location, served when the AP2 surface is enabled. if ($path === '/.well-known/agent-card.json') { if (!Ai2Web_AP2::enabled()) { return $error(404, 'not_found', 'No agent card.'); } return $method === 'GET' ? $json(200, Ai2Web_AP2::agent_card()) : $error(405, 'invalid_request', 'Use GET for the agent card.'); } if ($path === '/ai2w') { if ($method !== 'GET') { return $error(405, 'invalid_request', 'Use GET for the manifest.'); } return $json(200, $manifest); } if ($path === '/ai2w/negotiate') { $supports = is_array($body) ? ($body['agent']['supports'] ?? $body['supports'] ?? $body) : []; return $json(200, Ai2Web_Manifest::negotiate($manifest, is_array($supports) ? $supports : [])); } if ($path === '/ai2w/content') { return $json(200, Ai2Web_Manifest::content()); } if ($path === '/ai2w/search') { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public read-only search query, no state change. $get_q = isset($_GET['q']) ? sanitize_text_field(wp_unslash($_GET['q'])) : ''; $q = is_array($body) && isset($body['query']) ? (string) $body['query'] : $get_q; return $json(200, Ai2Web_Manifest::search($q)); } if ($path === '/ai2w/products') { return $json(200, Ai2Web_WooCommerce::products()); } if ($path === '/ai2w/events') { return $json(200, ['types' => Ai2Web_WooCommerce::event_types()]); } if ($path === '/ai2w/agent') { if ($method !== 'POST') { return $error(405, 'invalid_request', 'Use POST with a query for the agent service.'); } $r = Ai2Web_Agent::handle(is_array($body) ? $body : []); return $json($r['status'], $r['body']); } if ($path === '/ai2w/oauth/token') { if ($method !== 'POST') { return $error(405, 'invalid_request', 'Use POST for the token endpoint.'); } if (!Ai2Web_OAuth::available()) { return $error(404, 'not_found', 'OAuth is not enabled.'); } // Token endpoints are usually form-encoded; accept that or a JSON body. // phpcs:disable WordPress.Security.NonceVerification -- OAuth token exchange is authenticated by the auth code + PKCE verifier, not a WP nonce. $src = (is_array($body) && !empty($body)) ? $body : $_POST; $input = [ 'grant_type' => isset($src['grant_type']) ? sanitize_text_field(wp_unslash($src['grant_type'])) : '', 'code' => isset($src['code']) ? sanitize_text_field(wp_unslash($src['code'])) : '', 'redirect_uri' => isset($src['redirect_uri']) ? esc_url_raw(wp_unslash($src['redirect_uri'])) : '', 'code_verifier' => isset($src['code_verifier']) ? sanitize_text_field(wp_unslash($src['code_verifier'])) : '', 'client_id' => isset($src['client_id']) ? sanitize_text_field(wp_unslash($src['client_id'])) : '', ]; // phpcs:enable WordPress.Security.NonceVerification header('Cache-Control: no-store'); // OAuth2: token responses must not be cached header('Pragma: no-cache'); $r = Ai2Web_OAuth::token($input); return $json($r['status'], $r['body']); } if (preg_match('#^/ai2w/actions/([a-z0-9_-]+)$#i', $path, $mm)) { $name = str_replace('-', '_', strtolower($mm[1])); $input = is_array($body) ? $body : []; $r = Ai2Web_Actions::run($name, $input); return $json($r['status'], $r['body']); } // ACP (Agentic Commerce Protocol) product feed. if ($path === '/ai2w/acp/feed') { if ($method !== 'GET') { return $error(405, 'invalid_request', 'Use GET for the ACP feed.'); } if (!Ai2Web_ACP::enabled()) { return $error(404, 'not_found', 'ACP checkout is not enabled.'); } return $json(200, ['version' => Ai2Web_ACP::SPEC_VERSION, 'products' => Ai2Web_ACP::feed()]); } // ACP checkout sessions: /ai2w/acp/checkout_sessions[/{id}[/complete|/cancel]]. if (preg_match('#^/ai2w/acp/checkout_sessions(?:/([A-Za-z0-9_]+))?(?:/(complete|cancel))?$#', $path, $cm)) { $id = ($cm[1] ?? '') !== '' ? $cm[1] : null; $action = ($cm[2] ?? '') !== '' ? $cm[2] : null; $r = Ai2Web_ACP::dispatch($method, $id, $action, is_array($body) ? $body : []); return ['status' => $r['status'], 'headers' => array_merge(['content-type' => 'application/json; charset=utf-8'], $cors, $r['headers'] ?? []), 'body' => $r['body']]; } // NLWeb (nlweb.ai) projection: a natural-language `ask` query over the site's content. if ($path === '/ai2w/nlweb/ask') { if (!Ai2Web_Nlweb::enabled()) { return $error(404, 'not_found', 'NLWeb is not enabled.'); } if ($method !== 'GET' && $method !== 'POST') { return $error(405, 'invalid_request', 'Use GET or POST for the NLWeb ask endpoint.'); } $r = Ai2Web_Nlweb::ask($method, is_array($body) ? $body : []); return $json($r['status'], $r['body']); } // AP2 (Agent Payments Protocol) merchant surface. if (strncmp($path, '/ai2w/ap2', 9) === 0) { if (!Ai2Web_AP2::enabled()) { return $error(404, 'not_found', 'AP2 is not enabled.'); } if ($path === '/ai2w/ap2/agent-card') { return $method === 'GET' ? $json(200, Ai2Web_AP2::agent_card()) : $error(405, 'invalid_request', 'Use GET for the agent card.'); } if ($path === '/ai2w/ap2/jwks') { return $method === 'GET' ? $json(200, Ai2Web_AP2::jwks()) : $error(405, 'invalid_request', 'Use GET for the JWKS.'); } if ($path === '/ai2w/ap2/cart') { return $method === 'POST' ? $emit(Ai2Web_AP2::dispatch_cart(is_array($body) ? $body : [])) : $error(405, 'invalid_request', 'Use POST with an IntentMandate.'); } if ($path === '/ai2w/ap2/payment') { return $method === 'POST' ? $emit(Ai2Web_AP2::dispatch_payment(is_array($body) ? $body : [])) : $error(405, 'invalid_request', 'Use POST with a PaymentMandate.'); } if ($path === '/ai2w/ap2') { return $method === 'POST' ? $emit(Ai2Web_AP2::dispatch_a2a(is_array($body) ? $body : [])) : $error(405, 'invalid_request', 'Use POST (A2A JSON-RPC message/send).'); } return $error(404, 'invalid_request', "No AP2 route for $path."); } return $error(404, 'invalid_request', "No AI2Web route for $path."); } }