table = $wpdb->prefix . 'wppilot_connection'; } /** * Generate a new cryptographic per-site secret and persist it. * * @return string The raw secret (hex-encoded, 64 chars). */ public function generate_secret(): string { $secret = bin2hex( random_bytes( 32 ) ); $this->upsert( array( 'secret' => $secret ) ); return $secret; } public function get_secret(): string { $row = $this->get_row(); return $row->secret ?? ''; } public function get_site_id(): string { $row = $this->get_row(); return $row->site_id ?? ''; } public function set_site_id( string $site_id ): void { $this->upsert( array( 'site_id' => $site_id ) ); } public function set_callback_token_hash( string $hash ): void { $this->upsert( array( 'callback_token_hash' => $hash ) ); } public function get_callback_token_hash(): string { $row = $this->get_row(); return $row->callback_token_hash ?? ''; } public function set_connected_user_id( int $user_id ): void { $this->upsert( array( 'connected_user_id' => $user_id ) ); } public function get_connected_user_id(): int { $row = $this->get_row(); return (int) ( $row->connected_user_id ?? 0 ); } /** * Verify a backend callback token in constant time. */ public function verify_callback_token( string $token ): bool { $stored_hash = $this->get_callback_token_hash(); if ( empty( $stored_hash ) ) { return false; } return hash_equals( $stored_hash, hash( 'sha256', $token ) ); } public function set_jwt( string $token, int $expires_in_seconds ): void { update_option( 'wppilot_jwt_issued_at', time(), false ); $expires = gmdate( 'Y-m-d H:i:s', time() + $expires_in_seconds ); $this->upsert( array( 'jwt_token' => $token, 'jwt_expires_at' => $expires, ) ); } public function get_jwt(): string { $row = $this->get_row(); return $row->jwt_token ?? ''; } public function clear_jwt(): void { $this->upsert( array( 'jwt_token' => '', 'jwt_expires_at' => null, ) ); } /** * Whether the cached JWT is valid (present and not yet expired). Sessions are * long-lived (30 days, site-bound), so the old proactive two-minute refresh * window is gone: the token is refreshed lazily only once it has actually * expired. $buffer_seconds defaults to 0 (no proactive window); callers may * still pass a buffer if they want a safety margin. */ public function is_jwt_valid( int $buffer_seconds = 0 ): bool { $row = $this->get_row(); if ( empty( $row->jwt_token ) || empty( $row->jwt_expires_at ) ) { return false; } $expires_at = strtotime( $row->jwt_expires_at ); return $expires_at > ( time() + $buffer_seconds ); } /** Seconds since the cached JWT was issued, or null when unknown. */ public function jwt_age(): ?int { $issued = (int) get_option( 'wppilot_jwt_issued_at', 0 ); return $issued > 0 ? max( 0, time() - $issued ) : null; } public function is_connected(): bool { return '1' === get_option( 'wppilot_connected', '0' ); } public function set_connected( bool $connected ): void { update_option( 'wppilot_connected', $connected ? '1' : '0' ); $this->upsert( array( 'connected' => $connected ? 1 : 0 ) ); } /** * The backend's own verdict on this pairing, as distinct from our memory of it. * * `is_connected()` above is a LOCAL BELIEF: an option this plugin wrote to * itself the last time a handshake succeeded. Nothing ever revisited it, and * that is the whole of the following defect. * * A backend that loses this site — a restored-from-older-backup database, a * reset development environment, a site removed on the other side — answers * `POST /v1/auth/token` with 401 for a `site_url`/`secret` pair it does not * accept. The plugin retried once, gave up, surfaced a generic failure, and * threw the verdict away. `wppilot_connected` stayed `1` for ever. Because * the admin page asks only that local belief before deciding whether to * offer a connect affordance, THE OWNER IS LEFT WITH NO WAY TO ASK IT TO TRY * AGAIN: every call fails, the screen insists the site is connected, and the * only exits are a WP-CLI database edit or an emergency disconnect they have * no reason to think would help. Recovering the fixture on this machine * needed exactly that manual surgery. * * So the verdict is recorded. Note what does NOT record one: * * 401 from the secret exchange — the backend does not accept this pairing. * A verdict. The exchange carries no JWT, * so this cannot be a merely expired token. * transport failure, 5xx, 429 — NO VERDICT. The backend never answered, * or answered about itself rather than * about us. A laptop offline for an hour * must never destroy a working pairing, * which is the mirror of the mistake this * fixes: silence is not evidence, in * either direction. * * Keyed on the HTTP STATUS of one specific exchange, never on the text of an * error message. */ public function is_pairing_rejected(): bool { return '1' === get_option( 'wppilot_pairing_rejected', '0' ); } /** * Record, or withdraw, the backend's rejection of this pairing. * * @param bool $rejected True on an authoritative 401 from the secret * exchange; false whenever an exchange succeeds. */ public function set_pairing_rejected( bool $rejected ): void { if ( $rejected ) { update_option( 'wppilot_pairing_rejected', '1' ); return; } delete_option( 'wppilot_pairing_rejected' ); } /** * Consecutive refusals (401) of activity delivery after which the plugin * stops sending and asks the owner to reconnect. */ public const DELIVERY_REFUSAL_LIMIT = 3; /** First pause after a refusal, in seconds; doubled for each further one. */ private const DELIVERY_BACKOFF_BASE = 60; /** Longest pause between delivery attempts, in seconds. */ private const DELIVERY_BACKOFF_CAP = 86400; /** Option holding the delivery refusal record. */ private const DELIVERY_OPTION = 'wppilot_delivery_auth'; /** * Record that the backend refused (401) an activity delivery. * * 2026-10-08: two sites POSTed /v1/events/ingest every ~10 seconds and got * 401 every time, indefinitely, because nothing listened to the answer. A * refusal now pauses delivery for an exponentially growing interval, and * DELIVERY_REFUSAL_LIMIT consecutive refusals stop it altogether until the * owner reconnects — which is what the admin page then asks them to do. * * Only a 401 counts. A transport failure, 5xx or 429 says nothing about the * pairing and never reaches this method. * * @return int The number of consecutive refusals, including this one. */ public function record_delivery_refused(): int { $refusals = $this->delivery_refusals() + 1; $pause = min( self::DELIVERY_BACKOFF_CAP, self::DELIVERY_BACKOFF_BASE * ( 2 ** ( $refusals - 1 ) ) ); update_option( self::DELIVERY_OPTION, array( 'refusals' => $refusals, 'retry_at' => time() + $pause, ), false ); return $refusals; } /** Record that the backend accepted a delivery: the refusal run is over. */ public function record_delivery_accepted(): void { if ( 0 !== $this->delivery_refusals() ) { delete_option( self::DELIVERY_OPTION ); } } /** Consecutive delivery refusals recorded so far. */ public function delivery_refusals(): int { $record = get_option( self::DELIVERY_OPTION, array() ); return is_array( $record ) ? (int) ( $record['refusals'] ?? 0 ) : 0; } /** * Whether delivery may be attempted now. * * False while a backoff pause runs, and false for good once delivery is * suspended — only a reconnect (which clears the record) resumes it. */ public function may_deliver_now(): bool { if ( $this->is_delivery_suspended() ) { return false; } $record = get_option( self::DELIVERY_OPTION, array() ); $retry = is_array( $record ) ? (int) ( $record['retry_at'] ?? 0 ) : 0; return time() >= $retry; } /** Whether repeated refusals have stopped delivery until a reconnect. */ public function is_delivery_suspended(): bool { return $this->delivery_refusals() >= self::DELIVERY_REFUSAL_LIMIT; } /** Forget every delivery refusal (after a successful (re)connect). */ public function reset_delivery(): void { delete_option( self::DELIVERY_OPTION ); } /** * Whether the owner must reconnect: the backend refused the pairing, or * refused delivery often enough that the plugin stopped sending. This is * the one fact the admin page and the event batcher both read. */ public function needs_reconnect(): bool { return $this->is_pairing_rejected() || $this->is_delivery_suspended(); } /** * Clear all connection data (emergency disconnect). */ public function clear(): void { global $wpdb; // Single-row custom table; name is built from $wpdb->prefix + a fixed suffix and the id is prepared. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( $wpdb->prepare( "DELETE FROM {$this->table} WHERE id = %d", 1 ) ); update_option( 'wppilot_connected', '0' ); delete_option( 'wppilot_pairing_rejected' ); delete_option( self::DELIVERY_OPTION ); delete_transient( 'wppilot_connect_window' ); } /** * Return the single connection row, or null. */ private function get_row(): ?\stdClass { global $wpdb; // Single-row custom table; name is built from $wpdb->prefix + a fixed suffix and the id is prepared. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$this->table} WHERE id = %d", 1 ) ); } /** * Insert or update the single connection row (always id = 1). * * @param array $data Column => value pairs. */ private function upsert( array $data ): void { global $wpdb; $row = $this->get_row(); if ( $row ) { // Single-row custom table write; $wpdb->update sanitizes values internally. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->update( $this->table, $data, array( 'id' => 1 ) ); } else { $data['id'] = 1; $data['site_url'] = get_site_url(); // Single-row custom table write; $wpdb->insert sanitizes values internally. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->insert( $this->table, $data ); } } }