events = new Prweb_Events_Store(); $this->stats = new Prweb_Admin_Stats(); $this->admin_menu = new Prweb_Admin_Menu( $this->stats, $this->events ); $this->rsl_document = new Prweb_Rsl_Document(); $this->diagnostics = new Prweb_Diagnostics(); $this->ajax_assets = new Prweb_Ajax_Assets( $this->events, $this->rsl_document, $this->diagnostics ); $this->ajax_offers = new Prweb_Ajax_Offers( $this->events, $this->rsl_document ); $this->ajax_account = new Prweb_Ajax_Account( $this->events ); $this->ajax_events = new Prweb_Ajax_Events( $this->events ); $this->ajax_settings = new Prweb_Ajax_Settings( $this->events ); $this->ajax_onboarding = new Prweb_Ajax_Onboarding( $this->events, $this->stats, $this->diagnostics ); $this->ajax_payouts = new Prweb_Ajax_Payouts( $this->events ); $this->ajax_wallet = new Prweb_Ajax_Wallet( $this->events ); $this->rest_routes = new Prweb_Rest_Routes( $this->events, $this->rsl_document ); $this->reader_frontend = new Prweb_Reader_Frontend(); $this->login_redirect = new Prweb_Login_Redirect(); register_activation_hook( PRWEB_FILE, [ $this, 'on_activate' ] ); add_action( 'plugins_loaded', [ $this, 'maybe_upgrade_schema' ] ); // No explicit text-domain load: WP ≥ 4.6 auto-loads translations for // the plugin's own domain from the header's `Text Domain` + // `Domain Path`, and WordPress.org serves them from the directory. // `load_plugin_textdomain()` has been discouraged since 4.6 (ORL-110). $this->admin_menu->register_hooks(); $this->ajax_assets->register_hooks(); $this->ajax_offers->register_hooks(); $this->ajax_account->register_hooks(); $this->ajax_events->register_hooks(); $this->ajax_settings->register_hooks(); $this->ajax_onboarding->register_hooks(); $this->ajax_payouts->register_hooks(); $this->ajax_wallet->register_hooks(); $this->rest_routes->register_hooks(); // wp-login.php: an admin who already holds a session gets the // dashboard instead of a form with nothing left to do. $this->login_redirect->register_hooks(); // OSS-renderer opt-in (ORL-33). When the public `openrsl-wordpress` // Tier-1 plugin is active it owns rsl.xml + the robots.txt License line // and pulls its rules through the `openrsl_rsl_rule_source` filter; we // register the PDS-backed source on that filter so the OSS renderer // emits our priced, per-asset rules without depending on this private // add-on. The PDS-backed source itself stays private here // (Prweb_Rsl_Document::rule_source) — only the rule DATA crosses the // filter seam, mapped onto the OSS binding-namespace contract. add_filter( 'openrsl_rsl_rule_source', [ $this, 'pds_rsl_rule_source' ] ); // Only own rsl.xml + robots.txt ourselves when the OSS renderer is NOT // present, so the two plugins never double-serve `/rsl.xml`. With the // OSS plugin active, OpenRSL_Rsl_Document registers those hooks and // consumes our filter; standalone, we keep our own document hooks. if ( ! self::oss_renderer_active() ) { $this->rsl_document->register_hooks(); } $this->reader_frontend->register_hooks(); } /** * True when the public `openrsl-wordpress` Tier-1 plugin is loaded. We key * off its renderer class rather than the plugin file so the check works * regardless of the OSS plugin's install path or load order. */ public static function oss_renderer_active(): bool { return class_exists( 'OpenRSL_Rsl_Renderer' ); } /** * `openrsl_rsl_rule_source` filter callback — supply the PDS-backed rule * source to the OSS renderer. Maps the private flat-key rule shape * (`sellerId`/`dabId`/`offerId`/`scope`) onto the OSS renderer's * binding-namespace contract (a `pds:`-prefixed `bindings` map per rule), * preserving attribute order so output stays byte-identical with the * private renderer. * * @param mixed $default The OSS default rule source (ignored — we replace it). * @return array{tokenUrl:string,ns:string,nsUri:string,rules:array>} */ public function pds_rsl_rule_source( $default ): array { $source = $this->rsl_document->rule_source(); $rules = []; foreach ( $source['rules'] as $rule ) { $bindings = [ 'sellerId' => (string) ( $rule['sellerId'] ?? '' ), 'dabId' => (int) ( $rule['dabId'] ?? 0 ), 'offerId' => (int) ( $rule['offerId'] ?? 0 ), ]; if ( isset( $rule['scope'] ) && (string) $rule['scope'] !== '' ) { $bindings['scope'] = (string) $rule['scope']; } $rules[] = [ 'url' => (string) ( $rule['url'] ?? '' ), 'permits' => $rule['permits'] ?? null, 'prohibits' => $rule['prohibits'] ?? null, 'paymentType' => $rule['paymentType'] ?? Prweb_Rsl_Renderer::DEFAULT_PAYMENT_TYPE, 'bindings' => $bindings, ]; } return [ 'tokenUrl' => (string) $source['tokenUrl'], 'ns' => 'pds', 'nsUri' => Prweb_Rsl_Renderer::PDS_RSL_NAMESPACE_URI, 'rules' => $rules, ]; } // ------------------------------------------------------------------------- // Lifecycle — public so register_activation_hook can call it // ------------------------------------------------------------------------- public function on_activate(): void { // Activation can be the first code to run after an in-place upgrade, // so the prefix move has to happen before install_schema() creates // anything under the new prefix. Idempotent; a fresh install no-ops. Prweb_Migration_Storage_Prefix_2026_09_01::run(); $this->install_schema(); $this->maybe_seed_first_run_wizard(); } /** * Park the first-run wizard at the canonical entry step on a fresh install so * the takeover renderer can pick it up on the next admin page load. We * do this here (rather than from a template) so rendering remains * side-effect-free, and so the seeding happens once per install * instead of once per page load. */ private function maybe_seed_first_run_wizard(): void { $stage = Prweb_Ajax_Onboarding::get_stage(); $onboarded = '1' === (string) get_option( Prweb_Brand::option( 'onboarded' ), '' ); $user_id = self::current_pds_user_id(); if ( $stage === '' && ! $onboarded && ( $user_id === null || $user_id === '' ) ) { Prweb_Ajax_Onboarding::set_stage( Prweb_Wizard_Stages::entry_step() ); } } public function maybe_upgrade_schema(): void { // FIRST, before anything reads an option or touches a table. An install // upgrading from a prior prefix still holds `db_version` under the old // option name, so the check below would read empty and drive // install_schema() into creating a fresh, empty set of tables beside // the publisher's populated ones — after which the rename has a // non-empty target and skips, orphaning the real data (ORL-213). Prweb_Migration_Storage_Prefix_2026_09_01::run(); if ( (string) get_option( Prweb_Brand::option( 'db_version' ) ) !== PRWEB_VERSION ) { $this->install_schema(); return; } // Version-matched installs can still carry the pre-canonical events // table if the older migration was skipped or partially applied. Prweb_Migration_Audit_Event_Canon_Repair_2026_04_28::run(); Prweb_Migration_Reader_Registration_Idempotency_2026_08_12::run(); Prweb_Migration_Debug_Log_Dir_2026_09_08::run(); Prweb_Migration_Legacy_Static_Rsl_2026_10_05::run(); } // ------------------------------------------------------------------------- // Schema bootstrap — also delegates to Events_Store for the events table // ------------------------------------------------------------------------- private function install_schema(): void { global $wpdb; $charset = $wpdb->get_charset_collate(); $assets = Prweb_Brand::table( self::TABLE ); require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( "CREATE TABLE {$assets} ( id bigint(20) NOT NULL AUTO_INCREMENT, post_id bigint(20) NOT NULL, pds_dab_id bigint(20) DEFAULT NULL, content_hash varchar(64) DEFAULT NULL, pds_offer_id bigint(20) DEFAULT NULL, rsl_terms text DEFAULT NULL, status varchar(20) NOT NULL DEFAULT 'open', updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY post_id (post_id), KEY status (status) ) {$charset};" ); $this->events->install_schema( $charset ); // R3 Phase E step 7 — audit-event canonicalization migration. // Forward-only, idempotent (gated by `wp_options['prweb_audit_canon_v1_done']`). // Per distributed-data-reviewer-2026-04-27-20 Part B: aligns the events table to // shared/schemas/audit-event.schema.json (snapshot 2026-04-27). Prweb_Migration_Audit_Event_Canon_2026_04_27::run(); Prweb_Migration_Audit_Event_Canon_Repair_2026_04_28::run(); Prweb_Migration_Reader_Registration_Idempotency_2026_08_12::run(); Prweb_Migration_Debug_Log_Dir_2026_09_08::run(); Prweb_Migration_Legacy_Static_Rsl_2026_10_05::run(); update_option( Prweb_Brand::option( 'db_version' ), PRWEB_VERSION ); // Rewrite rules need a flush whenever we (re-)install. flush_rewrite_rules( false ); if ( (string) get_option( Prweb_Brand::option( 'rsl_mode' ), 'dynamic' ) !== 'static' ) { $this->rsl_document->clean_stale_static_rsl(); } } // ------------------------------------------------------------------------- // Public passthrough accessors — required by templates that reach $plugin // ------------------------------------------------------------------------- public function catalog_summary(): array { return $this->stats->catalog_summary(); } public function activity_24h(): array { return $this->stats->activity_24h(); } public function recent_events( int $limit = 10, ?string $kind_prefix = null ): array { return $this->events->recent_events( $limit, $kind_prefix ); } // ------------------------------------------------------------------------- // Static forwarding shims — templates call Prweb_Plugin::humanize_event() // and Prweb_Plugin::relative_time() directly; these delegate to Events_Store. // ------------------------------------------------------------------------- public static function humanize_event( string $kind, $payload, ?string $occurred_at = null ): array { return Prweb_Events_Store::humanize_event( $kind, $payload, $occurred_at ); } public static function relative_time( string $occurred_at ): string { return Prweb_Events_Store::relative_time( $occurred_at ); } // ------------------------------------------------------------------------- // OAuth + auth-proxy accessors — lazy so unit tests can construct the // plugin without a wp-admin URL context. // ------------------------------------------------------------------------- public static function instance(): ?self { return self::$instance; } /** * Static accessor for the onboarding AJAX collaborator. Used by the * OAuth callback handler in Admin_Menu to record wizard.step_completed * for the `connect` milestone when the publisher returns from * the auth service mid-flow. Returns null when the plugin is bootstrapped * outside an admin context (cron, CLI) — callers MUST tolerate. */ public static function ajax_onboarding(): ?Prweb_Ajax_Onboarding { return self::$instance?->ajax_onboarding ?? null; } public static function oauth(): Prweb_OAuth_Client { $self = self::$instance ?? new self(); return $self->get_oauth(); } public static function pds_proxy(): Prweb_Pds_Auth_Proxy { $self = self::$instance ?? new self(); return $self->get_pds_proxy(); } public function get_oauth(): Prweb_OAuth_Client { if ( $this->oauth === null ) { $this->oauth = new Prweb_OAuth_Client( self::auth_base_url(), PRWEB_OAUTH_CLIENT_ID, self::redirect_uri(), self::auth_public_base_url(), rest_url( 'pds/v1/events' ) ); } return $this->oauth; } public function get_pds_proxy(): Prweb_Pds_Auth_Proxy { if ( $this->pds_proxy === null ) { $this->pds_proxy = new Prweb_Pds_Auth_Proxy( self::auth_base_url(), $this->get_oauth() ); } return $this->pds_proxy; } /** The auth service's bind address for server-to-server calls (token exchange, PDS proxy). */ private static function auth_base_url(): string { return defined( 'PRWEB_AUTH_API_BASE' ) ? (string) PRWEB_AUTH_API_BASE : ''; } /** The origin where the publisher's browser starts the OAuth dance. Same-origin proxy fronts the auth service from this URL. */ private static function auth_public_base_url(): string { return defined( 'PRWEB_AUTH_PUBLIC_BASE' ) ? (string) PRWEB_AUTH_PUBLIC_BASE : ''; } private static function redirect_uri(): string { $override = Prweb_Brand::defined_value( 'OAUTH_REDIRECT_URI' ); if ( null !== $override && (string) $override !== '' ) { return (string) $override; } return admin_url( 'admin.php?page=' . Prweb_Brand::page( 'oauth-callback' ) ); } // ------------------------------------------------------------------------- // Single-source-of-truth helpers — Token_Store first, legacy denorm cache // only when emergency password-auth fallback is enabled via constant. // ------------------------------------------------------------------------- public static function is_legacy_auth_enabled(): bool { // Test seam: tests cannot redefine constants, so honour an override // global. Production code never sets this — only run.php fixtures do. if ( isset( $GLOBALS['__prweb_force_legacy'] ) ) { return (bool) $GLOBALS['__prweb_force_legacy']; } return true === Prweb_Brand::defined_value( 'LEGACY_PASSWORD_AUTH' ); } public static function current_pds_user_id(): ?string { $id = Prweb_Token_Store::get_pds_user_id(); if ( $id !== null && $id !== '' ) { return $id; } if ( self::is_legacy_auth_enabled() ) { $opt = (string) get_option( Prweb_Brand::option( 'user_id' ), '' ); return $opt !== '' ? $opt : null; } return null; } public static function current_wallet_id(): ?string { $wid = Prweb_Token_Store::get_wallet_id(); if ( $wid !== null && $wid !== '' ) { return $wid; } if ( self::is_legacy_auth_enabled() ) { $opt = (string) get_option( Prweb_Brand::option( 'wallet_id' ), '' ); return $opt !== '' ? $opt : null; } return null; } }