prefix (trusted); every value is bound via prepare() placeholders, verified by PHPStan. /** * Data access for adaf_agents. */ final class AgentRepository { /** * Constructor. * * @param wpdb $wpdb WordPress database. */ public function __construct( private readonly wpdb $wpdb ) { } /** * Fully prefixed table name for the site WordPress is on now. Worked * out per query, so after switch_to_blog() it is that site's table * rather than the one this repository was created on (ADR-16, found * 2026-10-16). */ private function table(): string { return $this->wpdb->prefix . 'adaf_agents'; } /** * Insert a new agent. * * @param string $agent_key Stable public identifier. * @param string $name Display name. * @param int $wp_user_id Linked WordPress user. * @param string|null $token_hash SHA-256 token hash (null = not issued yet). * @param DateTimeImmutable $created_at Creation timestamp. * @param list $scopes Token scopes (empty = unrestricted). * @param int|null $admin_confirmed_by The administrator confirming * that the agent runs as an * administrator (AF-25.15); null * for every other agent. */ public function create( string $agent_key, string $name, int $wp_user_id, ?string $token_hash, DateTimeImmutable $created_at, array $scopes = array(), ?int $admin_confirmed_by = null ): Agent { $row = array( 'agent_key' => $agent_key, 'name' => $name, 'wp_user_id' => $wp_user_id, 'token_hash' => $token_hash, 'scopes_json' => array() === $scopes ? null : (string) wp_json_encode( array_values( $scopes ) ), 'status' => 'active', 'created_at' => $created_at->format( 'Y-m-d H:i:s' ), ); $format = array( '%s', '%s', '%d', '%s', '%s', '%s', '%s' ); // Named only for an administrator-run agent, so an ordinary one is // created even where the DB_VERSION 13 columns are not there yet. if ( null !== $admin_confirmed_by ) { $row['admin_confirmed_by'] = $admin_confirmed_by; $row['admin_confirmed_at'] = $created_at->format( 'Y-m-d H:i:s' ); $format[] = '%d'; $format[] = '%s'; } $inserted = $this->wpdb->insert( $this->table(), $row, $format ); return new Agent( false === $inserted ? 0 : (int) $this->wpdb->insert_id, $agent_key, $name, $wp_user_id, $token_hash, 'active', $created_at, array_values( $scopes ), null, $admin_confirmed_by, null === $admin_confirmed_by ? null : $created_at ); } /** * Find by primary key. * * @param int $id Row id. */ public function find_by_id( int $id ): ?Agent { $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table()} WHERE id = %d", $id ), ARRAY_A ); return null === $row ? null : $this->hydrate( $row ); } /** * Find by public agent key. * * @param string $agent_key Agent key. */ public function find_by_key( string $agent_key ): ?Agent { $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table()} WHERE agent_key = %s", $agent_key ), ARRAY_A ); return null === $row ? null : $this->hydrate( $row ); } /** * Find by SHA-256 token hash. * * @param string $token_hash Token hash. */ public function find_by_token_hash( string $token_hash ): ?Agent { $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table()} WHERE token_hash = %s", $token_hash ), ARRAY_A ); return null === $row ? null : $this->hydrate( $row ); } /** * Find by the application password bound to the agent (AF-13.2). * * The lookup key is the password's uuid, not the password itself: * WordPress stores only a hash of the secret, and the uuid is the one * stable handle that both the user's profile screen and the * `application_password_did_authenticate` payload agree on. * * @param string $uuid Application password uuid. */ public function find_by_app_password_uuid( string $uuid ): ?Agent { if ( '' === $uuid ) { return null; } $row = $this->wpdb->get_row( $this->wpdb->prepare( "SELECT * FROM {$this->table()} WHERE app_password_uuid = %s", $uuid ), ARRAY_A ); return null === $row ? null : $this->hydrate( $row ); } /** * Every application-password uuid an agent claims (AF-16.8). * * The exposure scan needs the claimed set to subtract it from what the * site's users actually hold, and asking one uuid at a time would mean a * query per password. Selects the one column rather than hydrating every * agent: the caller wants membership, not rows. * * @return list */ public function all_app_password_uuids(): array { $uuids = $this->wpdb->get_col( "SELECT app_password_uuid FROM {$this->table()} WHERE app_password_uuid IS NOT NULL AND app_password_uuid <> ''" ); return array_values( array_map( 'strval', $uuids ?? array() ) ); } /** * Bind or unbind an application password. * * @param int $id Agent id. * @param string|null $uuid Application password uuid, or null to unbind. */ public function update_app_password_uuid( int $id, ?string $uuid ): void { $this->wpdb->update( $this->table(), array( 'app_password_uuid' => $uuid ), array( 'id' => $id ), array( '%s' ), array( '%d' ) ); } /** * All agents, newest first. * * @return list */ public function all(): array { $rows = $this->wpdb->get_results( "SELECT * FROM {$this->table()} ORDER BY id DESC", ARRAY_A ); return array_values( array_map( array( $this, 'hydrate' ), $rows ?? array() ) ); } /** * Replace (or clear) the stored token hash. * * @param int $id Agent id. * @param string|null $token_hash New hash, or null to revoke. */ public function update_token_hash( int $id, ?string $token_hash ): void { $this->wpdb->update( $this->table(), array( 'token_hash' => $token_hash ), array( 'id' => $id ), array( '%s' ), array( '%d' ) ); } /** * Update the agent status. * * @param int $id Agent id. * @param string $status New status. */ public function update_status( int $id, string $status ): void { $this->wpdb->update( $this->table(), array( 'status' => $status ), array( 'id' => $id ), array( '%s' ), array( '%d' ) ); } /** * Map a database row to the value object. * * @param array $row Raw row. */ private function hydrate( array $row ): Agent { $scopes = array(); if ( isset( $row['scopes_json'] ) && '' !== $row['scopes_json'] ) { $decoded = json_decode( (string) $row['scopes_json'], true ); if ( is_array( $decoded ) ) { $scopes = array_values( array_map( 'strval', $decoded ) ); } } return new Agent( (int) $row['id'], (string) $row['agent_key'], (string) $row['name'], (int) $row['wp_user_id'], null === $row['token_hash'] ? null : (string) $row['token_hash'], (string) $row['status'], new DateTimeImmutable( (string) $row['created_at'] ), $scopes, isset( $row['app_password_uuid'] ) && '' !== (string) $row['app_password_uuid'] ? (string) $row['app_password_uuid'] : null, isset( $row['admin_confirmed_by'] ) ? (int) $row['admin_confirmed_by'] : null, isset( $row['admin_confirmed_at'] ) && '' !== (string) $row['admin_confirmed_at'] ? new DateTimeImmutable( (string) $row['admin_confirmed_at'] ) : null ); } }