` whose keys * are non-empty strings and whose values are strings * (the empty string is allowed because the rotating- * input editor of `specifications/security-and-compliance.md` * §6.3 treats absence as "not yet configured"). * * - `enabled` is the provider-toggle flag the §13.4 * Providers tab exposes; it is independent of credential * completeness ({@see self::isConfigured()}). * * Per `specifications/security-and-compliance.md` §6.3 / §6.4 * the plaintext values MUST NEVER be logged, echoed, or * round-tripped through `__toString()` / `__debugInfo()`. * This VO therefore deliberately: * * - Does NOT implement `__toString()` so a `(string) $creds` * coercion would raise a TypeError rather than silently * leak the values. * - Does NOT implement `__debugInfo()` so a `var_dump()` of * the object during development still requires a deliberate * reflection step rather than printing secrets. * - Exposes a {@see self::masked()} helper that returns the * §6.3-masked map. Every REST / UI surface that needs to * surface credential values MUST use {@see self::masked()}; * {@see self::secrets()} / {@see self::secret()} are * reserved for the provider-implementation boundary that * actually signs outbound HTTP requests. * * Construction validates the structural invariants above and * raises {@see \InvalidArgumentException} with a developer- * facing message that NEVER includes the raw secret values. * * Instances are immutable; mutation helpers * ({@see self::withEnabled()} / {@see self::withSecret()}) * return fresh instances. * * @package Affilun\Application\Settings */ declare(strict_types=1); namespace Affilun\Application\Settings; use InvalidArgumentException; /** * Immutable per-provider credentials bundle. * * See the file-level docblock for the structural invariants * this VO enforces, the §6.3 masking rule that drives the * {@see self::masked()} accessor, and the rationale for not * exposing magic-method serialization hooks. */ final class ProviderCredentials { /** * Default Amazon Creators API credential version for the * Japan-focused affilun distribution. */ public const AMAZON_DEFAULT_VERSION = '3.3'; /** * Fixed Amazon marketplace for affilun's Japan-focused setup. */ public const AMAZON_MARKETPLACE = 'www.amazon.co.jp'; public const BUTTON_TEXT_COLOR_AUTO = 'auto'; public const BUTTON_TEXT_COLOR_BLACK = 'black'; public const BUTTON_TEXT_COLOR_WHITE = 'white'; /** * Default affiliate-button background colors, keyed by provider. * * @var array */ public const DEFAULT_BUTTON_COLORS = array( 'amazon' => '#f6a306', 'rakuten' => '#cf4944', 'yahoo_shopping' => '#51a7e8', 'mercari' => '#f2a1a1', ); /** * Lowercase provider identifier matching `^[a-z][a-z0-9_]*$`. */ private string $providerKey; /** * Provider-toggle flag exposed by the §13.4 Providers tab. */ private bool $enabled; /** * Opaque secrets map. Keys are provider-specific (e.g. * `access_key`, `application_id`) and values are stored * verbatim; the empty string is allowed as the "not yet * configured" placeholder. * * @var array */ private array $secrets; /** * Construct a credentials snapshot with explicit values for * every field. * * @param string $providerKey Lowercase provider identifier. * @param bool $enabled Provider-toggle flag. * @param array $secrets Opaque secrets map. * * @throws InvalidArgumentException When `$providerKey` violates * the `^[a-z][a-z0-9_]*$` * grammar, or when `$secrets` * contains a non-string key / * empty key / non-string * value. */ public function __construct( string $providerKey, bool $enabled, array $secrets ) { $this->providerKey = self::normalizeProviderKey( $providerKey ); $this->enabled = $enabled; $this->secrets = self::normalizeSecrets( $secrets ); } /** * Return the default affiliate-button color for a provider. * * @param string $providerKey Provider key. */ public static function defaultButtonColor( string $providerKey ): string { return self::DEFAULT_BUTTON_COLORS[ $providerKey ] ?? '#111827'; } /** * Normalize a user-supplied affiliate-button color. * * @param string $providerKey Provider key used for fallback. * @param string $value Candidate hex color. */ public static function normalizeButtonColor( string $providerKey, string $value ): string { $trimmed = trim( $value ); if ( preg_match( '/\A#[0-9a-fA-F]{6}\z/', $trimmed ) === 1 ) { return strtolower( $trimmed ); } return self::defaultButtonColor( $providerKey ); } /** * Normalize the provider-specific button text color preference. * * @param string $value Raw preference token. */ public static function normalizeButtonTextColor( string $value ): string { $normalized = strtolower( trim( $value ) ); return in_array( $normalized, array( self::BUTTON_TEXT_COLOR_AUTO, self::BUTTON_TEXT_COLOR_BLACK, self::BUTTON_TEXT_COLOR_WHITE ), true ) ? $normalized : self::BUTTON_TEXT_COLOR_AUTO; } /** * Resolve the actual text color for a button background and preference. * * @param string $backgroundColor Candidate hex background color. * @param string $preference One of auto/black/white. */ public static function resolveButtonTextColor( string $backgroundColor, string $preference ): string { return match ( self::normalizeButtonTextColor( $preference ) ) { self::BUTTON_TEXT_COLOR_BLACK => '#000000', self::BUTTON_TEXT_COLOR_WHITE => '#ffffff', default => self::automaticButtonTextColor( $backgroundColor ), }; } /** * Return an accessible black/white text color for a button background. * * Uses an APCA-style perceptual contrast comparison between black and white. * This better matches UI text readability than a simple luminance threshold, * while still keeping the output intentionally constrained to black/white. * * @param string $backgroundColor Candidate hex background color. */ public static function automaticButtonTextColor( string $backgroundColor ): string { $background = self::normalizeButtonColor( 'custom', $backgroundColor ); $blackScore = abs( self::apcaContrast( '#000000', $background ) ); $whiteScore = abs( self::apcaContrast( '#ffffff', $background ) ); return $whiteScore > $blackScore ? '#ffffff' : '#000000'; } /** * Calculate APCA-style contrast for text on a background. * * Positive values mean dark text on a light background; negative values mean * light text on a dark background. Callers compare absolute values when they * only need to choose the stronger black/white polarity. * * @param string $textColor Normalized `#rrggbb` text color. * @param string $backgroundColor Normalized `#rrggbb` background color. */ private static function apcaContrast( string $textColor, string $backgroundColor ): float { $textY = self::softClampLuminance( self::relativeLuminanceFromHex( $textColor ) ); $backgroundY = self::softClampLuminance( self::relativeLuminanceFromHex( $backgroundColor ) ); if ( abs( $backgroundY - $textY ) < 0.0005 ) { return 0.0; } if ( $backgroundY > $textY ) { $contrast = ( ( $backgroundY ** 0.56 ) - ( $textY ** 0.57 ) ) * 1.14; return $contrast < 0.1 ? 0.0 : ( $contrast - 0.027 ) * 100; } $contrast = ( ( $backgroundY ** 0.65 ) - ( $textY ** 0.62 ) ) * 1.14; return $contrast > -0.1 ? 0.0 : ( $contrast + 0.027 ) * 100; } /** * Soft-clamp near-black luminance per APCA's black threshold behavior. * * @param float $luminance WCAG relative luminance. */ private static function softClampLuminance( float $luminance ): float { $threshold = 0.022; return $luminance >= $threshold ? $luminance : $luminance + ( ( $threshold - $luminance ) ** 1.414 ); } /** * Calculate WCAG relative luminance for a hex color. * * @param string $color Normalized `#rrggbb` color. */ private static function relativeLuminanceFromHex( string $color ): float { $red = hexdec( substr( $color, 1, 2 ) ); $green = hexdec( substr( $color, 3, 2 ) ); $blue = hexdec( substr( $color, 5, 2 ) ); return self::relativeLuminance( $red, $green, $blue ); } /** * Calculate WCAG relative luminance for an RGB color. * * @param int $red Red channel, 0-255. * @param int $green Green channel, 0-255. * @param int $blue Blue channel, 0-255. */ private static function relativeLuminance( int $red, int $green, int $blue ): float { $channels = array_map( static function ( int $channel ): float { $value = $channel / 255; return $value <= 0.04045 ? $value / 12.92 : ( ( $value + 0.055 ) / 1.055 ) ** 2.4; }, array( $red, $green, $blue ) ); return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2]; } /** * Construct an Amazon credentials snapshot. * * **KI-019 Phase B (ADR-0022)**: 旧 PA-API 5.0 shape (`access_key` * / `secret_key` / `associate_tag` + AWS SigV4) を完全に置換し、 * Amazon Creators API の OAuth 2.0 credentials shape を採用。 * 旧 shape の Access Key / Secret Key は本 factory では受け付けず、 * 旧 PA-API credentials を保存していた利用者は Associates Central で * Creators API credentials を新規発行する必要がある (自動変換は不可能、 * ADR-0022 §2.2)。 * * The secrets map carries: * * - `credential_id` — Creators API Credential ID * (旧 Access Key の置換、Associates Central で発行) * - `credential_secret` — Creators API Credential Secret * (旧 Secret Key の置換、発行時に 1 回だけ表示される) * - `version` — Credential Version (`2.1` / `2.2` / `2.3` / * `3.1` / `3.2` / `3.3` の closed enum、token endpoint と * region 切替に使用、`provider-api-contracts.md` §9.1 の * "Token 取得エンドポイント" テーブル参照) * - `partner_tag` — Associates Partner Tag (旧 Associate Tag、 * アフィリエイトリンクの `tag=` パラメータに使用) * - `marketplace` — Target Amazon marketplace. affilun is * Japan-focused and fixes this to `www.amazon.co.jp`; the * value is stamped into both the `x-marketplace` header and * request body `marketplace` field. * * Empty strings are permitted so the rotating-input editor can * persist "enabled but not yet configured" intermediate states. * * @param bool $enabled Provider-toggle flag. * @param string $credentialId Creators API Credential ID. * @param string $credentialSecret Creators API Credential Secret. * @param string $version Credential Version (`2.1` / `2.2` / * `2.3` / `3.1` / `3.2` / `3.3`). * @param string $partnerTag Associates Partner Tag. * @param string $marketplace Target Amazon marketplace * (e.g. `www.amazon.co.jp`). */ public static function amazon( bool $enabled, string $credentialId, string $credentialSecret, string $version, string $partnerTag, string $marketplace ): self { return new self( 'amazon', $enabled, array( 'credential_id' => $credentialId, 'credential_secret' => $credentialSecret, 'version' => $version, 'partner_tag' => $partnerTag, 'marketplace' => $marketplace, ) ); } /** * Construct a Rakuten credentials snapshot. * * The secrets map carries the current Rakuten Web Service * authentication shape: * * - `application_id` — application identifier. * - `access_key` — required API access key. * - `affiliate_id` — affiliate identifier used to request * affiliate URLs. * * @param bool $enabled Provider-toggle flag. * @param string $applicationId Rakuten Application ID. * @param string $affiliateId Rakuten Affiliate ID. * @param string $accessKey Rakuten Access Key. */ public static function rakuten( bool $enabled, string $applicationId, string $affiliateId, string $accessKey = '' ): self { return new self( 'rakuten', $enabled, array( 'application_id' => $applicationId, 'access_key' => $accessKey, 'affiliate_id' => $affiliateId, ) ); } /** * Construct a Yahoo Shopping credentials snapshot. * * The secrets map carries `app_id` (Yahoo Developer Network * Client ID) plus optional ValueCommerce `affiliate_sid` / * `affiliate_pid` fields used for Yahoo!ショッピング reward * attribution. The legacy `affiliate_parameters` slot is still * accepted for backward compatibility with already-persisted * settings rows but is treated as opaque. * * @param bool $enabled Provider-toggle flag. * @param string $appId Yahoo Client ID. * @param string|null $affiliateParameters Optional opaque * legacy parameter * string; pass null * (the default) to * omit the slot. * @param string|null $affiliateSid ValueCommerce sid. * @param string|null $affiliatePid ValueCommerce pid. */ public static function yahoo( bool $enabled, string $appId, ?string $affiliateParameters = null, ?string $affiliateSid = null, ?string $affiliatePid = null ): self { $secrets = array( 'app_id' => $appId, ); if ( $affiliateParameters !== null ) { $secrets['affiliate_parameters'] = $affiliateParameters; } if ( $affiliateSid !== null ) { $secrets['affiliate_sid'] = $affiliateSid; } if ( $affiliatePid !== null ) { $secrets['affiliate_pid'] = $affiliatePid; } return new self( 'yahoo_shopping', $enabled, $secrets ); } /** * Construct a Mercari provider toggle snapshot. * * 0.4.0 uses Mercari for public search-button links. There is no API / SDK, * but the Ambassador `afid` is stored so links can be stamped as affiliate * URLs. * * @param bool $enabled Provider-toggle flag. * @param string $afid Mercari Ambassador affiliate id. */ public static function mercari( bool $enabled, string $afid = '' ): self { return new self( 'mercari', $enabled, array( 'afid' => $afid, ) ); } /** * Lowercase provider identifier. */ public function providerKey(): string { return $this->providerKey; } /** * Provider-toggle flag exposed by the §13.4 Providers tab. */ public function enabled(): bool { return $this->enabled; } /** * Full secrets map. * * Returned verbatim. Callers that need to surface the values * to UI / REST surfaces MUST use {@see self::masked()} * instead; this accessor is reserved for the provider * implementation boundary that signs outbound HTTP requests. * * @return array */ public function secrets(): array { return $this->secrets; } /** * Look up a single secret by key. * * Returns null when the key is absent so callers can * distinguish "not configured" from "configured as the * empty string". * * @param string $key Secret key. */ public function secret( string $key ): ?string { if ( ! array_key_exists( $key, $this->secrets ) ) { return null; } return $this->secrets[ $key ]; } /** * Produce a copy of this snapshot with `enabled` replaced. * * @param bool $enabled New provider-toggle flag. */ public function withEnabled( bool $enabled ): self { $clone = clone $this; $clone->enabled = $enabled; return $clone; } /** * Produce a copy of this snapshot with the supplied secret * key set to the supplied value. * * Adds the entry when missing, overwrites it when present. * The key is validated against the same non-empty-string * rule as the constructor. * * @param string $key Secret key. * @param string $value Secret value (empty string permitted). * * @throws InvalidArgumentException When `$key` is the empty * string after trimming. */ public function withSecret( string $key, string $value ): self { $normalizedKey = trim( $key ); if ( $normalizedKey === '' ) { $message = 'ProviderCredentials secret key must be a non-empty string.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $clone = clone $this; $clone->secrets[ $normalizedKey ] = $value; return $clone; } /** * Report whether every secret value is non-empty after * trimming. * * `enabled` is intentionally NOT consulted here: this probe * answers "are the values complete enough to attempt an API * call?", which the §13.4 Validate Connection button needs * regardless of the toggle state. */ public function isConfigured(): bool { if ( $this->secrets === array() ) { return false; } foreach ( $this->secrets as $value ) { if ( trim( $value ) === '' ) { return false; } } return true; } /** * Return the secrets map with every value replaced by its * §6.3-masked form. * * Used by every REST / UI surface that needs to surface * credential values to the admin. The keys are preserved so * the masked map round-trips through the rotating-input * editor unchanged. * * @return array */ public function masked(): array { $masked = array(); foreach ( $this->secrets as $key => $value ) { $masked[ $key ] = SettingsCredentialMask::mask( $value ); } return $masked; } /** * Report whether this snapshot equals the supplied one. * * Equality is structural: the provider key, enabled flag, * and the full secrets map must match under strict * comparison. * * @param self $other Other snapshot to compare against. */ public function equals( self $other ): bool { return $this->providerKey === $other->providerKey && $this->enabled === $other->enabled && $this->secrets === $other->secrets; } /** * Serialize this snapshot for {@see \Affilun\Infrastructure\Persistence\OptionStore} * persistence. * * The shape is the inverse of {@see self::fromArray()}: * `array('provider_key' => ..., 'enabled' => ..., 'secrets' => $secrets)`. * * @return array */ public function toArray(): array { return array( 'provider_key' => $this->providerKey, 'enabled' => $this->enabled, 'secrets' => $this->secrets, ); } /** * Construct a snapshot from a {@see self::toArray()} payload. * * Performs full structural validation: missing keys, wrong * types, and the same per-field rules the constructor * enforces all surface as {@see InvalidArgumentException} * rather than silently coercing. * * @param array $data Persisted payload. * * @throws InvalidArgumentException When `$data` is malformed. */ public static function fromArray( array $data ): self { if ( ! array_key_exists( 'provider_key', $data ) || ! is_string( $data['provider_key'] ) ) { $message = 'ProviderCredentials payload must carry a string `provider_key`.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! array_key_exists( 'enabled', $data ) || ! is_bool( $data['enabled'] ) ) { $message = 'ProviderCredentials payload must carry a bool `enabled`.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! array_key_exists( 'secrets', $data ) || ! is_array( $data['secrets'] ) ) { $message = 'ProviderCredentials payload must carry an array `secrets`.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $secrets = $data['secrets']; if ( $data['provider_key'] === 'rakuten' && ! array_key_exists( 'access_key', $secrets ) ) { // Pre-0.8.0 settings remain intact but must be treated as // incomplete until the new Rakuten Access Key is entered. $secrets['access_key'] = ''; } return new self( $data['provider_key'], $data['enabled'], $secrets ); } /** * Validate and normalize the `providerKey` field at * construction. * * @param string $providerKey Raw provider key. * * @throws InvalidArgumentException When the key violates the * `^[a-z][a-z0-9_]*$` * grammar. */ private static function normalizeProviderKey( string $providerKey ): string { if ( preg_match( '/^[a-z][a-z0-9_]*$/', $providerKey ) !== 1 ) { $message = 'ProviderCredentials providerKey must match ^[a-z][a-z0-9_]*$.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } return $providerKey; } /** * Validate and normalize the `secrets` map at construction. * * Keys must be non-empty strings; values must be strings * (empty string permitted). The map's iteration order is * preserved verbatim because callers may rely on it for * stable serialization. * * @param array $secrets Raw secrets map. * * @return array * * @throws InvalidArgumentException When `$secrets` violates * the per-key / per-value * rules above. */ private static function normalizeSecrets( array $secrets ): array { $result = array(); foreach ( $secrets as $key => $value ) { if ( ! is_string( $key ) || trim( $key ) === '' ) { $message = 'ProviderCredentials secrets keys must be non-empty strings.'; // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! is_string( $value ) ) { $message = sprintf( 'ProviderCredentials secret `%s` must be a string.', $key ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $result[ $key ] = $value; } return $result; } }