query( $wpdb->prepare( "INSERT INTO %i (subject_key, client_hash, created, last_seen) VALUES (%s, %s, %d, %d) ON DUPLICATE KEY UPDATE last_seen = VALUES(last_seen)", $t, $key, AgentAdapt_Agents::hash( $client_id ), $now, $now ) ); return (int) $wpdb->get_var( $wpdb->prepare( "SELECT id FROM %i WHERE subject_key = %s", $t, $key ) ); // phpcs:enable } /** * The stored key of a (client_id, sub) pair. * * @param string $client_id Client ID URL. * @param string $sub User ID. */ private static function subject_key( string $client_id, string $sub ): string { return hash( 'sha256', $client_id . "\n" . $sub ); } /** * The User ID a client with none of its own (a public client, spec §6) * gets for an account: its subject is per client and account. Being * derived from the account ID, such a subject is personal data of that * account (forget_account_subjects()). * * @param int $user_id Account. */ public static function account_sub( int $user_id ): string { return 'account:' . $user_id; } /** * Start a new signed-out Session. * * @param string $client_hash Agent hash. * @param int $subject_id Subject. * @return array The session row. */ public static function start( string $client_hash, int $subject_id ): array { global $wpdb; $now = time(); $sid = 'ses_' . AgentAdapt_Tokens::random( 18 ); $row = array( 'session_id' => $sid, 'client_hash' => $client_hash, 'subject_id' => $subject_id, 'user_id' => 0, 'bound_user_id' => 0, 'grant_id' => '', // 't_' + 30 hex = WooCommerce's own guest-session key shape (32 chars), // so WooCommerce's session cleanup treats it like any guest cart. 'wc_session_key' => 't_' . substr( hash( 'sha256', 'agentadapt-wc|' . $sid ), 0, 30 ), 'created' => $now, 'last_used' => $now, 'expires' => $now + self::IDLE_LIFETIME, 'ended' => 0, ); $wpdb->insert( AgentAdapt_Schema::table( 'sessions' ), $row ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return $row; } /** * A live Session, or null when unknown, ended or idle-expired. * * @param string $session_id Session ID. * @return array|null */ public static function get( string $session_id ): ?array { global $wpdb; if ( ! preg_match( '/^ses_[A-Za-z0-9_-]{8,40}$/', $session_id ) ) { return null; } $t = AgentAdapt_Schema::table( 'sessions' ); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM %i WHERE session_id = %s", $t, $session_id ), ARRAY_A ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery if ( ! $row || (int) $row['ended'] || (int) $row['expires'] < time() ) { return null; } return $row; } /** * Sign a Session in to an account (spec 4.4). A Session is signed in to * at most one account, ever: `bound_user_id` is set on first sign-in and * never changes, so even after a sign-out the Session cannot be signed in * to a DIFFERENT account (`account_mismatch`). * * @param array $session Session row. * @param int $user_id Account. * @param string $grant_id The Account Token that signs it in. * @return true|WP_Error */ public static function sign_in( array $session, int $user_id, string $grant_id ) { $bound = (int) $session['bound_user_id']; if ( 0 !== $bound && $bound !== $user_id ) { return new WP_Error( 'account_mismatch', 'this Session belongs to a different account; start a new Session' ); } global $wpdb; $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery AgentAdapt_Schema::table( 'sessions' ), array( 'user_id' => $user_id, 'bound_user_id' => $user_id, 'grant_id' => $grant_id, 'last_used' => time() ), array( 'session_id' => $session['session_id'] ) ); return true; } /** * Sign out every Session an Account Token signed in (spec 4.9). They carry * on signed out; `bound_user_id` stays, so even signed out they can be * signed in again only to the same account (spec 4.4, `account_mismatch`). * * Sessions with no User ID end instead: an MCP client that doesn't * implement the protocol (a public client, MCP's own sign-in) starts a * Session that belongs to the client and the account, and it can't * continue signed out (spec §6). * * @param string $grant_id Grant. * @param bool $end End the Sessions rather than sign them out. */ public static function sign_out_grant( string $grant_id, bool $end = false ): void { if ( '' === $grant_id ) { return; } global $wpdb; $set = array( 'user_id' => 0, 'grant_id' => '' ) + ( $end ? array( 'ended' => 1 ) : array() ); $wpdb->update( AgentAdapt_Schema::table( 'sessions' ), $set, array( 'grant_id' => $grant_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } /** * End a Session with no User ID (spec §6) once its sign-in is gone: * signed out, or its Account Token revoked or expired. Callers ask only * for a public client's Sessions; every other Session continues signed * out instead (spec 4.9). * * @param array $row Session row. * @return bool True when the Session has ended. */ public static function end_if_signed_out( array $row ): bool { if ( (int) $row['user_id'] > 0 && AgentAdapt_Grants::active( (string) $row['grant_id'] ) ) { return false; } global $wpdb; $wpdb->update( AgentAdapt_Schema::table( 'sessions' ), array( 'user_id' => 0, 'grant_id' => '', 'ended' => 1 ), array( 'session_id' => $row['session_id'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery return true; } /** * Forget the carts of Sessions that can no longer be used, once their * carts are purged (AgentAdapt_Cart_Continuity::purge()). A live Session * is never touched, whatever the caller passes. * * @param string[] $session_ids Sessions. */ public static function forget_carts( array $session_ids ): void { $session_ids = array_values( array_filter( array_map( 'strval', $session_ids ) ) ); if ( ! $session_ids ) { return; } global $wpdb; // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.NotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare,WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber,PluginCheck.Security.DirectDB.UnescapedDBParameter -- one %s placeholder per Session; the table name is %i. $wpdb->query( $wpdb->prepare( "UPDATE %i SET wc_session_key = '' WHERE ( ended = 1 OR expires < %d ) AND session_id IN (" . implode( ',', array_fill( 0, count( $session_ids ), '%s' ) ) . ')', array_merge( array( AgentAdapt_Schema::table( 'sessions' ), time() ), $session_ids ) ) ); // phpcs:enable } /** * Erase an account from every Session ever signed in to it (WordPress's * personal-data eraser, AgentAdapt_Privacy, after it has deleted their * carts and tokens): each one ends, and loses its account, its Account * Token and its cart key. Ended rather than only signed out, because what * it held was the account's, and a Session with no account bound could be * signed in to another one. The row stays, as an anonymous count of one * agent visit, because the Agents screen counts Sessions per agent. * * @param int $user_id Account. * @return int Sessions erased. */ public static function erase_account( int $user_id ): int { if ( $user_id < 1 ) { return 0; } global $wpdb; return (int) $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prepare( "UPDATE %i SET user_id = 0, bound_user_id = 0, grant_id = '', wc_session_key = '', ended = 1 WHERE bound_user_id = %d OR user_id = %d", AgentAdapt_Schema::table( 'sessions' ), $user_id, $user_id ) ); } /** * Re-key, at random, the subjects these clients were given for an account * (account_sub()), so nothing can work the account out from them again * (WordPress's personal-data eraser). The row and its id stay, so the * Sessions and events that point to it keep their counts, and the * client's next sign-in to the account starts a new subject. Only for * clients with no User ID of their own: every other subject is keyed by * the agent's User ID, which says nothing about the account. * * @param int $user_id Account. * @param string[] $client_ids Client IDs of public clients. * @return int Subjects re-keyed. */ public static function forget_account_subjects( int $user_id, array $client_ids ): int { if ( $user_id < 1 ) { return 0; } global $wpdb; $n = 0; foreach ( array_unique( array_map( 'strval', $client_ids ) ) as $client_id ) { $n += (int) $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery AgentAdapt_Schema::table( 'subjects' ), array( 'subject_key' => hash( 'sha256', 'forgotten|' . AgentAdapt_Tokens::random( 32 ) ) ), array( 'subject_key' => self::subject_key( $client_id, self::account_sub( $user_id ) ) ) ); } return $n; } /** * Extend a Session's idle lifetime on use (at most once a minute). * * @param array $row Session row. */ public static function touch( array $row ): void { $now = time(); if ( $now - (int) $row['last_used'] < 60 ) { return; } global $wpdb; $wpdb->update( AgentAdapt_Schema::table( 'sessions' ), array( 'last_used' => $now, 'expires' => $now + self::IDLE_LIFETIME ), array( 'session_id' => $row['session_id'] ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery } }