` map of * capability tokens currently held; values are always * literally `'true'`. Keys are validated as non-empty * trimmed strings so a typo'd capability list ("" / " ") * fails loudly here rather than silently masking a * deny-by-default capability check. * * Validation failures raise {@see InvalidArgumentException}; the * messages are developer-facing and never rendered to HTML. * * @package Affilun\Presentation\Rest */ declare(strict_types=1); namespace Affilun\Presentation\Rest; use InvalidArgumentException; /** * Immutable per-request §3 / §4 metadata carried through the * REST presentation pipeline. * * See the file-level docblock for the mapping to * `rest-api-spec.md` §3 / §4 and the per-field validation rules. */ final class RequestContext { /** * Grammar for the `requestId` and `correlationId` fields. * * Held as a private constant so the constructor and * {@see self::generate()} share the same regex literal. */ private const ID_GRAMMAR = '/^[a-zA-Z0-9_-]{8,64}$/'; /** * §4.1 request identifier stamped into the response envelope's * `meta.request_id` field. */ private string $requestId; /** * §4.1 correlation identifier stamped into the response * envelope's `meta.correlation_id` field, or null when the * field should be omitted from meta entirely. */ private ?string $correlationId; /** * WordPress user id of the caller, or null when the request is * anonymous (per §3.1 only the public settings endpoint * legitimately permits anonymous access). */ private ?int $userId; /** * Map of capability tokens currently held by the caller. * * Keys are capability tokens (`edit_posts`, `manage_options`, * `affilun_run_sync`, ...); values are always literally `'true'` * to keep the type uniform and the {@see self::hasCapability()} * check a single `array_key_exists` lookup. * * @var array */ private array $userCapabilities; /** * Construct a request context with explicit values for every * §3 / §4 field. * * @param string $requestId §4.1 request identifier. * @param string|null $correlationId §4.1 correlation identifier * or null to omit. * @param int|null $userId WordPress user id or null * when anonymous. * @param array $userCapabilities Map of capability tokens * currently held. * * @throws InvalidArgumentException When any field violates the * per-field validation rules. */ public function __construct( string $requestId, ?string $correlationId, ?int $userId, array $userCapabilities ) { if ( preg_match( self::ID_GRAMMAR, $requestId ) !== 1 ) { $message = sprintf( 'RequestContext requestId must match %s; got "%s".', self::ID_GRAMMAR, $requestId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $correlationId !== null && preg_match( self::ID_GRAMMAR, $correlationId ) !== 1 ) { $message = sprintf( 'RequestContext correlationId must match %s when non-null; got "%s".', self::ID_GRAMMAR, $correlationId ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized = array(); foreach ( $userCapabilities as $cap => $value ) { if ( ! is_string( $cap ) ) { $message = sprintf( 'RequestContext userCapabilities keys must be strings; got %s.', gettype( $cap ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $trimmed = trim( $cap ); if ( $trimmed === '' ) { $message = 'RequestContext userCapabilities keys must be non-empty trimmed strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( $value !== 'true' ) { $message = sprintf( 'RequestContext userCapabilities values must be literal "true"; got "%s" for "%s".', is_string( $value ) ? $value : gettype( $value ), $trimmed ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[ $trimmed ] = 'true'; } $this->requestId = $requestId; $this->correlationId = $correlationId; $this->userId = $userId; $this->userCapabilities = $normalized; } /** * §4.1 request identifier carried by this context. */ public function requestId(): string { return $this->requestId; } /** * §4.1 correlation identifier carried by this context, or null * when the field should be omitted from the response envelope. */ public function correlationId(): ?string { return $this->correlationId; } /** * WordPress user id of the caller, or null when anonymous. */ public function userId(): ?int { return $this->userId; } /** * Map of capability tokens currently held by the caller. * * Keys are capability tokens; values are always literally * `'true'`. * * @return array */ public function userCapabilities(): array { return $this->userCapabilities; } /** * Report whether the caller currently holds the given capability * token. * * @param string $capability Capability token to test. */ public function hasCapability( string $capability ): bool { return array_key_exists( $capability, $this->userCapabilities ); } /** * Return a copy of this context with the given correlation id. * * Pass `null` to drop the correlation id from the resulting * context (so the response envelope omits the field entirely); * pass a non-null value to set a new correlation id (validated * against the same grammar as the constructor). * * @param string|null $newId New correlation id, or null to drop. * * @throws InvalidArgumentException When `$newId` is non-null and * fails the grammar check. */ public function withCorrelationId( ?string $newId ): self { return new self( $this->requestId, $newId, $this->userId, $this->userCapabilities ); } /** * Generate a fresh request context with a random request id. * * The request id is `req_` + 16 hex chars * (`bin2hex(random_bytes(8))`), which fits the constructor's * 8..64 character grammar with margin to spare. * * The correlation id policy is: * * - `$externalCorrelationId === null` → no correlation id is * stamped onto the resulting context (so the response * envelope omits the field entirely). * * - `$externalCorrelationId !== null` → the supplied value is * validated against the constructor's grammar and used * verbatim. Callers that want a generated correlation id * should pass `'corr_' . bin2hex(random_bytes(8))` (or any * other grammar-compliant token) directly. * * @param int|null $userId WordPress user id, or null when anonymous. * @param array $capabilities Capability map for {@see self::__construct()}. * @param string|null $externalCorrelationId Validated external correlation id, or null * to omit. * * @throws InvalidArgumentException When `$externalCorrelationId` * is non-null and fails the * grammar check, or when * `$capabilities` carries an * invalid entry. */ public static function generate( ?int $userId, array $capabilities, ?string $externalCorrelationId = null ): self { $requestId = 'req_' . bin2hex( random_bytes( 8 ) ); return new self( $requestId, $externalCorrelationId, $userId, $capabilities ); } }