auth_base_url = rtrim( $auth_base_url, '/' ); } public function request( string $identity_mode, string $pds_path, array $body ): array { if ( ! in_array( $identity_mode, self::ALLOWED_MODES, true ) ) { throw new \InvalidArgumentException( esc_html( 'Pds_Auth_Proxy: invalid identity_mode "' . $identity_mode . '"' ) ); } try { $this->oauth->refresh_if_needed(); } catch ( \Throwable $e ) { $this->emit_refresh_event( $e ); return [ 'status' => 401, 'body' => null, 'raw' => $this->refresh_failure_raw( $e ), ]; } try { $resp = $this->send( $identity_mode, $pds_path, $body ); } catch ( \RuntimeException $e ) { return [ 'status' => 401, 'body' => null, 'raw' => 'not_connected', ]; } if ( $this->status_of( $resp ) === 401 ) { try { $this->oauth->refresh(); } catch ( \Throwable $e ) { $this->emit_refresh_event( $e ); return [ 'status' => 401, 'body' => null, 'raw' => $this->refresh_failure_raw( $e ), ]; } try { $resp = $this->send( $identity_mode, $pds_path, $body ); } catch ( \RuntimeException $e ) { return [ 'status' => 401, 'body' => null, 'raw' => 'not_connected', ]; } } return $this->shape( $resp ); } /** * GET passthrough for credential-free PDS read endpoints (`/balances`, * `/assets`, `/offers`, `/licenses`). Same Bearer + 401 → refresh → retry * envelope as `request()`, but no body and no identity-mode injection. * * @param string $pds_path PDS path, e.g. `/assets` or `/balances`. * @param array $query Query string parameters. */ public function request_get( string $pds_path, array $query = [] ): array { try { $this->oauth->refresh_if_needed(); } catch ( \Throwable $e ) { $this->emit_refresh_event( $e ); return [ 'status' => 401, 'body' => null, 'raw' => $this->refresh_failure_raw( $e ), ]; } try { $resp = $this->send_get( $pds_path, $query ); } catch ( \RuntimeException $e ) { return [ 'status' => 401, 'body' => null, 'raw' => 'not_connected', ]; } if ( $this->status_of( $resp ) === 401 ) { try { $this->oauth->refresh(); } catch ( \Throwable $e ) { $this->emit_refresh_event( $e ); return [ 'status' => 401, 'body' => null, 'raw' => $this->refresh_failure_raw( $e ), ]; } try { $resp = $this->send_get( $pds_path, $query ); } catch ( \RuntimeException $e ) { return [ 'status' => 401, 'body' => null, 'raw' => 'not_connected', ]; } } return $this->shape( $resp ); } /** * Stable discriminator for the response envelope's `raw` field. Callers * (admin UI, crawler) can branch on this without substring-matching the * underlying exception message — `disconnected` triggers a Reconnect prompt, * `transient` retries silently. */ private function refresh_failure_raw( \Throwable $e ): string { if ( $e instanceof Prweb_OAuth_Refresh_Disconnected_Exception ) { return 'refresh_failed:disconnected'; } if ( $e instanceof Prweb_OAuth_Refresh_Transient_Exception ) { return 'refresh_failed:transient'; } return 'refresh_failed:unknown'; } /** * Audit refresh failures so admins see "OAuth disconnected, please reconnect" * in the audit log. Disconnected (4xx) and transient (5xx) get distinct * event kinds. Best-effort — we never let auditing failure mask the real error. */ private function emit_refresh_event( \Throwable $e ): void { if ( ! class_exists( 'Prweb_Events_Store' ) ) { return; } // OAuth refresh failures (disconnected / transient / generic) collapse // to the canonical `oauth.token_refreshed` per // distributed-data-reviewer-2026-04-27-20 Part B Rule 4. The exception class // distinction lives in payload.refresh_outcome for the humanizer. $outcome = $e instanceof Prweb_OAuth_Refresh_Disconnected_Exception ? 'disconnected' : ( $e instanceof Prweb_OAuth_Refresh_Transient_Exception ? 'transient' : 'failed' ); $subject = ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) ? (string) wp_get_current_user()->user_login : 'system'; try { ( new Prweb_Events_Store() )->audit( 'oauth.token_refreshed', [ 'subject_user' => $subject, 'refresh_outcome' => $outcome, 'reason' => $e->getMessage(), ] ); } catch ( \Throwable $ignore ) { // never fail the request because auditing failed } } // ------------------------------------------------------------------------- // Internals // ------------------------------------------------------------------------- private function send( string $identity_mode, string $pds_path, array $body ): mixed { $access = Prweb_Token_Store::get_access(); if ( $access === null || $access === '' ) { throw new \RuntimeException( 'Pds_Auth_Proxy: no access_token; not connected' ); } $url = $this->auth_base_url . '/v1/pds/' . ltrim( $pds_path, '/' ); return wp_remote_post( $url, [ 'timeout' => self::HTTP_TIMEOUT, 'redirection' => 0, 'headers' => [ 'Authorization' => 'Bearer ' . $access, 'X-Pds-Identity-Mode' => $identity_mode, 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'body' => wp_json_encode( $body ), ] ); } private function send_get( string $pds_path, array $query ): mixed { $access = Prweb_Token_Store::get_access(); if ( $access === null || $access === '' ) { throw new \RuntimeException( 'Pds_Auth_Proxy: no access_token; not connected' ); } $url = $this->auth_base_url . '/v1/pds/' . ltrim( $pds_path, '/' ); if ( ! empty( $query ) ) { $url = add_query_arg( $query, $url ); } return wp_remote_get( $url, [ 'timeout' => self::HTTP_TIMEOUT, 'redirection' => 0, 'headers' => [ 'Authorization' => 'Bearer ' . $access, 'Accept' => 'application/json', ], ] ); } private function status_of( mixed $resp ): int { if ( is_wp_error( $resp ) ) { return 0; } return (int) wp_remote_retrieve_response_code( $resp ); } private function shape( mixed $resp ): array { if ( is_wp_error( $resp ) ) { return [ 'status' => 0, 'body' => null, 'raw' => $resp->get_error_message(), ]; } $status = (int) wp_remote_retrieve_response_code( $resp ); $raw = (string) wp_remote_retrieve_body( $resp ); $decoded = json_decode( $raw, true ); return [ 'status' => $status, 'body' => is_array( $decoded ) ? $decoded : null, 'raw' => $raw, ]; } }