user_login : ''; return $record; }, self::all() ) ); } /** * Mint a credential. The returned `secret` is the ONLY time it exists. * * @param string $name Administrator-supplied label. * @param int $user_id Account the credential acts as. * @param string $access_level ToolDescriptor::ACCESS_READ|ACCESS_FULL. * @return array{id:string,secret:string,record:array} */ public static function create( string $name, int $user_id, string $access_level ): array { $secret = bin2hex( random_bytes( 32 ) ); $id = 'cred_' . bin2hex( random_bytes( 8 ) ); $record = [ 'id' => $id, 'name' => $name !== '' ? $name : __( 'Untitled connection', 'templately' ), 'token_hash' => hash( 'sha256', $secret ), 'user_id' => $user_id, 'access_level' => self::normalize_level( $access_level ), 'created_at' => time(), 'last_used_at' => null, ]; $records = self::all(); $records[ $id ] = $record; update_option( self::OPTION, $records, 'no' ); return [ 'id' => $id, 'secret' => $secret, 'record' => $record ]; } /** * Resolve a presented secret to its record, or null. * * Compares with hash_equals against every record. N is single digits in * practice, and a linear scan keeps the comparison constant-time per record. * * @param string $secret * @return array|null */ public static function find_by_secret( string $secret ): ?array { if ( '' === $secret ) { return null; } $presented = hash( 'sha256', $secret ); foreach ( self::all() as $record ) { if ( ! empty( $record['token_hash'] ) && hash_equals( (string) $record['token_hash'], $presented ) ) { return $record; } } return null; } /** * @param string $id * @param string $access_level * @return bool */ public static function set_access_level( string $id, string $access_level ): bool { $records = self::all(); if ( ! isset( $records[ $id ] ) ) { return false; } $records[ $id ]['access_level'] = self::normalize_level( $access_level ); // Takes effect on the very next request — no client reconfiguration // needed, because the level is read per-request from this record (FR-027). return update_option( self::OPTION, $records, 'no' ); } /** * Revoke ONE credential. Every other credential keeps working (FR-020b). * * @param string $id * @return bool */ public static function revoke( string $id ): bool { $records = self::all(); if ( ! isset( $records[ $id ] ) ) { return false; } unset( $records[ $id ] ); return update_option( self::OPTION, $records, 'no' ); } /** * Revoke everything and return the endpoint to inert (FR-024a). * Also clears delegated records — one kill switch covers both credential * systems, so an administrator never has to revoke twice. * * @return void */ public static function revoke_all(): void { delete_option( self::OPTION ); if ( class_exists( OAuth\RecordStore::class ) ) { OAuth\RecordStore::purge_all(); } } /** * Whether the site holds any credential. While false, every request to the * endpoint is refused — the endpoint is inert until an administrator * explicitly connects (FR-023). * * @return bool */ public static function site_has_any(): bool { if ( ! empty( self::all() ) ) { return true; } return class_exists( OAuth\RecordStore::class ) && OAuth\RecordStore::has_any(); } /** * Record use, throttled. * * @param string $id * @return void */ public static function touch( string $id ): void { $records = self::all(); if ( ! isset( $records[ $id ] ) ) { return; } $last = (int) ( $records[ $id ]['last_used_at'] ?? 0 ); if ( ( time() - $last ) < self::LAST_USED_THROTTLE ) { return; } $records[ $id ]['last_used_at'] = time(); update_option( self::OPTION, $records, 'no' ); } /** * Anything not explicitly "read" is full access — fail closed (FR-026c). * * @param string $level * @return string */ public static function normalize_level( string $level ): string { return ToolDescriptor::ACCESS_READ === $level ? ToolDescriptor::ACCESS_READ : ToolDescriptor::ACCESS_FULL; } }