`. * On a real deployment that meant a forgotten `define()` produced no error — * the plugin just pointed at localhost and every downstream call failed later * with a confusing message. This class turns that into a reported condition: * * - the bootstrap calls note() for every value it resolves, recording where * the value came from (a wp-config constant, an environment variable, or * the built-in default); * - report() returns a per-variable status list the Diagnostics screen and * the onboarding wizard render; * - has_blocking_gaps() is true when a `prod` install is missing a required * value — that is a hard error and disables the affected surface * (see wizard_blocked() for the onboarding-wizard-specific gate). * * `dev` keeps the localhost fallbacks: a missing value there is informational. * `staging` surfaces the same gap as a warning — visible, but never blocking — * so a staging install can still be exercised end-to-end before every value * is finalised. */ declare(strict_types=1); defined( 'ABSPATH' ) || exit; final class Prweb_Config { /** Deployment environments, ordered dev → prod. Mirrors Prweb_Log. */ public const ENVIRONMENTS = [ 'dev', 'staging', 'prod' ]; public const SOURCE_WP_CONFIG = 'wp-config'; public const SOURCE_ENV = 'env'; public const SOURCE_DEFAULT = 'default'; public const SOURCE_MISSING = 'missing'; public const SEVERITY_OK = 'ok'; public const SEVERITY_INFO = 'info'; public const SEVERITY_WARN = 'warn'; public const SEVERITY_ERROR = 'error'; /** * Per-key resolution recorded by the bootstrap, keyed by the unprefixed * config name (e.g. `AUTH_API_BASE`). * * @var array */ private static array $seeded = []; /** * Record how the bootstrap resolved one configured value. Called from * property-rights-web.php as each constant is defined. * * @param string $key Unprefixed name, e.g. `PDS_API_BASE`. * @param string|null $operator_value The value the operator supplied, or null. * @param string $source One of the SOURCE_* constants. * @param string $resolved The value the plugin will actually use. * @param string|null $found_as Concrete constant / env-var spelling that resolved. */ public static function note( string $key, ?string $operator_value, string $source, string $resolved, ?string $found_as = null ): void { self::$seeded[ $key ] = [ 'value' => ( is_string( $operator_value ) && trim( $operator_value ) !== '' ) ? $operator_value : null, 'source' => $source, 'resolved' => $resolved, 'found_as' => $found_as, ]; } /** Test seam — drop every recorded resolution. */ public static function reset_for_tests(): void { self::$seeded = []; } /** * The required-configuration catalogue, in display order. * * `constant_backed` entries are resolved by the bootstrap and reported from * the note() record; the rest (ENV, VALIDATOR_INTERNAL_URL) are resolved * live here because nothing define()s a fallback for them. * * `required_for_wizard` marks the subset the onboarding wizard's `connect` * step actually reads (Prweb_Plugin::oauth() → redirect_registration_status() * / start_flow()) — ENV to know whether to enforce at all, plus the two * auth bases the OAuth handshake calls directly. The other four matter for * the rest of the plugin (wallet, PDS, validator, RUM) but nothing in the * connect step touches them, so they don't gate it. See * wizard_blocked() / wizard_rows() below — has_blocking_gaps() stays the * broader, site-wide "is any required value missing" signal used by the * admin_notices gap notice. * * `advisory` (default false) marks a value whose consumer already has its * own working fallback — VALIDATOR_INTERNAL_URL only, see the entry below. * A missing advisory value is always SEVERITY_INFO, in every environment; * it never blocks. A present-but-malformed value is still SEVERITY_ERROR * regardless — a broken override is a real error even if omitting it is fine. * * @return array,constant_backed:bool,required_for_wizard:bool,example:string,advisory?:bool}> */ public static function catalog(): array { return [ [ 'key' => 'ENV', 'label' => __( 'Deployment environment', 'propertyrightsweb' ), 'description' => __( 'Which environment this install runs as. Drives fail-closed behaviour and structured-log tagging.', 'propertyrightsweb' ), 'category' => __( 'Environment', 'propertyrightsweb' ), 'format' => 'enum', 'enum' => self::ENVIRONMENTS, 'constant_backed' => false, 'required_for_wizard' => true, 'example' => 'prod', ], [ 'key' => 'AUTH_API_BASE', 'label' => __( 'Auth service — server-to-server base URL', 'propertyrightsweb' ), 'description' => __( 'Where the WordPress server reaches the auth service for token exchange, userinfo, revoke and the PDS proxy.', 'propertyrightsweb' ), 'category' => __( 'Auth', 'propertyrightsweb' ), 'format' => 'url', 'enum' => [], 'constant_backed' => true, 'required_for_wizard' => true, 'example' => 'https://auth.example.com', ], [ 'key' => 'AUTH_PUBLIC_BASE', 'label' => __( 'Auth service — browser-facing base URL', 'propertyrightsweb' ), 'description' => __( 'The site origin a publisher\'s browser is redirected to during the OAuth handshake. Must resolve from the operator\'s machine.', 'propertyrightsweb' ), 'category' => __( 'Auth', 'propertyrightsweb' ), 'format' => 'url', 'enum' => [], 'constant_backed' => true, 'required_for_wizard' => true, 'example' => 'https://app.example.com', ], [ 'key' => 'OBSERVABILITY_PUBLIC_BASE', 'label' => __( 'UI observability ingest base URL', 'propertyrightsweb' ), 'description' => __( 'Browser-facing endpoint the wp-admin UI posts RUM telemetry to. Kept separate because the server-to-server auth URL may not be reachable from the browser.', 'propertyrightsweb' ), 'category' => __( 'Observability', 'propertyrightsweb' ), 'format' => 'url', 'enum' => [], 'constant_backed' => true, 'required_for_wizard' => false, 'example' => 'https://app.example.com', ], [ 'key' => 'WALLET_API_BASE', 'label' => __( 'Custodian wallet base URL', 'propertyrightsweb' ), 'description' => __( 'Where the WordPress server reaches the wallet service for balances, payout policy and payouts.', 'propertyrightsweb' ), 'category' => __( 'Wallet', 'propertyrightsweb' ), 'format' => 'url', 'enum' => [], 'constant_backed' => true, 'required_for_wizard' => false, 'example' => 'https://wallet.example.com', ], [ 'key' => 'VALIDATOR_INTERNAL_URL', 'label' => __( 'Validator — internal base URL', 'propertyrightsweb' ), 'description' => __( 'Optional override for server-to-server validator health probes. validator_internal_base() already falls back to the public validator URL configured in Settings, rewritten for container reachability — define this only when the internal address must differ from that public one.', 'propertyrightsweb' ), 'category' => __( 'Validator', 'propertyrightsweb' ), 'format' => 'url', 'enum' => [], 'constant_backed' => false, 'required_for_wizard' => false, 'advisory' => true, 'example' => 'https://validator.example.com', ], [ 'key' => 'PDS_API_BASE', 'label' => __( 'PDS API base URL', 'propertyrightsweb' ), 'description' => __( 'The PDS node the plugin settles payments and posts on-chain audit against. No built-in default — funding calls fail closed without it.', 'propertyrightsweb' ), 'category' => __( 'PDS', 'propertyrightsweb' ), 'format' => 'url', 'enum' => [], 'constant_backed' => true, 'required_for_wizard' => false, 'example' => 'https://pds.example.com', ], ]; } /** Resolved deployment environment; `dev` when unset or unrecognised. */ public static function environment(): string { $env = Prweb_Brand::config( 'ENV' ); return ( is_string( $env ) && in_array( $env, self::ENVIRONMENTS, true ) ) ? $env : 'dev'; } /** True on a `staging` / `prod` install — where a missing value is reported, at minimum, as a warning (a hard error on `prod` specifically). */ public static function is_strict(): bool { return self::environment() !== 'dev'; } /** * Per-variable status rows for the Diagnostics screen and the wizard. * * @return array */ public static function report(): array { $env = self::environment(); $rows = []; foreach ( self::catalog() as $entry ) { $key = $entry['key']; $constant = Prweb_Brand::env_name( $key ); if ( $entry['constant_backed'] && isset( self::$seeded[ $key ] ) ) { $seed = self::$seeded[ $key ]; $source = $seed['source']; $value = $seed['value'] ?? ''; $resolved = $seed['resolved']; $found_as = $seed['found_as']; } else { $origin = Prweb_Brand::config_origin( $key ); $value = (string) ( $origin['value'] ?? '' ); $found_as = $origin['name']; $source = $origin['source'] === 'wp-config' ? self::SOURCE_WP_CONFIG : ( $origin['source'] === 'env' ? self::SOURCE_ENV : self::SOURCE_MISSING ); $resolved = $value; } $present = in_array( $source, [ self::SOURCE_WP_CONFIG, self::SOURCE_ENV ], true ); $check = $present ? $value : $resolved; $valid = $check !== '' && self::value_is_valid( $entry, $check ); [ $severity, $detail ] = self::classify( $entry, $env, $source, $present, $valid, $resolved ); $rows[] = [ 'name' => $key, 'label' => $entry['label'], 'description' => $entry['description'], 'category' => $entry['category'], 'constant' => $constant, 'found_as' => ( $found_as !== null && $found_as !== $constant ) ? $found_as : null, 'found_as_kind' => ( $found_as !== null && in_array( $found_as, Prweb_Brand::env_names( $key ), true ) ) ? 'legacy' : ( $found_as !== null ? 'alias' : null ), 'source' => $source, 'value' => $present ? $value : ( $source === self::SOURCE_DEFAULT ? $resolved : '' ), 'present' => $present, 'valid' => $valid, 'severity' => $severity, 'ok' => in_array( $severity, [ self::SEVERITY_OK, self::SEVERITY_INFO ], true ), 'detail' => $detail, 'required_for_wizard' => $entry['required_for_wizard'], ]; } return $rows; } /** * Report rows that are an ERROR (present-but-invalid on any install, or * missing on a strict staging/prod install) — across ALL required * configuration, not just what the wizard reads. This is the site-wide * "something required is missing" signal: it drives * Prweb_Admin_Menu::maybe_render_config_gap_notice(), which is not * step-specific. For what actually gates the onboarding wizard's connect * step, see wizard_blocking_rows() / wizard_blocked() below — a broken * WALLET_API_BASE, say, is a real gap this method reports, but it does not * stop the wizard from starting the OAuth handshake, so it must not * disable that button. */ public static function blocking_rows(): array { return array_values( array_filter( self::report(), static fn( array $row ): bool => $row['severity'] === self::SEVERITY_ERROR ) ); } /** True when a strict install is missing or misconfiguring a required value, anywhere in the catalogue. */ public static function has_blocking_gaps(): bool { return self::blocking_rows() !== []; } /** Worst severity across the whole report — for the summary dot / notice. */ public static function worst_severity(): string { return self::worst_of( self::report() ); } /** * The report rows the onboarding wizard's `connect` step actually reads * (ENV + the two auth bases — see catalog()'s `required_for_wizard` flag). * A narrower slice of report(): everything the wizard needs is required * configuration, but not everything required configuration is something * the wizard needs. */ public static function wizard_rows(): array { return array_values( array_filter( self::report(), static fn( array $row ): bool => $row['required_for_wizard'] ) ); } /** wizard_rows() filtered to the ones that are an ERROR — what actually gates "Connect to PDS". */ public static function wizard_blocking_rows(): array { return array_values( array_filter( self::wizard_rows(), static fn( array $row ): bool => $row['severity'] === self::SEVERITY_ERROR ) ); } /** True when the wizard's connect step cannot proceed — the actual gate connect.php / ajax_oauth_start() enforce. */ public static function wizard_blocked(): bool { return self::wizard_blocking_rows() !== []; } /** Worst severity among wizard_rows() only — drives the connect-step banner tone. */ public static function wizard_worst_severity(): string { return self::worst_of( self::wizard_rows() ); } /** @param array $rows */ private static function worst_of( array $rows ): string { $rank = [ self::SEVERITY_OK => 0, self::SEVERITY_INFO => 1, self::SEVERITY_WARN => 2, self::SEVERITY_ERROR => 3 ]; $worst = self::SEVERITY_OK; foreach ( $rows as $row ) { if ( ( $rank[ $row['severity'] ] ?? 0 ) > ( $rank[ $worst ] ?? 0 ) ) { $worst = $row['severity']; } } return $worst; } /** * Plain-text rendering of the report — the "copy and share with support" * payload, matching the tone of the Diagnostics probes. */ public static function as_text(): string { $lines = [ Prweb_Brand::NAME . ' — required configuration (' . self::environment() . ')' ]; foreach ( self::report() as $row ) { $mark = [ self::SEVERITY_OK => 'OK ', self::SEVERITY_INFO => 'INFO', self::SEVERITY_WARN => 'WARN', self::SEVERITY_ERROR => 'FAIL', ][ $row['severity'] ] ?? '? '; $lines[] = sprintf( '%s %-26s %s', $mark, $row['constant'], $row['detail'] ); } return implode( "\n", $lines ) . "\n"; } // ------------------------------------------------------------------------- // Internal // ------------------------------------------------------------------------- /** @param array $entry */ private static function value_is_valid( array $entry, string $value ): bool { if ( $entry['format'] === 'enum' ) { return in_array( $value, $entry['enum'], true ); } // 'url' — an absolute http(s) URL with a host. $parts = wp_parse_url( $value ); if ( ! is_array( $parts ) ) { return false; } $scheme = strtolower( (string) ( $parts['scheme'] ?? '' ) ); return in_array( $scheme, [ 'http', 'https' ], true ) && ( $parts['host'] ?? '' ) !== ''; } /** * Decide the severity + human detail for one row. * * @param array $entry * @return array{0:string,1:string} */ private static function classify( array $entry, string $env, string $source, bool $present, bool $valid, string $resolved ): array { $constant = Prweb_Brand::env_name( $entry['key'] ); if ( $present && $valid ) { $where = $source === self::SOURCE_WP_CONFIG ? __( 'set in wp-config.php', 'propertyrightsweb' ) : __( 'set via environment variable', 'propertyrightsweb' ); return [ self::SEVERITY_OK, $where ]; } if ( $present && ! $valid ) { $expected = $entry['format'] === 'enum' ? sprintf( /* translators: %s is a comma-separated list of allowed values. */ __( 'expected one of: %s', 'propertyrightsweb' ), implode( ', ', $entry['enum'] ) ) : __( 'expected an absolute http(s) URL', 'propertyrightsweb' ); return [ self::SEVERITY_ERROR, sprintf( /* translators: 1: the constant name, 2: what a valid value looks like. */ __( '%1$s is set but malformed — %2$s.', 'propertyrightsweb' ), $constant, $expected ), ]; } // Not supplied by the operator. if ( ! empty( $entry['advisory'] ) ) { return [ self::SEVERITY_INFO, sprintf( /* translators: %s is the constant name. */ __( 'not set — optional, and this environment already falls back to a working default. Define %s only if the internal address needs to differ from the public one.', 'propertyrightsweb' ), $constant ), ]; } if ( $env === 'dev' ) { $detail = $resolved !== '' ? sprintf( /* translators: 1: constant name, 2: the built-in default value. */ __( 'not set — using the built-in dev default (%2$s). Define %1$s before deploying.', 'propertyrightsweb' ), $constant, $resolved ) : sprintf( /* translators: %s is the constant name. */ __( 'not set — define %s before deploying.', 'propertyrightsweb' ), $constant ); return [ self::SEVERITY_INFO, $detail ]; } $severity = $env === 'prod' ? self::SEVERITY_ERROR : self::SEVERITY_WARN; return [ $severity, sprintf( /* translators: 1: constant name, 2: environment name, 3: an example value. */ __( 'not set on a %2$s install — define %1$s in wp-config.php (e.g. %3$s).', 'propertyrightsweb' ), $constant, $env, $entry['example'] ), ]; } }