'GET', 'callback' => static fn() => new WP_REST_Response( AgentAdapt_Openapi::document(), 200, array( 'Cache-Control' => 'public, max-age=300' ) ), // Public on purpose: the API description is linked from the public // poppy.json so agents can read it BEFORE they have a Session. 'permission_callback' => '__return_true', ) ); foreach ( AgentAdapt_Capabilities::all() as $id => $cap ) { register_rest_route( self::NS, self::route_pattern( $cap ), array( 'methods' => $cap['method'], 'args' => self::rest_args( $cap ), 'permission_callback' => static fn( WP_REST_Request $r ) => self::authorize( $r, $id ), 'callback' => static fn( WP_REST_Request $r ) => self::invoke( $r, $id ), ) ); } } /** * Permission: a valid Session Token + proof, the gate's yes, and the rate limit. * * @param WP_REST_Request $request Request. * @param string $id Capability id. * @return true|WP_Error */ public static function authorize( WP_REST_Request $request, string $id ) { $context = AgentAdapt_Request_Auth::context( $request->get_method() ); if ( is_wp_error( $context ) ) { return $context; } $rate = AgentAdapt_Rate_Limit::hit( 'api-session:' . $context->session_id, self::PER_SESSION_PER_MINUTE, MINUTE_IN_SECONDS ); if ( ! $rate['allowed'] ) { return new WP_Error( 'rate_limited', 'too many requests', array( 'status' => 429, 'retry_after' => $rate['retry_after'] ) ); } $gate = AgentAdapt_Gate::check( $context, $id ); if ( is_wp_error( $gate ) ) { $data = (array) $gate->get_error_data(); if ( in_array( $gate->get_error_code(), array( 'sign_in_required', 'insufficient_scope' ), true ) ) { $data['www_authenticate'] = self::challenge( $gate ); } AgentAdapt_Events::record( 'error', $context, array( 'outcome' => $gate->get_error_code(), 'meta' => array( 'capability' => $id ) ) ); return new WP_Error( $gate->get_error_code(), $gate->get_error_message(), $data ); } self::$context = $context; return true; } /** * The WWW-Authenticate challenge for a refusal at the API (spec §6): * sign_in_required, insufficient_scope (with the scopes needed), or * extension_required (with the extension's name, spec 3.3). * * @param WP_Error $error The refusal. */ public static function challenge( WP_Error $error ): string { $data = (array) $error->get_error_data(); return 'DPoP error="' . $error->get_error_code() . '"' . ( isset( $data['scope'] ) ? ', scope="' . $data['scope'] . '"' : '' ) . ( isset( $data['extension'] ) ? ', extension="' . $data['extension'] . '"' : '' ); } /** * Run the capability's handler. * * @param WP_REST_Request $request Request. * @param string $id Capability id. * @return WP_REST_Response|WP_Error */ public static function invoke( WP_REST_Request $request, string $id ) { $cap = AgentAdapt_Capabilities::get( $id ); $context = self::$context; if ( null === $cap || null === $context ) { return new WP_Error( 'insufficient_scope', 'not authorised', array( 'status' => 403 ) ); } $params = array(); foreach ( array_keys( $cap['params'] ) as $name ) { if ( null !== $request->get_param( $name ) ) { $params[ $name ] = $request->get_param( $name ); } } $result = call_user_func( $cap['handler'], $context, $params ); if ( is_wp_error( $result ) || $result instanceof WP_REST_Response ) { return $result; } return new WP_REST_Response( $result, (int) ( $cap['status'] ?? 200 ) ); } /** * Agent-shaped errors, challenges, ETags and cache headers on our namespace. * * @param WP_HTTP_Response|WP_REST_Response $response Response. * @param WP_REST_Server $server Server. * @param WP_REST_Request $request Request. * @return WP_HTTP_Response|WP_REST_Response */ public static function shape_response( $response, $server, $request ) { if ( ! str_starts_with( (string) $request->get_route(), '/' . self::NS . '/' ) || '/' . self::NS . '/openapi.json' === $request->get_route() ) { return $response; } $response->header( 'Cache-Control', 'private, no-cache' ); $response->header( 'Vary', 'Authorization, DPoP' ); if ( $response->is_error() ) { $data = (array) $response->get_data(); $extra = (array) ( $data['data'] ?? array() ); if ( ! empty( $extra['www_authenticate'] ) ) { $response->header( 'WWW-Authenticate', (string) $extra['www_authenticate'] ); } if ( ! empty( $extra['retry_after'] ) ) { $response->header( 'Retry-After', (string) $extra['retry_after'] ); } $code = (string) ( $data['code'] ?? 'error' ); $body = array( 'error' => $code ); if ( ! in_array( $code, array( 'rate_limited', 'extension_required' ), true ) ) { $body['error_description'] = (string) ( $data['message'] ?? '' ); } if ( isset( $extra['params'] ) ) { $body['invalid_params'] = $extra['params']; } // Operations extension §9: terms_changed carries the current // operation; extension_required names the extension. foreach ( array( 'operation', 'extension' ) as $member ) { if ( isset( $extra[ $member ] ) ) { $body[ $member ] = $extra[ $member ]; } } $response->set_data( $body ); return $response; } if ( 'GET' === $request->get_method() && 200 === $response->get_status() ) { $etag = 'W/"' . substr( hash( 'sha256', (string) wp_json_encode( $response->get_data() ) ), 0, 32 ) . '"'; $response->header( 'ETag', $etag ); $inm = (string) $request->get_header( 'if_none_match' ); if ( '' !== $inm && in_array( $etag, array_map( 'trim', explode( ',', $inm ) ), true ) ) { $response->set_status( 304 ); $response->set_data( null ); } } return $response; } /** * WP route regex from a {param} path. * * @param array $cap Capability. */ public static function route_pattern( array $cap ): string { return (string) preg_replace_callback( '/\{([a-z_]+)\}/', static function ( $m ) use ( $cap ) { $type = $cap['params'][ $m[1] ]['type'] ?? 'string'; return '(?P<' . $m[1] . '>' . ( 'integer' === $type ? '\d+' : '[A-Za-z0-9_.-]+' ) . ')'; }, (string) $cap['path'] ); } /** * WP REST args (validation + sanitisation) from the capability's params. * * @param array $cap Capability. * @return array> */ private static function rest_args( array $cap ): array { $args = array(); foreach ( $cap['params'] as $name => $schema ) { $arg = array_intersect_key( (array) $schema, array_flip( array( 'type', 'description', 'enum', 'minimum', 'maximum', 'default', 'minLength', 'maxLength', 'items', 'properties' ) ) ); $arg['required'] = ! empty( $schema['required'] ); $arg['validate_callback'] = 'rest_validate_request_arg'; $arg['sanitize_callback'] = 'rest_sanitize_request_arg'; $args[ $name ] = $arg; } return $args; } }