store = new TokenStore(); $this->api = new Client(); $this->mutex = new Mutex(); } private function run( callable $action, int $wait = 0 ): mixed { return $this->mutex->run( function () use ( $action ) { try { return $action(); } catch ( \Throwable $exception ) { $this->store->disable(); $this->store->clear(); wp_clear_scheduled_hook( self::CRON ); return new WP_Error( 'storage', __( 'Connection could not be saved safely. Please connect again.', 'waitlistize' ), array( 'status' => 503 ) ); } }, $wait ); } private function save( array $state ): void { if ( ! $this->mutex->owned() || $this->store->disabled() ) { throw new RuntimeException( 'Connection operation interrupted.' ); } $this->store->write( $state ); } private function local(): array { if ( $this->store->disabled() ) { return array( 'status' => 'disconnected' ); } $state = $this->store->read(); if ( 'installation_changed' === $state['status'] ) { wp_clear_scheduled_hook( self::CRON ); } if ( 'refresh_in_flight' === $state['status'] ) { return $this->lost( $state ); } if ( 'pending' === $state['status'] && ! self::valid_slug( $state['account_slug'] ?? null ) ) { $state = array( 'status' => 'pairing_restart_required' ); $this->save( $state ); } if ( 'pending' === $state['status'] && time() >= $state['expires_at'] ) { $state = array( 'status' => 'expired' ); $this->save( $state ); } return $state; } /** Public data is an explicit allowlist, never an upstream response. */ private function view( array $state ): array { $result = array( 'booking_ready' => empty( array_diff( explode( ' ', self::BOOKING_SCOPE ), explode( ' ', $state['scope'] ?? '' ) ) ), 'status' => $state['status'], 'account' => $state['account'] ?? null, 'account_slug' => $state['account_slug'] ?? null, 'last_success' => $state['last_success'] ?? null, 'retry_after' => max( 0, (int) ( $state['next_check'] ?? 0 ) - time() ), ); if ( 'pending' === $state['status'] ) { $result['user_code'] = $state['user_code']; $result['verification_uri'] = $state['verification_uri']; $result['verification_uri_complete'] = $state['verification_uri_complete']; $result['expires_at'] = $state['expires_at']; $result['interval'] = $state['interval']; $result['retry_after'] = max( 1, $state['next_poll'] - time() ); } return $result; } public function status(): array|WP_Error { return $this->run( fn () => $this->view( $this->local() ) ); } private static function valid_slug( mixed $slug ): bool { return is_string( $slug ) && 1 === preg_match( '/\A[A-Za-z0-9_-]{1,255}\z/', $slug ); } public function start( mixed $account_slug = null ): array|WP_Error { return $this->run( function () use ( $account_slug ) { $current = $this->local(); if ( in_array( $current['status'], array( 'connected', 'offline', 'pending' ), true ) ) { return $this->view( $current ); } if ( ! self::valid_slug( $account_slug ) ) { return new WP_Error( 'account_slug_invalid', __( 'Enter a Waitlistize account slug using 1–255 letters (A–Z, a–z), digits, underscores or hyphens.', 'waitlistize' ), array( 'status' => 400 ) ); } $site = (string) get_option( 'home' ); if ( 'https' !== wp_parse_url( $site, PHP_URL_SCHEME ) || wp_parse_url( $site, PHP_URL_USER ) || wp_parse_url( $site, PHP_URL_QUERY ) || wp_parse_url( $site, PHP_URL_FRAGMENT ) ) { return new WP_Error( 'site_https', __( 'Use an HTTPS WordPress site URL before connecting.', 'waitlistize' ), array( 'status' => 400 ) ); } if ( ! function_exists( 'sodium_crypto_secretbox' ) ) { return new WP_Error( 'encryption', __( 'Sodium encryption support is required to connect.', 'waitlistize' ), array( 'status' => 503 ) ); } if ( time() < ( $current['next_check'] ?? 0 ) ) { return $this->view( $current ); } $this->store->enable(); $state = array( 'status' => 'starting', 'installation_id' => wp_generate_uuid4(), 'requested_scope' => self::REQUEST_SCOPE, ); $this->save( $state ); $response = $this->api->request( 'device-authorization', array( 'client_id' => Client::CLIENT_ID, 'installation_id' => $state['installation_id'], 'account_slug' => $account_slug, 'site_url' => $site, 'site_name' => mb_substr( wp_strip_all_tags( get_bloginfo( 'name' ) ), 0, 255 ), 'scope' => $state['requested_scope'], ) ); if ( ! is_wp_error( $response ) && $response['status'] >= 400 && $response['status'] < 500 && 429 !== $response['status'] ) { $this->save( array( 'status' => 'pairing_rejected' ) ); return new WP_Error( 'pairing_rejected', __( 'Waitlistize rejected pairing. Check the account slug and connection settings, then retry.', 'waitlistize' ), array( 'status' => 400 ) ); } if ( is_wp_error( $response ) || 200 !== $response['status'] ) { $this->save( array( 'status' => 'unavailable', 'next_check' => time() + ( is_wp_error( $response ) ? 10 : max( 10, $response['retry_after'] ) ), ) ); return new WP_Error( 'unavailable', __( 'Could not start pairing. Please retry shortly.', 'waitlistize' ), array( 'status' => 503 ) ); } $data = $response['data']; if ( ! self::valid_slug( $data['account_slug'] ?? null ) ) { $this->save( array( 'status' => 'incompatible_backend' ) ); return new WP_Error( 'pairing_compatibility', __( 'Waitlistize did not confirm the target account. The backend must support WL-WP-CONNECTION-1.1 before pairing.', 'waitlistize' ), array( 'status' => 502 ) ); } foreach ( array( 'device_code', 'user_code', 'verification_uri', 'verification_uri_complete' ) as $key ) { if ( empty( $data[ $key ] ) || ! is_string( $data[ $key ] ) ) { throw new RuntimeException( 'Invalid pairing response.' ); } } if ( ! preg_match( '/^[A-Z0-9]{4}-[A-Z0-9]{4}$/i', $data['user_code'] ) || ! $this->api->verification_url( $data['verification_uri'], $data['user_code'], false ) || ! $this->api->verification_url( $data['verification_uri_complete'], $data['user_code'], true ) || ! is_int( $data['expires_in'] ?? null ) || $data['expires_in'] <= 0 || ! is_int( $data['interval'] ?? null ) || $data['interval'] < 1 ) { throw new RuntimeException( 'Invalid pairing metadata.' ); } $state += array_intersect_key( $data, array_flip( array( 'device_code', 'user_code', 'verification_uri', 'verification_uri_complete', 'interval', 'account_slug' ) ) ); $state['status'] = 'pending'; $state['expires_at'] = time() + $data['expires_in']; $state['next_poll'] = time() + $state['interval']; $this->save( $state ); return $this->view( $state ); } ); } public function poll(): array|WP_Error { return $this->run( function () { $state = $this->local(); if ( 'pending' !== $state['status'] || time() < $state['next_poll'] ) { return $this->view( $state ); } // Reserve the interval before the request, including worker failure. $state['next_poll'] = time() + $state['interval']; $this->save( $state ); $response = $this->api->request( 'token', array( 'client_id' => Client::CLIENT_ID, 'installation_id' => $state['installation_id'], 'grant_type' => 'urn:ietf:params:oauth:grant-type:device_code', 'device_code' => $state['device_code'], ) ); if ( ! is_wp_error( $response ) && 200 === $response['status'] ) { $state = $this->credentials( $response['data'], $state ); $this->save( $state ); if ( ! wp_next_scheduled( self::CRON ) ) { wp_schedule_event( time() + 300, 'waitlistize_five_minutes', self::CRON ); } return $this->view( $this->account( $state ) ); } $error = is_wp_error( $response ) ? '' : ( $response['data']['error'] ?? '' ); if ( 'slow_down' === $error ) { $state['interval'] = max( $state['interval'] + 5, (int) ( $response['data']['interval'] ?? 0 ) ); } elseif ( in_array( $error, array( 'access_denied', 'expired_token', 'invalid_grant', 'invalid_request', 'unsupported_grant_type' ), true ) ) { $states = array( 'access_denied' => 'denied', 'expired_token' => 'expired', ); $state = array( 'status' => $states[ $error ] ?? 'connection_lost' ); $this->save( $state ); return $this->view( $state ); } $delay = $state['interval']; if ( is_wp_error( $response ) || $response['status'] >= 500 || 429 === $response['status'] ) { $delay = max( $delay, is_wp_error( $response ) ? 10 : $response['retry_after'] ); } elseif ( ! in_array( $error, array( 'authorization_pending', 'slow_down' ), true ) ) { return $this->view( $this->lost( $state ) ); } $state['next_poll'] = time() + $delay; $this->save( $state ); return $this->view( $state ); } ); } private static function normalize_scope( string $scope ): string { $scopes = explode( ' ', $scope ); sort( $scopes ); return implode( ' ', $scopes ); } private function credentials( array $data, array $previous ): array { foreach ( array( 'access_token', 'refresh_token', 'scope' ) as $field ) { if ( empty( $data[ $field ] ) || ! is_string( $data[ $field ] ) ) { throw new RuntimeException( 'Invalid credential response.' ); } } foreach ( array( 'expires_in', 'refresh_expires_in', 'account_id', 'connection_id' ) as $field ) { if ( ! is_int( $data[ $field ] ?? null ) || $data[ $field ] <= 0 ) { throw new RuntimeException( 'Invalid credential metadata.' ); } } if ( self::normalize_scope( $previous['scope'] ?? $previous['requested_scope'] ?? 'services:read' ) !== self::normalize_scope( $data['scope'] ) || 'Bearer' !== ( $data['token_type'] ?? '' ) || ( isset( $previous['account_id'] ) && ( $previous['account_id'] !== $data['account_id'] || $previous['connection_id'] !== $data['connection_id'] || $previous['scope'] !== $data['scope'] ) ) ) { throw new RuntimeException( 'Credential binding changed.' ); } return array( 'status' => ( 'pending' === $previous['status'] || ! empty( $previous['verify_account_slug'] ) ) ? 'offline' : 'connected', 'account_slug' => $previous['account_slug'] ?? null, 'verify_account_slug' => 'pending' === $previous['status'] || ! empty( $previous['verify_account_slug'] ), 'installation_id' => $previous['installation_id'], 'access_token' => $data['access_token'], 'refresh_token' => $data['refresh_token'], 'expires_at' => time() + $data['expires_in'], 'refresh_expires_at' => time() + $data['refresh_expires_in'], 'account_id' => $data['account_id'], 'connection_id' => $data['connection_id'], 'scope' => $data['scope'], 'account' => $previous['account'] ?? null, 'last_success' => $previous['last_success'] ?? null, ); } private function lost( array $previous, string $status = 'connection_lost' ): array { $state = array( 'status' => $status, 'last_success' => $previous['last_success'] ?? null, ); $this->save( $state ); wp_clear_scheduled_hook( self::CRON ); return $state; } private function refresh_request( array $state ): array|WP_Error { return $this->api->request( 'token', array( 'client_id' => Client::CLIENT_ID, 'installation_id' => $state['installation_id'], 'grant_type' => 'refresh_token', 'refresh_token' => $state['refresh_token'], ) ); } private function refresh( array $state ): array { if ( time() >= $state['refresh_expires_at'] ) { return $this->lost( $state ); } // Persist a tombstone BEFORE consuming the single-use token. A crash, // timeout, malformed response or HTTP 5xx must never trigger a replay. $this->save( array( 'status' => 'refresh_in_flight', 'last_success' => $state['last_success'], ) ); $response = $this->refresh_request( $state ); if ( ! is_wp_error( $response ) && 200 === $response['status'] ) { $state = $this->credentials( $response['data'], $state ); $this->save( $state ); return $state; } if ( ! is_wp_error( $response ) && 429 === $response['status'] ) { $state['status'] = 'offline'; $state['next_check'] = time() + $response['retry_after']; $this->save( $state ); return $state; } return $this->lost( $state ); } private function account( array $state ): array { $response = $this->api->request( 'account', array(), $state['access_token'] ); if ( ! is_wp_error( $response ) && in_array( $response['status'], array( 401, 403 ), true ) ) { return $this->lost( $state ); } if ( ! is_wp_error( $response ) && 200 === $response['status'] ) { $data = $response['data']; if ( ( $data['id'] ?? null ) !== $state['account_id'] || ( $data['connection_id'] ?? null ) !== $state['connection_id'] || ! is_string( $data['name'] ?? null ) || ! is_string( $data['slug'] ?? null ) || ! is_string( $data['timezone'] ?? null ) || ( $data['scope'] ?? null ) !== $state['scope'] ) { return $this->lost( $state ); } // Enforce the canonical target until the first successful identity check. // Established connections stay bound to account_id through later slug renames. if ( ! empty( $state['verify_account_slug'] ) && $data['slug'] !== $state['account_slug'] ) { return $this->lost( $state, 'account_mismatch' ); } $state['verify_account_slug'] = false; $state['account_slug'] = $state['account_slug'] ?? $data['slug']; $state['account'] = array_intersect_key( $data, array_flip( array( 'id', 'name', 'slug', 'timezone' ) ) ); $state['status'] = 'connected'; $state['last_success'] = gmdate( 'c' ); $state['next_check'] = time() + 30; } else { $state['status'] = 'offline'; $state['next_check'] = time() + max( 30, is_wp_error( $response ) ? 30 : $response['retry_after'] ); } $this->save( $state ); return $state; } public function check(): array|WP_Error { return $this->run( function () { $state = $this->local(); if ( ! in_array( $state['status'], array( 'connected', 'offline' ), true ) || time() < ( $state['next_check'] ?? 0 ) ) { return $this->view( $state ); } if ( time() + 60 >= $state['expires_at'] ) { $state = $this->refresh( $state ); } if ( 'connected' === $state['status'] || ( 'offline' === $state['status'] && time() >= ( $state['next_check'] ?? 0 ) ) ) { $state = $this->account( $state ); } return $this->view( $state ); } ); } public function disconnect(): array|WP_Error { // Immediate local stop even while another worker holds the lifecycle lock. $this->store->disable(); wp_clear_scheduled_hook( self::CRON ); $result = $this->run( function () { $state = $this->store->read(); $this->store->clear(); $remote = 'not_attempted'; if ( ! empty( $state['access_token'] ) ) { $remote = 'failed'; if ( time() >= $state['expires_at'] ) { $response = time() < $state['refresh_expires_at'] ? $this->refresh_request( $state ) : null; if ( is_array( $response ) && 200 === $response['status'] ) { $state = $this->credentials( $response['data'], $state ); } else { return array( 'status' => 'disconnected', 'remote_revocation' => 'failed', ); } } $response = $this->api->request( 'revoke', array(), $state['access_token'] ); $remote = ! is_wp_error( $response ) && 204 === $response['status'] ? 'succeeded' : 'failed'; } elseif ( in_array( $state['status'], array( 'connection_lost', 'account_mismatch', 'refresh_in_flight', 'installation_changed' ), true ) ) { $remote = 'failed'; } return array( 'status' => 'disconnected', 'remote_revocation' => $remote, ); }, 15 ); if ( is_wp_error( $result ) ) { $this->store->clear(); return array( 'status' => 'disconnected', 'remote_revocation' => 'failed', ); } return $result; } }