*/ public static function poppy(): array { $doc = array( 'protocol_version' => self::PROTOCOL_VERSION, 'organization' => array( 'name' => wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ), 'domain' => AgentAdapt_Urls::domain(), ), 'auth' => self::auth(), 'apis' => array( array( 'type' => 'openapi', 'url' => AgentAdapt_Urls::openapi_url(), 'description' => __( 'Product search and details, store policies, and the shopping cart.', 'agent-adapt' ), ), ), ); // Spec §5: the agent's browser joins the Session here before browsing. $doc['web'] = array( 'browser_session_endpoint' => AgentAdapt_Browser_Session::endpoint() ); // Spec §8: agents report a customer's task they couldn't do here. Spec // 3.1 then requires `auth`, which this document always has. $doc['feedback'] = array( 'endpoint' => AgentAdapt_Feedback::endpoint() ); $extensions = AgentAdapt_Extensions::advertised(); if ( $extensions ) { $doc['extensions'] = $extensions; } /** * The discovery document. Premium modules add sign-in types, APIs * and extensions here; whatever they add they must actually serve. * * @param array $doc poppy.json. */ return (array) apply_filters( 'agentadapt_poppy_document', $doc ); } /** * The `auth` member: issuer plus each sign-in type offered (spec 3.1). * * @return array */ private static function auth(): array { $auth = array( 'issuer' => AgentAdapt_Urls::issuer() ); foreach ( array( 'direct', 'device' ) as $type ) { if ( AgentAdapt_Signin::offers( $type ) ) { $auth[ $type ] = array( 'scopes' => array_values( AgentAdapt_Signin::types()[ $type ] ) ); } } $custom = AgentAdapt_Signin::custom_scopes(); if ( $custom ) { $auth['custom_scopes'] = $custom; } return $auth; } /** * RFC 8414 metadata for the issuer. * * @return array */ public static function as_metadata(): array { $doc = array( 'issuer' => AgentAdapt_Urls::issuer(), 'token_endpoint' => AgentAdapt_Urls::token_endpoint(), 'revocation_endpoint' => AgentAdapt_Urls::revocation_endpoint(), // Spec 3.2: every domain whose poppy.json names this issuer. 'poppy_domains' => array( AgentAdapt_Urls::domain() ), 'grant_types_supported' => self::grant_types(), 'token_endpoint_auth_methods_supported' => array( 'private_key_jwt' ), 'token_endpoint_auth_signing_alg_values_supported' => AgentAdapt_Jose::ALGS, 'revocation_endpoint_auth_methods_supported' => array( 'private_key_jwt' ), 'dpop_signing_alg_values_supported' => AgentAdapt_Jose::ALGS, 'scopes_supported' => array_values( array_unique( array_merge( array(), ...array_values( AgentAdapt_Signin::types() ) ) ) ), 'response_types_supported' => AgentAdapt_Signin::offers( 'direct' ) ? array( 'code' ) : array(), ); if ( AgentAdapt_Signin::offers( 'direct' ) ) { $doc['authorization_endpoint'] = AgentAdapt_Urls::url( '/' . AgentAdapt_Urls::BASE . '/oauth/authorize' ); $doc['code_challenge_methods_supported'] = array( 'S256' ); $doc['authorization_response_iss_parameter_supported'] = true; // RFC 9207. } if ( AgentAdapt_Signin::offers( 'device' ) ) { $doc['device_authorization_endpoint'] = AgentAdapt_Urls::url( '/' . AgentAdapt_Urls::BASE . '/oauth/device' ); } /** * The authorization server metadata. Premium modules add the * authorization / device endpoints when they offer those sign-ins. * * @param array $doc RFC 8414 metadata. */ return (array) apply_filters( 'agentadapt_as_metadata', $doc ); } /** * Grant types the token endpoint accepts. * * @return string[] */ private static function grant_types(): array { $grants = array( AgentAdapt_Token_Endpoint::GRANT_JWT_BEARER, 'refresh_token' ); if ( AgentAdapt_Signin::offers( 'direct' ) ) { $grants[] = 'authorization_code'; } if ( AgentAdapt_Signin::offers( 'device' ) ) { $grants[] = AgentAdapt_Token_Endpoint::GRANT_DEVICE; } return $grants; } /** * GET /.well-known/poppy.json * * The document speaks for the host that serves it, after any redirects, * and `organization.domain` must be that host, compared ignoring case and * nothing else (spec 3.1, since 15 Oct 2026). The router answers before * WordPress's canonical redirect, so a request that reached this store on * the `www.` twin of its host is redirected to the configured host first, * never answered with a document that names a different host. */ public static function serve_poppy(): void { $to = self::canonical_poppy_url( self::request_host() ); if ( '' !== $to && in_array( AgentAdapt_Http::method(), array( 'GET', 'HEAD' ), true ) ) { AgentAdapt_Http::redirect( $to, 301 ); } self::serve( self::poppy() ); } /** * Where a request for poppy.json that arrived on $host must go first, or * '' to answer it here. Only the `www.` twin of the configured host is * redirected, the one pair of hosts WordPress itself redirects between * (redirect_canonical()). Any other Host header is answered as it is: it * is either the public host behind a proxy that rewrites Host, where a * redirect would loop, or not this store at all. * * @param string $host Host the request arrived on, without a port. */ public static function canonical_poppy_url( string $host ): string { $host = strtolower( trim( $host ) ); $domain = AgentAdapt_Urls::domain(); if ( '' === $host || '' === $domain || $host === $domain ) { return ''; } return ( 'www.' . $domain === $host || 'www.' . $host === $domain ) ? AgentAdapt_Urls::poppy_url() : ''; } /** * The Host the current request arrived on, lower-case, without a port. */ private static function request_host(): string { // Compared with a fixed value, never output or stored. $host = isset( $_SERVER['HTTP_HOST'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ) : ''; return (string) preg_replace( '/:\d+$/', '', $host ); } /** * GET the RFC 8414 metadata (either location). */ public static function serve_as_metadata(): void { self::serve( self::as_metadata() ); } /** * Public, cacheable JSON. GET/HEAD only. * * @param array $doc Document. */ private static function serve( array $doc ): void { if ( ! in_array( AgentAdapt_Http::method(), array( 'GET', 'HEAD' ), true ) ) { AgentAdapt_Http::json( 405, array( 'error' => 'method_not_allowed' ), array( 'Allow' => 'GET, HEAD' ) ); } AgentAdapt_Http::json( 200, $doc, array( 'Cache-Control' => 'public, max-age=' . self::MAX_AGE, // Discovery documents are public data and are fetched by agents, // not browsers; CORS lets browser-based agents read them too. 'Access-Control-Allow-Origin' => '*', ) ); } }