$input The exact input it will run with. * * @return void * @since 1.5.0 */ public static function arm( string $ability_name, array $input ): void { self::$fingerprint = self::fingerprint( $ability_name, $input ); self::$minted_at = time(); } /** * Consume the ticket if it authorises this exact execution. * * One-shot: a match clears the ticket so it can never authorise a second * execution. A mismatch leaves the ticket untouched and returns false, so the * call is gated normally. A ticket older than {@see self::TTL_SECONDS} is * treated as an orphan — discarded and refused — which closes the window where * a ticket minted but never consumed (a fatal before the ability ran) could * match a later call in a persistent worker. * * @param string $ability_name The ability being intercepted. * @param array $input Its resolved input. * @param int|null $now Current Unix time; defaults to now. * * @return bool True when this execution was authorised by an armed ticket. * @since 1.5.0 */ public static function consume( string $ability_name, array $input, ?int $now = null ): bool { if ( self::$fingerprint === null ) { return false; } $now = $now ?? time(); if ( self::$minted_at === null || ( $now - self::$minted_at ) > self::TTL_SECONDS ) { // Orphaned or expired: discard it so it can never match again. self::clear(); return false; } if ( ! hash_equals( self::$fingerprint, self::fingerprint( $ability_name, $input ) ) ) { return false; } self::clear(); return true; } /** * Whether a ticket is currently armed. * * Exists so a test can assert the ticket is live *during* the approved run * and gone afterwards, including when the ability throws. {@see self::consume()} * destroys on a match, so it cannot answer that question without changing it. * * Deliberately says only whether, never what: the fingerprint stays private, * so this cannot help anybody reconstruct one. * * @return bool * @since 1.5.0 */ public static function is_armed(): bool { return self::$fingerprint !== null; } /** * Discard any armed ticket. Called in a `finally` around the approved run so * a ticket never outlives the execution it was minted for. * * @return void * @since 1.5.0 */ public static function clear(): void { self::$fingerprint = null; self::$minted_at = null; } /** * The fingerprint binding a ticket to one ability + input. * * Both sides compute it the same way over the same array, so the encode is * deterministic for equal input. * * @param string $ability_name The ability. * @param array $input Its resolved input. * * @return string 64-char sha256 hex. * @since 1.5.0 */ public static function fingerprint( string $ability_name, array $input ): string { return hash( 'sha256', $ability_name . '|' . (string) wp_json_encode( $input ) ); } }