From 0250b2a746f8fa4bd30219706ca7f031f93cfb5c Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 2 Apr 2026 09:15:21 -0700 Subject: [PATCH 01/45] feat: initial reader registration API rollup from working branch --- .../class-reader-activation.php | 284 +++++++++++++ src/reader-activation/index.js | 79 ++++ .../reader-registration-endpoint.php | 378 ++++++++++++++++++ 3 files changed, 741 insertions(+) create mode 100644 tests/unit-tests/reader-registration-endpoint.php diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 9d3fcf92d2..cefbd516ff 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -147,6 +147,19 @@ public static function enqueue_scripts() { 'is_ras_enabled' => self::is_enabled(), ]; + $frontend_integrations = self::get_frontend_registration_integrations(); + if ( ! empty( $frontend_integrations ) ) { + $integrations_config = []; + foreach ( $frontend_integrations as $id => $label ) { + $integrations_config[ $id ] = [ + 'key' => self::get_frontend_registration_key( $id ), + 'label' => $label, + ]; + } + $script_data['frontend_registration_integrations'] = $integrations_config; + $script_data['frontend_registration_url'] = \rest_url( NEWSPACK_API_NAMESPACE . '/reader-activation/register' ); + } + if ( Recaptcha::can_use_captcha() ) { $recaptcha_version = Recaptcha::get_setting( 'version' ); $script_dependencies[] = Recaptcha::SCRIPT_HANDLE; @@ -260,6 +273,277 @@ public static function register_routes() { ], ] ); + + \register_rest_route( + NEWSPACK_API_NAMESPACE, + '/reader-activation/register', + [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [ __CLASS__, 'api_frontend_register_reader' ], + 'permission_callback' => '__return_true', + 'args' => [ + 'npe' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_email', + 'default' => '', + ], + 'email' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'integration_id' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'integration_key' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'first_name' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'last_name' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'g-recaptcha-response' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + ], + ] + ); + } + + /** + * Get registered frontend registration integrations. + * + * @return array Map of integration ID => label. + */ + public static function get_frontend_registration_integrations() { + /** + * Filters the list of integrations that can trigger frontend reader registration. + * + * @param array $integrations Map of integration ID => display label. + */ + return \apply_filters( 'newspack_frontend_registration_integrations', [] ); + } + + /** + * Generate an HMAC key for a frontend registration integration. + * + * The key is deterministic (safe for page caching) and unique per + * integration ID and site. It is not a secret — it is output to the + * page source — but it binds registration requests to a PHP-registered + * integration, preventing arbitrary callers. + * + * @param string $integration_id Integration identifier. + * @return string HMAC-SHA256 hex string. + */ + public static function get_frontend_registration_key( $integration_id ) { + return hash_hmac( 'sha256', $integration_id, \wp_salt( 'auth' ) ); + } + + /** + * Check and increment the per-IP rate limit for frontend registration. + * + * @return bool|\WP_Error True if under limit, WP_Error if exceeded. + */ + private static function check_registration_rate_limit() { + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '127.0.0.1'; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders,WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__REMOTE_ADDR__ + $cache_key = 'newspack_reg_ip_' . md5( $ip ); + + /** + * Filters the maximum number of frontend registration attempts per IP per hour. + * + * @param int $limit Maximum attempts. Default 10. + * @param string $ip The client IP address. + */ + $limit = \apply_filters( 'newspack_frontend_registration_rate_limit', 10, $ip ); + + if ( \wp_using_ext_object_cache() ) { + $cache_group = 'newspack_rate_limit'; + \wp_cache_add( $cache_key, 0, $cache_group, HOUR_IN_SECONDS ); + $attempts = \wp_cache_incr( $cache_key, 1, $cache_group ); + } else { + $attempts = (int) \get_transient( $cache_key ); + \set_transient( $cache_key, $attempts + 1, HOUR_IN_SECONDS ); + $attempts++; + } + + if ( $attempts > $limit ) { + Logger::log( 'Frontend registration rate limit exceeded for IP ' . $ip ); + return new \WP_Error( + 'rate_limit_exceeded', + __( 'Too many registration attempts. Please try again later.', 'newspack-plugin' ), + [ 'status' => 429 ] + ); + } + + return true; + } + + /** + * REST API handler for frontend integration reader registration. + * + * Validation sequence: + * 1. User is not logged in + * 2. Reader Activation is enabled + * 3. Integration ID is registered + * 4. Integration key matches HMAC + * 5. Honeypot field is empty + * 6. reCAPTCHA (when configured) + * 7. Per-IP rate limit + * 8. Email is valid + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response|\WP_Error + */ + public static function api_frontend_register_reader( $request ) { + // Step 1: Reject if caller is already logged in. + if ( \is_user_logged_in() ) { + return new \WP_Error( + 'already_logged_in', + __( 'Registration is not available for logged-in users.', 'newspack-plugin' ), + [ 'status' => 403 ] + ); + } + + // Step 2: Check RAS is enabled. + if ( ! self::is_enabled() ) { + return new \WP_Error( + 'reader_activation_disabled', + __( 'Reader Activation is not enabled.', 'newspack-plugin' ), + [ 'status' => 403 ] + ); + } + + // Step 3: Validate integration ID is registered. + $integration_id = $request->get_param( 'integration_id' ); + $integrations = self::get_frontend_registration_integrations(); + if ( empty( $integration_id ) || ! isset( $integrations[ $integration_id ] ) ) { + Logger::log( 'Frontend registration rejected: invalid integration ID "' . $integration_id . '"' ); + return new \WP_Error( + 'invalid_integration', + __( 'Invalid integration.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + // Step 4: Validate integration key. + $integration_key = $request->get_param( 'integration_key' ); + $expected_key = self::get_frontend_registration_key( $integration_id ); + if ( ! hash_equals( $expected_key, $integration_key ) ) { + Logger::log( 'Frontend registration rejected: invalid key for integration "' . $integration_id . '"' ); + return new \WP_Error( + 'invalid_integration_key', + __( 'Invalid integration key.', 'newspack-plugin' ), + [ 'status' => 403 ] + ); + } + + // Step 5: Honeypot — the `email` field must be empty. Real email is in `npe`. + $honeypot = $request->get_param( 'email' ); + if ( ! empty( $honeypot ) ) { + // Return fake success to avoid revealing the honeypot to bots. + return new \WP_REST_Response( + [ + 'success' => true, + 'status' => 'created', + 'email' => $honeypot, + ], + 200 + ); + } + + // Step 6: reCAPTCHA (when configured). + $recaptcha_token = $request->get_param( 'g-recaptcha-response' ); + $should_verify = \apply_filters( 'newspack_recaptcha_verify_captcha', Recaptcha::can_use_captcha(), '', 'integration_registration' ); + if ( $should_verify ) { + // Bridge: verify_captcha() reads from $_POST. + $_POST['g-recaptcha-response'] = $recaptcha_token; // phpcs:ignore WordPress.Security.NonceVerification.Missing + $captcha_result = Recaptcha::verify_captcha(); + unset( $_POST['g-recaptcha-response'] ); + if ( \is_wp_error( $captcha_result ) ) { + return new \WP_Error( + 'recaptcha_failed', + $captcha_result->get_error_message(), + [ 'status' => 403 ] + ); + } + } + + // Step 7: Per-IP rate limit. + $rate_check = self::check_registration_rate_limit(); + if ( \is_wp_error( $rate_check ) ) { + return $rate_check; + } + + // Step 8: Validate email. + $email = $request->get_param( 'npe' ); + if ( empty( $email ) ) { + return new \WP_Error( + 'invalid_email', + __( 'A valid email address is required.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + // Build display name from profile fields. + $first_name = $request->get_param( 'first_name' ); + $last_name = $request->get_param( 'last_name' ); + $display_name = trim( $first_name . ' ' . $last_name ); + + // Build metadata. + $metadata = [ + 'registration_method' => 'integration-registration-' . $integration_id, + 'current_page_url' => \wp_get_referer() ? \wp_get_referer() : '', + ]; + + $result = self::register_reader( $email, $display_name, true, $metadata ); + + if ( \is_wp_error( $result ) ) { + return new \WP_Error( + 'registration_failed', + $result->get_error_message(), + [ 'status' => 500 ] + ); + } + + if ( false === $result ) { + return new \WP_Error( + 'reader_already_exists', + __( 'A reader with this email address is already registered.', 'newspack-plugin' ), + [ 'status' => 409 ] + ); + } + + // Apply profile fields after creation. + if ( ! empty( $first_name ) || ! empty( $last_name ) ) { + \wp_update_user( + [ + 'ID' => $result, + 'first_name' => $first_name, + 'last_name' => $last_name, + ] + ); + } + + return new \WP_REST_Response( + [ + 'success' => true, + 'status' => 'created', + 'email' => $email, + ], + 201 + ); } /** diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index 7a06913d6a..2e5913ab21 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -427,6 +427,84 @@ function attachNewsletterFormListener() { } ); } +/** + * Register a reader via a frontend integration. + * + * @param {string} email Reader email address. + * @param {string} integrationId Registered integration ID. + * @param {Object} profileFields Optional profile fields: { first_name, last_name }. + * @return {Promise} Resolves with reader data on success, rejects with error on failure. + */ +function register( email, integrationId, profileFields = {} ) { + const config = newspack_ras_config?.frontend_registration_integrations || {}; + const integration = config[ integrationId ]; + + if ( ! integration ) { + return Promise.reject( new Error( 'Unknown integration: ' + integrationId ) ); + } + + if ( ! email || ! /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test( email ) ) { + return Promise.reject( new Error( 'Invalid email address.' ) ); + } + + const body = { + npe: email, + integration_id: integrationId, + integration_key: integration.key, + first_name: profileFields.first_name || '', + last_name: profileFields.last_name || '', + }; + + // Acquire reCAPTCHA v3 token if configured, then POST. + const captchaPromise = + newspack_ras_config?.captcha_site_key && window.grecaptcha + ? window.grecaptcha.execute( newspack_ras_config.captcha_site_key, { + action: 'integration_registration', + } ) + : Promise.resolve( '' ); + + return captchaPromise + .then( function ( token ) { + if ( token ) { + body[ 'g-recaptcha-response' ] = token; + } + return fetch( newspack_ras_config.frontend_registration_url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify( body ), + } ); + } ) + .then( function ( response ) { + return response.json().then( function ( data ) { + if ( ! response.ok ) { + const error = new Error( data.message || 'Registration failed.' ); + error.code = data.code; + throw error; + } + return data; + } ); + } ) + .then( function ( data ) { + store.set( 'reader', { email, authenticated: true } ); + emit( EVENTS.reader, { email, authenticated: true } ); + dispatchActivity( 'reader_registered', { + email, + integration_id: integrationId, + status: 'created', + } ); + return data; + } ) + .catch( function ( error ) { + dispatchActivity( 'reader_registration_failed', { + email, + integration_id: integrationId, + error: error.code || 'network_error', + } ); + throw error; + } ); +} + const readerActivation = { store, overlays, @@ -451,6 +529,7 @@ const readerActivation = { setPendingCheckout, getPendingCheckout, debugLog, + register, ...( newspack_ras_config.is_ras_enabled && { openAuthModal } ), }; diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php new file mode 100644 index 0000000000..26dfe584f0 --- /dev/null +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -0,0 +1,378 @@ +server = $wp_rest_server; + add_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + // Ensure routes are registered — Reader_Activation::init() may have run + // before IS_TEST_ENV was defined, skipping the rest_api_init hook. + add_action( 'rest_api_init', [ Reader_Activation::class, 'register_routes' ] ); + do_action( 'rest_api_init' ); + wp_set_current_user( 0 ); + } + + /** + * Clean up after each test. + */ + public function tear_down() { + global $wp_rest_server; + $wp_rest_server = null; + remove_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + $user = get_user_by( 'email', self::$reader_email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + // Reset rate limit state. + delete_transient( 'newspack_reg_ip_' . md5( '127.0.0.1' ) ); + wp_cache_delete( 'newspack_reg_ip_' . md5( '127.0.0.1' ), 'newspack_rate_limit' ); + // Clean up any $_POST pollution. + unset( $_POST['g-recaptcha-response'] ); + parent::tear_down(); + } + + /** + * Helper to make a registration request. + * + * @param array $body Request body. + * @return WP_REST_Response + */ + private function do_register_request( $body = [] ) { + $request = new WP_REST_Request( 'POST', self::$route ); + $request->set_header( 'Content-Type', 'application/json' ); + $request->set_body( wp_json_encode( $body ) ); + return $this->server->dispatch( $request ); + } + + /** + * Test successful reader registration. + */ + public function test_register_new_reader() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + $this->assertEquals( 'created', $data['status'] ); + $this->assertEquals( self::$reader_email, $data['email'] ); + $this->assertInstanceOf( 'WP_User', get_user_by( 'email', self::$reader_email ) ); + } + + /** + * Test duplicate email returns 409. + */ + public function test_register_duplicate_email() { + // Create the user directly to avoid register_reader()'s RAS-enabled check. + self::factory()->user->create( + [ + 'user_email' => self::$reader_email, + 'role' => 'subscriber', + ] + ); + wp_set_current_user( 0 ); + + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 409, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'reader_already_exists', $data['code'] ); + } + + /** + * Test missing email returns 400. + */ + public function test_register_missing_email() { + $response = $this->do_register_request( + [ + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_email', $data['code'] ); + } + + /** + * Test invalid email returns 400. + */ + public function test_register_invalid_email() { + $response = $this->do_register_request( + [ + 'npe' => 'not-an-email', + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_email', $data['code'] ); + } + + /** + * Test missing integration ID returns 400. + */ + public function test_register_missing_integration_id() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_key' => 'anything', + ] + ); + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_integration', $data['code'] ); + } + + /** + * Test unregistered integration ID returns 400. + */ + public function test_register_unknown_integration_id() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => 'unknown-tool', + 'integration_key' => self::generate_key( 'unknown-tool' ), + ] + ); + $this->assertEquals( 400, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_integration', $data['code'] ); + } + + /** + * Test wrong integration key returns 403. + */ + public function test_register_wrong_integration_key() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => 'wrong-key', + ] + ); + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_integration_key', $data['code'] ); + } + + /** + * Test honeypot field triggers fake success. + */ + public function test_honeypot_returns_fake_success() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'email' => 'bot-filled@spam.com', + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + // Verify user was NOT actually created. + $this->assertFalse( get_user_by( 'email', self::$reader_email ) ); + } + + /** + * Test logged-in user returns 403. + */ + public function test_register_while_logged_in() { + $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + wp_set_current_user( $admin_id ); + + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'already_logged_in', $data['code'] ); + + wp_delete_user( $admin_id ); + } + + /** + * Test registration with profile fields. + */ + public function test_register_with_profile_fields() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + 'first_name' => 'Jane', + 'last_name' => 'Doe', + ] + ); + $this->assertEquals( 201, $response->get_status() ); + $user = get_user_by( 'email', self::$reader_email ); + $this->assertInstanceOf( 'WP_User', $user ); + $this->assertEquals( 'Jane', $user->first_name ); + $this->assertEquals( 'Doe', $user->last_name ); + $this->assertStringContainsString( 'Jane', $user->display_name ); + } + + /** + * Test registration stores the integration-based registration method. + */ + public function test_register_stores_registration_method() { + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + $user = get_user_by( 'email', self::$reader_email ); + $this->assertEquals( + 'integration-registration-' . self::$integration_id, + get_user_meta( $user->ID, Reader_Activation::REGISTRATION_METHOD, true ) + ); + } + + /** + * Test RAS disabled returns 403. + * + * Skipped in the test environment because Reader_Activation::is_enabled() + * short-circuits to true when IS_TEST_ENV is defined, bypassing the filter. + */ + public function test_register_when_ras_disabled() { + if ( defined( 'IS_TEST_ENV' ) && IS_TEST_ENV ) { + $this->markTestSkipped( 'is_enabled() always returns true when IS_TEST_ENV is defined.' ); + } + + add_filter( 'newspack_reader_activation_enabled', '__return_false' ); + + $response = $this->do_register_request( + [ + 'npe' => self::$reader_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'reader_activation_disabled', $data['code'] ); + + remove_filter( 'newspack_reader_activation_enabled', '__return_false' ); + } + + /** + * Test per-IP rate limiting returns 429. + */ + public function test_rate_limit_exceeded() { + // Lower limit to 2 for testing. + $set_limit = function() { + return 2; + }; + add_filter( 'newspack_frontend_registration_rate_limit', $set_limit ); + + $base_body = [ + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ]; + + // First two requests should succeed or return non-429 errors. + // Reset current user between requests since successful registration authenticates the reader. + $this->do_register_request( array_merge( $base_body, [ 'npe' => 'rate1@test.com' ] ) ); + wp_set_current_user( 0 ); + $this->do_register_request( array_merge( $base_body, [ 'npe' => 'rate2@test.com' ] ) ); + wp_set_current_user( 0 ); + + // Third request should be rate-limited. + $response = $this->do_register_request( array_merge( $base_body, [ 'npe' => 'rate3@test.com' ] ) ); + $this->assertEquals( 429, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'rate_limit_exceeded', $data['code'] ); + + remove_filter( 'newspack_frontend_registration_rate_limit', $set_limit ); + + // Clean up created users. + foreach ( [ 'rate1@test.com', 'rate2@test.com' ] as $email ) { + $user = get_user_by( 'email', $email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + } +} From 96516b244ef27eb4d080477a1bebc9757fed4930 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 2 Apr 2026 12:11:06 -0700 Subject: [PATCH 02/45] docs: annotated new functions with type hints --- includes/reader-activation/class-reader-activation.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index cefbd516ff..b3b49799a0 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -327,7 +327,7 @@ public static function register_routes() { * * @return array Map of integration ID => label. */ - public static function get_frontend_registration_integrations() { + public static function get_frontend_registration_integrations(): array { /** * Filters the list of integrations that can trigger frontend reader registration. * @@ -347,7 +347,7 @@ public static function get_frontend_registration_integrations() { * @param string $integration_id Integration identifier. * @return string HMAC-SHA256 hex string. */ - public static function get_frontend_registration_key( $integration_id ) { + public static function get_frontend_registration_key( string $integration_id ): string { return hash_hmac( 'sha256', $integration_id, \wp_salt( 'auth' ) ); } @@ -356,7 +356,7 @@ public static function get_frontend_registration_key( $integration_id ) { * * @return bool|\WP_Error True if under limit, WP_Error if exceeded. */ - private static function check_registration_rate_limit() { + private static function check_registration_rate_limit(): bool|\WP_Error { $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '127.0.0.1'; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders,WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__REMOTE_ADDR__ $cache_key = 'newspack_reg_ip_' . md5( $ip ); @@ -406,7 +406,7 @@ private static function check_registration_rate_limit() { * @param \WP_REST_Request $request Request object. * @return \WP_REST_Response|\WP_Error */ - public static function api_frontend_register_reader( $request ) { + public static function api_frontend_register_reader( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { // Step 1: Reject if caller is already logged in. if ( \is_user_logged_in() ) { return new \WP_Error( From e38a94f357348201108bef32f7de09a3cd2a7e47 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 2 Apr 2026 12:15:51 -0700 Subject: [PATCH 03/45] fix: centralize, normalize definition of `$referer` --- includes/reader-activation/class-reader-activation.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index b3b49799a0..2842f67fb7 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -501,10 +501,12 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) $last_name = $request->get_param( 'last_name' ); $display_name = trim( $first_name . ' ' . $last_name ); - // Build metadata. - $metadata = [ + // Build metadata. Normalize referer to a local path, matching process_auth_form(). + $referer = \wp_parse_url( \wp_get_referer() ); + $current_page_url = ! empty( $referer['path'] ) ? \esc_url( \home_url( $referer['path'] ) ) : ''; + $metadata = [ 'registration_method' => 'integration-registration-' . $integration_id, - 'current_page_url' => \wp_get_referer() ? \wp_get_referer() : '', + 'current_page_url' => $current_page_url, ]; $result = self::register_reader( $email, $display_name, true, $metadata ); From ea9af30d6b1260d2acd47587273346a125bfdb4f Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 2 Apr 2026 12:17:04 -0700 Subject: [PATCH 04/45] fix: use returned status rather than hardcoded value --- src/reader-activation/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index 2e5913ab21..bc63891ade 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -491,7 +491,7 @@ function register( email, integrationId, profileFields = {} ) { dispatchActivity( 'reader_registered', { email, integration_id: integrationId, - status: 'created', + status: data.status || 'created', } ); return data; } ) From e0b2de81b894c149676710c9b324db4e1463047e Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 2 Apr 2026 12:57:02 -0700 Subject: [PATCH 05/45] test: annotate with group like we do elsewhere for isolated testing --- tests/unit-tests/reader-registration-endpoint.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php index 26dfe584f0..deaf33d421 100644 --- a/tests/unit-tests/reader-registration-endpoint.php +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -9,6 +9,8 @@ /** * Tests the Frontend Reader Registration REST endpoint. + * + * @group frontend-registration */ class Newspack_Test_Frontend_Registration_Endpoint extends WP_UnitTestCase { /** From d206c54c38a82bc6feb95958af23053042e01421 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 2 Apr 2026 12:59:22 -0700 Subject: [PATCH 06/45] docs: note to self about parameterizing `verify_captcha()` --- includes/reader-activation/class-reader-activation.php | 1 + 1 file changed, 1 insertion(+) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 2842f67fb7..7eb413dab0 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -468,6 +468,7 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) $should_verify = \apply_filters( 'newspack_recaptcha_verify_captcha', Recaptcha::can_use_captcha(), '', 'integration_registration' ); if ( $should_verify ) { // Bridge: verify_captcha() reads from $_POST. + // @todo Refactor Recaptcha::verify_captcha() to accept an optional $token parameter, eliminating this $_POST mutation. $_POST['g-recaptcha-response'] = $recaptcha_token; // phpcs:ignore WordPress.Security.NonceVerification.Missing $captcha_result = Recaptcha::verify_captcha(); unset( $_POST['g-recaptcha-response'] ); From f143df23229f0afe2610d8d368ebde5c670f0f14 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 2 Apr 2026 15:29:57 -0700 Subject: [PATCH 07/45] docs: note about better mitigating attacks --- includes/reader-activation/class-reader-activation.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 7eb413dab0..4df7336830 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -453,6 +453,8 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) $honeypot = $request->get_param( 'email' ); if ( ! empty( $honeypot ) ) { // Return fake success to avoid revealing the honeypot to bots. + // @todo Consider returning the npe value instead of the honeypot value to make + // the fake response indistinguishable from a real one. return new \WP_REST_Response( [ 'success' => true, From 4d2fcd43ef7adf4af2e92640ab719c91a2419d01 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 2 Apr 2026 16:56:45 -0700 Subject: [PATCH 08/45] docs: note potential disclosure and mitigation path --- includes/reader-activation/class-reader-activation.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 4df7336830..4fdf0d4bb0 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -522,6 +522,9 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) ); } + // @todo register_reader() returns false for both existing readers (sends magic link) + // and existing non-reader accounts (sends login reminder). This 409 treats both + // identically. Consider distinguishing these cases to avoid disclosing account type. if ( false === $result ) { return new \WP_Error( 'reader_already_exists', From 3200cc09c2173d3abd62df4c0374b22594c66217 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 2 Apr 2026 18:53:41 -0700 Subject: [PATCH 09/45] fix: merge into existing `reader` now that we are out of POC --- src/reader-activation/index.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index bc63891ade..cdf83e55de 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -486,8 +486,13 @@ function register( email, integrationId, profileFields = {} ) { } ); } ) .then( function ( data ) { - store.set( 'reader', { email, authenticated: true } ); - emit( EVENTS.reader, { email, authenticated: true } ); + const reader = { + ...( store.get( 'reader' ) || {} ), + email, + authenticated: true, + }; + store.set( 'reader', reader, false ); + emit( EVENTS.reader, reader ); dispatchActivity( 'reader_registered', { email, integration_id: integrationId, From a70d5ff94250e7ff2208a46cc8b06d5eb43107d0 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 04:17:36 -0700 Subject: [PATCH 10/45] fix: condition reCAPTCHA v3 actions on their `ready()` --- src/reader-activation/index.js | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index cdf83e55de..9ac5852c24 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -458,8 +458,15 @@ function register( email, integrationId, profileFields = {} ) { // Acquire reCAPTCHA v3 token if configured, then POST. const captchaPromise = newspack_ras_config?.captcha_site_key && window.grecaptcha - ? window.grecaptcha.execute( newspack_ras_config.captcha_site_key, { - action: 'integration_registration', + ? new Promise( function ( resolve, reject ) { + window.grecaptcha.ready( function () { + window.grecaptcha + .execute( newspack_ras_config.captcha_site_key, { + action: 'integration_registration', + } ) + .then( resolve ) + .catch( reject ); + } ); } ) : Promise.resolve( '' ); From ea549c1551b949d06ed681d8d96b180849791dfd Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 04:21:53 -0700 Subject: [PATCH 11/45] style: explicit fallback for `wp_parse_url()` as suggested by Copilot --- includes/reader-activation/class-reader-activation.php | 1 + 1 file changed, 1 insertion(+) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 4fdf0d4bb0..fa18213f0e 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -506,6 +506,7 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) // Build metadata. Normalize referer to a local path, matching process_auth_form(). $referer = \wp_parse_url( \wp_get_referer() ); + $referer = is_array( $referer ) ? $referer : []; $current_page_url = ! empty( $referer['path'] ) ? \esc_url( \home_url( $referer['path'] ) ) : ''; $metadata = [ 'registration_method' => 'integration-registration-' . $integration_id, From 18de923b96ca6eb10c041e235c4e6cc474e3651b Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 04:27:19 -0700 Subject: [PATCH 12/45] fix: make endpoint available only when RAS is enabled, per Copilot --- includes/reader-activation/class-reader-activation.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index fa18213f0e..8ec0bc2ef1 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -157,7 +157,9 @@ public static function enqueue_scripts() { ]; } $script_data['frontend_registration_integrations'] = $integrations_config; - $script_data['frontend_registration_url'] = \rest_url( NEWSPACK_API_NAMESPACE . '/reader-activation/register' ); + if ( self::is_enabled() ) { + $script_data['frontend_registration_url'] = \rest_url( NEWSPACK_API_NAMESPACE . '/reader-activation/register' ); + } } if ( Recaptcha::can_use_captcha() ) { From 689107b0da41026d5ec86eea1666e91e967dbb15 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 04:28:45 -0700 Subject: [PATCH 13/45] test: properly tear down our new routes --- tests/unit-tests/reader-registration-endpoint.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php index deaf33d421..94294d2f29 100644 --- a/tests/unit-tests/reader-registration-endpoint.php +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -85,6 +85,7 @@ public function tear_down() { global $wp_rest_server; $wp_rest_server = null; remove_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + remove_action( 'rest_api_init', [ Reader_Activation::class, 'register_routes' ] ); $user = get_user_by( 'email', self::$reader_email ); if ( $user ) { wp_delete_user( $user->ID ); From 56ba6ea0747e5cabfd53b6d2449e984406d805e2 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 04:58:41 -0700 Subject: [PATCH 14/45] feat: localize reCAPTCHA site key and version for both v2 and v3 --- includes/reader-activation/class-reader-activation.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 8ec0bc2ef1..a619d1f572 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -165,9 +165,8 @@ public static function enqueue_scripts() { if ( Recaptcha::can_use_captcha() ) { $recaptcha_version = Recaptcha::get_setting( 'version' ); $script_dependencies[] = Recaptcha::SCRIPT_HANDLE; - if ( 'v3' === $recaptcha_version ) { - $script_data['captcha_site_key'] = Recaptcha::get_site_key(); - } + $script_data['captcha_site_key'] = Recaptcha::get_site_key(); + $script_data['captcha_version'] = $recaptcha_version; } Newspack::load_common_assets(); From 60b0e772bfc023b4f88053d21100f5778f6993ba Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 05:05:25 -0700 Subject: [PATCH 15/45] feat: add reCAPTCHA v2 invisible support to register() --- src/reader-activation/index.js | 86 ++++++++++++++++++++++++++++------ 1 file changed, 72 insertions(+), 14 deletions(-) diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index 9ac5852c24..a6f213e911 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -427,6 +427,49 @@ function attachNewsletterFormListener() { } ); } +/** + * Acquire a reCAPTCHA v2 invisible token. + * + * Renders a temporary invisible widget, executes it, and resolves + * with the token. Cleans up the widget container after completion. + * + * @param {string} siteKey reCAPTCHA site key. + * @return {Promise} Resolves with the reCAPTCHA token. + */ +function acquireV2InvisibleToken( siteKey ) { + return new Promise( function ( resolve, reject ) { + const container = document.createElement( 'div' ); + container.style.display = 'none'; + document.body.appendChild( container ); + + let widgetId; + try { + widgetId = window.grecaptcha.render( container, { + sitekey: siteKey, + size: 'invisible', + callback( token ) { + container.remove(); + resolve( token ); + }, + 'error-callback'() { + container.remove(); + reject( new Error( 'reCAPTCHA challenge failed.' ) ); + }, + 'expired-callback'() { + container.remove(); + reject( new Error( 'reCAPTCHA token expired.' ) ); + }, + } ); + } catch ( err ) { + container.remove(); + reject( err ); + return; + } + + window.grecaptcha.execute( widgetId ); + } ); +} + /** * Register a reader via a frontend integration. * @@ -455,20 +498,35 @@ function register( email, integrationId, profileFields = {} ) { last_name: profileFields.last_name || '', }; - // Acquire reCAPTCHA v3 token if configured, then POST. - const captchaPromise = - newspack_ras_config?.captcha_site_key && window.grecaptcha - ? new Promise( function ( resolve, reject ) { - window.grecaptcha.ready( function () { - window.grecaptcha - .execute( newspack_ras_config.captcha_site_key, { - action: 'integration_registration', - } ) - .then( resolve ) - .catch( reject ); - } ); - } ) - : Promise.resolve( '' ); + // Acquire reCAPTCHA token if configured, using the appropriate version flow. + const captchaSiteKey = newspack_ras_config?.captcha_site_key; + const captchaVersion = newspack_ras_config?.captcha_version; + let captchaPromise; + + if ( captchaSiteKey && window.grecaptcha ) { + if ( captchaVersion === 'v3' ) { + captchaPromise = new Promise( function ( resolve, reject ) { + window.grecaptcha.ready( function () { + window.grecaptcha + .execute( captchaSiteKey, { + action: 'integration_registration', + } ) + .then( resolve ) + .catch( reject ); + } ); + } ); + } else if ( captchaVersion && captchaVersion.substring( 0, 2 ) === 'v2' ) { + captchaPromise = new Promise( function ( resolve, reject ) { + window.grecaptcha.ready( function () { + acquireV2InvisibleToken( captchaSiteKey ).then( resolve ).catch( reject ); + } ); + } ); + } else { + captchaPromise = Promise.resolve( '' ); + } + } else { + captchaPromise = Promise.resolve( '' ); + } return captchaPromise .then( function ( token ) { From f224df807aba6f7ee994218a28bc2949624a4ed9 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 05:10:26 -0700 Subject: [PATCH 16/45] fix: move grecaptcha.execute inside try block to prevent Promise leak --- src/reader-activation/index.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index a6f213e911..b602adf157 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -442,9 +442,8 @@ function acquireV2InvisibleToken( siteKey ) { container.style.display = 'none'; document.body.appendChild( container ); - let widgetId; try { - widgetId = window.grecaptcha.render( container, { + const widgetId = window.grecaptcha.render( container, { sitekey: siteKey, size: 'invisible', callback( token ) { @@ -460,13 +459,11 @@ function acquireV2InvisibleToken( siteKey ) { reject( new Error( 'reCAPTCHA token expired.' ) ); }, } ); + window.grecaptcha.execute( widgetId ); } catch ( err ) { container.remove(); reject( err ); - return; } - - window.grecaptcha.execute( widgetId ); } ); } From 9cfcc309cafa5ba975e166523421bf0652d25e20 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 05:29:16 -0700 Subject: [PATCH 17/45] fix: add isolated flag to v2 invisible widget to prevent interference --- src/reader-activation/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index b602adf157..4fddd472bb 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -446,6 +446,7 @@ function acquireV2InvisibleToken( siteKey ) { const widgetId = window.grecaptcha.render( container, { sitekey: siteKey, size: 'invisible', + isolated: true, callback( token ) { container.remove(); resolve( token ); From 8fd1a1730c4e2ec5534b07fa192995dd41a032a5 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 05:29:46 -0700 Subject: [PATCH 18/45] fix: add 30s timeout to v2 invisible token acquisition to prevent hang --- src/reader-activation/index.js | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index 4fddd472bb..0cdfaa0027 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -442,28 +442,43 @@ function acquireV2InvisibleToken( siteKey ) { container.style.display = 'none'; document.body.appendChild( container ); + let settled = false; + const timeout = setTimeout( function () { + if ( ! settled ) { + settled = true; + container.remove(); + reject( new Error( 'reCAPTCHA timed out.' ) ); + } + }, 30000 ); + + function settle( fn, value ) { + if ( settled ) { + return; + } + settled = true; + clearTimeout( timeout ); + container.remove(); + fn( value ); + } + try { const widgetId = window.grecaptcha.render( container, { sitekey: siteKey, size: 'invisible', isolated: true, callback( token ) { - container.remove(); - resolve( token ); + settle( resolve, token ); }, 'error-callback'() { - container.remove(); - reject( new Error( 'reCAPTCHA challenge failed.' ) ); + settle( reject, new Error( 'reCAPTCHA challenge failed.' ) ); }, 'expired-callback'() { - container.remove(); - reject( new Error( 'reCAPTCHA token expired.' ) ); + settle( reject, new Error( 'reCAPTCHA token expired.' ) ); }, } ); window.grecaptcha.execute( widgetId ); } catch ( err ) { - container.remove(); - reject( err ); + settle( reject, err ); } } ); } From 334c6d04043ea96307bfef81eb336e713ca30ba2 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 05:30:04 -0700 Subject: [PATCH 19/45] docs: note potential concurrent-call guard for v2 invisible token --- src/reader-activation/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index 0cdfaa0027..b16e33e7fa 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -433,6 +433,10 @@ function attachNewsletterFormListener() { * Renders a temporary invisible widget, executes it, and resolves * with the token. Cleans up the widget container after completion. * + * @todo Consider adding an in-flight guard to coalesce concurrent calls, + * since each invocation renders a separate widget and the reCAPTCHA API + * may not handle multiple simultaneous invisible widgets well. + * * @param {string} siteKey reCAPTCHA site key. * @return {Promise} Resolves with the reCAPTCHA token. */ From dd4c46b88ba54222690ad133c17cbb97e4ddaf8e Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 12:32:22 -0700 Subject: [PATCH 20/45] fix: gracefully reject calls if essential config is missing --- src/reader-activation/index.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index b16e33e7fa..0cbc006704 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -507,6 +507,10 @@ function register( email, integrationId, profileFields = {} ) { return Promise.reject( new Error( 'Invalid email address.' ) ); } + if ( ! newspack_ras_config?.frontend_registration_url ) { + return Promise.reject( new Error( 'Registration is not available.' ) ); + } + const body = { npe: email, integration_id: integrationId, From 99415808b30cc5010aa4e3096a70ca3664b213fd Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 12:58:32 -0700 Subject: [PATCH 21/45] docs: potential future directions for rate limiting --- includes/reader-activation/class-reader-activation.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index a619d1f572..1c11b032c8 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -358,6 +358,10 @@ public static function get_frontend_registration_key( string $integration_id ): * @return bool|\WP_Error True if under limit, WP_Error if exceeded. */ private static function check_registration_rate_limit(): bool|\WP_Error { + // @todo REMOTE_ADDR may be a proxy/load-balancer IP in some environments. + // On WordPress VIP/Atomic this is the real client IP. For other hosts, + // consider parsing forwarded headers or providing a filter to override IP resolution. + // See WooCommerce_Connection::get_client_ip() for a forwarded-header approach. $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '127.0.0.1'; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders,WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__REMOTE_ADDR__ $cache_key = 'newspack_reg_ip_' . md5( $ip ); From 4fbb990f1c021833015832d7dc77ca42c3502205 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 13:39:33 -0700 Subject: [PATCH 22/45] fix: address potential race condition on multiple registrations with same email --- .../reader-activation/class-reader-activation.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 1c11b032c8..e367e0d151 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -521,6 +521,18 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) $result = self::register_reader( $email, $display_name, true, $metadata ); if ( \is_wp_error( $result ) ) { + // Race condition: concurrent requests for the same email can cause + // wp_insert_user() or wc_create_new_customer() to return an "existing + // user" error instead of register_reader() returning false. + $existing_user_codes = [ 'existing_user_email', 'existing_user_login', 'registration-error-email-exists' ]; + if ( array_intersect( $result->get_error_codes(), $existing_user_codes ) ) { + return new \WP_Error( + 'reader_already_exists', + __( 'A reader with this email address is already registered.', 'newspack-plugin' ), + [ 'status' => 409 ] + ); + } + return new \WP_Error( 'registration_failed', $result->get_error_message(), From be599187a2247d4381d8df9491647d5e01b1e603 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 15:16:54 -0700 Subject: [PATCH 23/45] fix: ensure idempotency by making sure callers get current reader data for logged-in readers --- .../class-reader-activation.php | 16 +++++++++++----- .../unit-tests/reader-registration-endpoint.php | 15 +++++++++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index e367e0d151..11679278e7 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -412,12 +412,18 @@ private static function check_registration_rate_limit(): bool|\WP_Error { * @return \WP_REST_Response|\WP_Error */ public static function api_frontend_register_reader( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - // Step 1: Reject if caller is already logged in. + // Step 1: If caller is already logged in, return current reader data. + // This makes the API idempotent — integrations don't need to check + // authentication state before calling register(). if ( \is_user_logged_in() ) { - return new \WP_Error( - 'already_logged_in', - __( 'Registration is not available for logged-in users.', 'newspack-plugin' ), - [ 'status' => 403 ] + $current_user = \wp_get_current_user(); + return new \WP_REST_Response( + [ + 'success' => true, + 'status' => 'existing', + 'email' => $current_user->user_email, + ], + 200 ); } diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php index 94294d2f29..41bb007919 100644 --- a/tests/unit-tests/reader-registration-endpoint.php +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -253,10 +253,15 @@ public function test_honeypot_returns_fake_success() { } /** - * Test logged-in user returns 403. + * Test logged-in user returns current reader data. */ public function test_register_while_logged_in() { - $admin_id = self::factory()->user->create( [ 'role' => 'administrator' ] ); + $admin_id = self::factory()->user->create( + [ + 'role' => 'administrator', + 'user_email' => 'admin@test.com', + ] + ); wp_set_current_user( $admin_id ); $response = $this->do_register_request( @@ -266,9 +271,11 @@ public function test_register_while_logged_in() { 'integration_key' => self::generate_key( self::$integration_id ), ] ); - $this->assertEquals( 403, $response->get_status() ); + $this->assertEquals( 200, $response->get_status() ); $data = $response->get_data(); - $this->assertEquals( 'already_logged_in', $data['code'] ); + $this->assertTrue( $data['success'] ); + $this->assertEquals( 'existing', $data['status'] ); + $this->assertEquals( 'admin@test.com', $data['email'] ); wp_delete_user( $admin_id ); } From 22c87afd60656923a6615b1f67b7d52f00ec844a Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 15:20:11 -0700 Subject: [PATCH 24/45] fix: condition config output on RAS --- includes/reader-activation/class-reader-activation.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 11679278e7..1f344041bb 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -148,7 +148,7 @@ public static function enqueue_scripts() { ]; $frontend_integrations = self::get_frontend_registration_integrations(); - if ( ! empty( $frontend_integrations ) ) { + if ( self::is_enabled() && ! empty( $frontend_integrations ) ) { $integrations_config = []; foreach ( $frontend_integrations as $id => $label ) { $integrations_config[ $id ] = [ @@ -157,9 +157,7 @@ public static function enqueue_scripts() { ]; } $script_data['frontend_registration_integrations'] = $integrations_config; - if ( self::is_enabled() ) { - $script_data['frontend_registration_url'] = \rest_url( NEWSPACK_API_NAMESPACE . '/reader-activation/register' ); - } + $script_data['frontend_registration_url'] = \rest_url( NEWSPACK_API_NAMESPACE . '/reader-activation/register' ); } if ( Recaptcha::can_use_captcha() ) { From 56e699f315c7fdb5f21f54c5ec35e81d4ef64df2 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 15:47:39 -0700 Subject: [PATCH 25/45] fix: use server-side email, not submitted email, for logged-in users --- src/reader-activation/index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index 0cbc006704..828eded936 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -572,15 +572,16 @@ function register( email, integrationId, profileFields = {} ) { } ); } ) .then( function ( data ) { + const readerEmail = data.email || email; const reader = { ...( store.get( 'reader' ) || {} ), - email, + email: readerEmail, authenticated: true, }; store.set( 'reader', reader, false ); emit( EVENTS.reader, reader ); dispatchActivity( 'reader_registered', { - email, + email: readerEmail, integration_id: integrationId, status: data.status || 'created', } ); From 099a1098effbcff8faf75b8f6c3d3b170eaf20d2 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 15:54:44 -0700 Subject: [PATCH 26/45] fix: reject Promise and provide helpful error if reCAPTCHA not happy --- src/reader-activation/index.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index 828eded936..d8fc91d170 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -524,7 +524,10 @@ function register( email, integrationId, profileFields = {} ) { const captchaVersion = newspack_ras_config?.captcha_version; let captchaPromise; - if ( captchaSiteKey && window.grecaptcha ) { + if ( captchaSiteKey ) { + if ( ! window.grecaptcha ) { + return Promise.reject( new Error( 'reCAPTCHA is configured but not loaded.' ) ); + } if ( captchaVersion === 'v3' ) { captchaPromise = new Promise( function ( resolve, reject ) { window.grecaptcha.ready( function () { From 0ec69a2ca39d9a18bc6117dbd8332c1800134655 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 16:05:05 -0700 Subject: [PATCH 27/45] fix: move reCAPTCHA behind rate limiting to protect metered service from floods --- .../class-reader-activation.php | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 1f344041bb..15c5159419 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -397,13 +397,13 @@ private static function check_registration_rate_limit(): bool|\WP_Error { * REST API handler for frontend integration reader registration. * * Validation sequence: - * 1. User is not logged in + * 1. Already logged in — return current reader data * 2. Reader Activation is enabled * 3. Integration ID is registered * 4. Integration key matches HMAC * 5. Honeypot field is empty - * 6. reCAPTCHA (when configured) - * 7. Per-IP rate limit + * 6. Per-IP rate limit + * 7. reCAPTCHA (when configured) * 8. Email is valid * * @param \WP_REST_Request $request Request object. @@ -474,7 +474,14 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) ); } - // Step 6: reCAPTCHA (when configured). + // Step 6: Per-IP rate limit. Checked before reCAPTCHA to avoid + // triggering external verification calls for rate-limited IPs. + $rate_check = self::check_registration_rate_limit(); + if ( \is_wp_error( $rate_check ) ) { + return $rate_check; + } + + // Step 7: reCAPTCHA (when configured). $recaptcha_token = $request->get_param( 'g-recaptcha-response' ); $should_verify = \apply_filters( 'newspack_recaptcha_verify_captcha', Recaptcha::can_use_captcha(), '', 'integration_registration' ); if ( $should_verify ) { @@ -492,12 +499,6 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) } } - // Step 7: Per-IP rate limit. - $rate_check = self::check_registration_rate_limit(); - if ( \is_wp_error( $rate_check ) ) { - return $rate_check; - } - // Step 8: Validate email. $email = $request->get_param( 'npe' ); if ( empty( $email ) ) { From 4d88837af1b1d4578999cf6733ecd70c5e20a450 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 17:33:49 -0700 Subject: [PATCH 28/45] test: regression test for race condition --- .../reader-registration-endpoint.php | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php index 41bb007919..a9fa89f1ac 100644 --- a/tests/unit-tests/reader-registration-endpoint.php +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -385,4 +385,49 @@ public function test_rate_limit_exceeded() { } } } + + /** + * Test that a race condition WP_Error with "existing user" code maps to 409. + * + * Simulates a race where another request creates the user between + * register_reader()'s exists check and its wp_insert_user() call. + */ + public function test_race_condition_existing_user_returns_409() { + $race_email = 'race-test@test.com'; + + // This filter fires inside canonize_user_data(), after the exists check + // but before wp_insert_user(). Creating the user here simulates a race. + $create_user_during_insert = function( $user_data ) use ( $race_email ) { + if ( ! empty( $user_data['user_email'] ) && $user_data['user_email'] === $race_email ) { + wp_insert_user( + [ + 'user_login' => 'race-user', + 'user_email' => $race_email, + 'user_pass' => wp_generate_password(), + 'role' => 'subscriber', + ] + ); + } + return $user_data; + }; + add_filter( 'newspack_register_reader_user_data', $create_user_during_insert ); + + $response = $this->do_register_request( + [ + 'npe' => $race_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 409, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'reader_already_exists', $data['code'] ); + + remove_filter( 'newspack_register_reader_user_data', $create_user_during_insert ); + + $user = get_user_by( 'email', $race_email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } } From e6d621693978917927187c318cc836e2056e88d9 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 17:36:19 -0700 Subject: [PATCH 29/45] test: test our referrer normalization --- .../reader-registration-endpoint.php | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php index a9fa89f1ac..ce06a40777 100644 --- a/tests/unit-tests/reader-registration-endpoint.php +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -430,4 +430,36 @@ public function test_race_condition_existing_user_returns_409() { wp_delete_user( $user->ID ); } } + + /** + * Test that current_page_url is normalized from the HTTP referer. + * + * The endpoint should parse the referer, extract the path, and rebuild + * it with home_url() — matching the process_auth_form() convention. + */ + public function test_current_page_url_normalization() { + $test_email = 'referer-test@test.com'; + + // Set a referer with query params and fragment that should be stripped. + $_SERVER['HTTP_REFERER'] = home_url( '/sample-page/?foo=bar&baz=1#section' ); + + $response = $this->do_register_request( + [ + 'npe' => $test_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + + $user = get_user_by( 'email', $test_email ); + $this->assertInstanceOf( 'WP_User', $user ); + + $registration_page = get_user_meta( $user->ID, Reader_Activation::REGISTRATION_PAGE, true ); + // Should be normalized to just the path on the home URL, no query params. + $this->assertEquals( home_url( '/sample-page/' ), $registration_page ); + + unset( $_SERVER['HTTP_REFERER'] ); + wp_delete_user( $user->ID ); + } } From 59a2242f9ecd31ffeb51ec95d98339eaeedf7a3e Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 17:42:25 -0700 Subject: [PATCH 30/45] test: regression test reCAPTCHA at the filter level, as mocking the API looks prohibitively messy --- .../reader-registration-endpoint.php | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php index ce06a40777..1cb086c0de 100644 --- a/tests/unit-tests/reader-registration-endpoint.php +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -462,4 +462,74 @@ public function test_current_page_url_normalization() { unset( $_SERVER['HTTP_REFERER'] ); wp_delete_user( $user->ID ); } + + /** + * Test that the reCAPTCHA verify filter controls the verification attempt. + * + * When the filter returns true, the endpoint enters the verification block + * and calls verify_captcha(). In the test environment reCAPTCHA is not + * configured, so verify_captcha() short-circuits to true (passes). + * This test confirms the filter is respected and the $_POST bridge + * sets and cleans up the token correctly. + */ + public function test_recaptcha_filter_forces_verification() { + $captcha_email = 'captcha-test@test.com'; + $token_value = 'test-recaptcha-token'; + + // Force reCAPTCHA verification on, regardless of configuration. + $force_verify = function() { + return true; + }; + add_filter( 'newspack_recaptcha_verify_captcha', $force_verify ); + + $response = $this->do_register_request( + [ + 'npe' => $captcha_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + 'g-recaptcha-response' => $token_value, + ] + ); + // verify_captcha() returns true when not configured, so registration succeeds. + $this->assertEquals( 201, $response->get_status() ); + // Verify $_POST was cleaned up after the bridge. + $this->assertArrayNotHasKey( 'g-recaptcha-response', $_POST ); // phpcs:ignore WordPress.Security.NonceVerification.Missing + + remove_filter( 'newspack_recaptcha_verify_captcha', $force_verify ); + + $user = get_user_by( 'email', $captcha_email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that the reCAPTCHA verify filter can disable verification. + * + * When forced off, registration should succeed even if reCAPTCHA + * would otherwise be required. + */ + public function test_recaptcha_filter_disables_verification() { + $disable_verify = function() { + return false; + }; + add_filter( 'newspack_recaptcha_verify_captcha', $disable_verify ); + + $recaptcha_email = 'captcha-disabled@test.com'; + $response = $this->do_register_request( + [ + 'npe' => $recaptcha_email, + 'integration_id' => self::$integration_id, + 'integration_key' => self::generate_key( self::$integration_id ), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + + remove_filter( 'newspack_recaptcha_verify_captcha', $disable_verify ); + + $user = get_user_by( 'email', $recaptcha_email ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } } From 7fb7989ebdf95fd539316907069dc35c77d9950a Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 17:46:19 -0700 Subject: [PATCH 31/45] test: verify registry gating on RAS --- .../reader-registration-endpoint.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php index 1cb086c0de..05312a7e0f 100644 --- a/tests/unit-tests/reader-registration-endpoint.php +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -532,4 +532,27 @@ public function test_recaptcha_filter_disables_verification() { wp_delete_user( $user->ID ); } } + + /** + * Test that the integration registry returns filtered integrations. + */ + public function test_get_frontend_registration_integrations() { + // The test integration is registered via the filter in set_up(). + $integrations = Reader_Activation::get_frontend_registration_integrations(); + $this->assertArrayHasKey( self::$integration_id, $integrations ); + $this->assertEquals( 'Test Integration', $integrations[ self::$integration_id ] ); + } + + /** + * Test that the integration registry is empty without the filter. + */ + public function test_get_frontend_registration_integrations_empty_without_filter() { + remove_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + + $integrations = Reader_Activation::get_frontend_registration_integrations(); + $this->assertEmpty( $integrations ); + + // Re-add for tear_down consistency. + add_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); + } } From 4fbf3e0206855d9f3fefe2c6925b460d06b39a89 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Fri, 3 Apr 2026 17:47:49 -0700 Subject: [PATCH 32/45] test: verify stability of our integration keys --- .../unit-tests/reader-registration-endpoint.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php index 05312a7e0f..2efa3f3eec 100644 --- a/tests/unit-tests/reader-registration-endpoint.php +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -555,4 +555,21 @@ public function test_get_frontend_registration_integrations_empty_without_filter // Re-add for tear_down consistency. add_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); } + + /** + * Test that integration key generation is deterministic and unique per ID. + */ + public function test_integration_key_determinism_and_uniqueness() { + $key_a_first = Reader_Activation::get_frontend_registration_key( 'integration-a' ); + $key_a_second = Reader_Activation::get_frontend_registration_key( 'integration-a' ); + $key_b = Reader_Activation::get_frontend_registration_key( 'integration-b' ); + + // Same ID produces the same key. + $this->assertEquals( $key_a_first, $key_a_second ); + // Different IDs produce different keys. + $this->assertNotEquals( $key_a_first, $key_b ); + // Keys are 64-character hex strings (SHA-256). + $this->assertMatchesRegularExpression( '/^[a-f0-9]{64}$/', $key_a_first ); + $this->assertMatchesRegularExpression( '/^[a-f0-9]{64}$/', $key_b ); + } } From e6826ff0923aa1c980a5e8b74a7c377965c1e298 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 9 Apr 2026 15:17:10 -0700 Subject: [PATCH 33/45] refactor: extract Reader_Registration class from Reader_Activation --- .../class-reader-registration.php | 369 ++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 includes/reader-activation/class-reader-registration.php diff --git a/includes/reader-activation/class-reader-registration.php b/includes/reader-activation/class-reader-registration.php new file mode 100644 index 0000000000..55c096d863 --- /dev/null +++ b/includes/reader-activation/class-reader-registration.php @@ -0,0 +1,369 @@ + \WP_REST_Server::CREATABLE, + 'callback' => [ __CLASS__, 'api_frontend_register_reader' ], + 'permission_callback' => '__return_true', + 'args' => [ + 'npe' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_email', + 'default' => '', + ], + 'email' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'integration_id' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'integration_key' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'first_name' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'last_name' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + 'g-recaptcha-response' => [ + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'default' => '', + ], + ], + ] + ); + } + + /** + * Get registered frontend registration integrations. + * + * @return array Map of integration ID => label. + */ + public static function get_frontend_registration_integrations(): array { + /** + * Filters the list of integrations that can trigger frontend reader registration. + * + * @param array $integrations Map of integration ID => display label. + */ + return \apply_filters( 'newspack_frontend_registration_integrations', [] ); + } + + /** + * Generate an HMAC key for a frontend registration integration. + * + * The key is deterministic (safe for page caching) and unique per + * integration ID and site. It is not a secret — it is output to the + * page source — but it binds registration requests to a PHP-registered + * integration, preventing arbitrary callers. + * + * @param string $integration_id Integration identifier. + * @return string HMAC-SHA256 hex string. + */ + public static function get_frontend_registration_key( string $integration_id ): string { + return hash_hmac( 'sha256', $integration_id, \wp_salt( 'auth' ) ); + } + + /** + * Get script data for frontend localization. + * + * Called by Reader_Activation::enqueue_scripts() to merge integration + * config into the newspack_ras_config object. + * + * @return array Script data to merge, or empty array if no integrations. + */ + public static function get_script_data(): array { + if ( ! Reader_Activation::is_enabled() ) { + return []; + } + + $frontend_integrations = self::get_frontend_registration_integrations(); + if ( empty( $frontend_integrations ) ) { + return []; + } + + $integrations_config = []; + foreach ( $frontend_integrations as $id => $label ) { + $integrations_config[ $id ] = [ + 'key' => self::get_frontend_registration_key( $id ), + 'label' => $label, + ]; + } + + return [ + 'frontend_registration_integrations' => $integrations_config, + 'frontend_registration_url' => \rest_url( NEWSPACK_API_NAMESPACE . '/reader-activation/register' ), + ]; + } + + /** + * Check and increment the per-IP rate limit for frontend registration. + * + * @return bool|\WP_Error True if under limit, WP_Error if exceeded. + */ + private static function check_registration_rate_limit(): bool|\WP_Error { + // @todo REMOTE_ADDR may be a proxy/load-balancer IP in some environments. + // On WordPress VIP/Atomic this is the real client IP. For other hosts, + // consider parsing forwarded headers or providing a filter to override IP resolution. + // See WooCommerce_Connection::get_client_ip() for a forwarded-header approach. + $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '127.0.0.1'; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders,WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__REMOTE_ADDR__ + $cache_key = 'newspack_reg_ip_' . md5( $ip ); + + /** + * Filters the maximum number of frontend registration attempts per IP per hour. + * + * @param int $limit Maximum attempts. Default 10. + * @param string $ip The client IP address. + */ + $limit = \apply_filters( 'newspack_frontend_registration_rate_limit', 10, $ip ); + + if ( \wp_using_ext_object_cache() ) { + $cache_group = 'newspack_rate_limit'; + \wp_cache_add( $cache_key, 0, $cache_group, HOUR_IN_SECONDS ); + $attempts = \wp_cache_incr( $cache_key, 1, $cache_group ); + } else { + $attempts = (int) \get_transient( $cache_key ); + \set_transient( $cache_key, $attempts + 1, HOUR_IN_SECONDS ); + $attempts++; + } + + if ( $attempts > $limit ) { + Logger::log( 'Frontend registration rate limit exceeded for IP ' . $ip ); + return new \WP_Error( + 'rate_limit_exceeded', + __( 'Too many registration attempts. Please try again later.', 'newspack-plugin' ), + [ 'status' => 429 ] + ); + } + + return true; + } + + /** + * REST API handler for frontend integration reader registration. + * + * Validation sequence: + * 1. Already logged in — return current reader data + * 2. Reader Activation is enabled + * 3. Integration ID is registered + * 4. Integration key matches HMAC + * 5. Honeypot field is empty + * 6. Per-IP rate limit + * 7. reCAPTCHA (when configured) + * 8. Email is valid + * + * @param \WP_REST_Request $request Request object. + * @return \WP_REST_Response|\WP_Error + */ + public static function api_frontend_register_reader( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + // Step 1: If caller is already logged in, return current reader data. + // This makes the API idempotent — integrations don't need to check + // authentication state before calling register(). + if ( \is_user_logged_in() ) { + $current_user = \wp_get_current_user(); + return new \WP_REST_Response( + [ + 'success' => true, + 'status' => 'existing', + 'email' => $current_user->user_email, + ], + 200 + ); + } + + // Step 2: Check RAS is enabled. + if ( ! Reader_Activation::is_enabled() ) { + return new \WP_Error( + 'reader_activation_disabled', + __( 'Reader Activation is not enabled.', 'newspack-plugin' ), + [ 'status' => 403 ] + ); + } + + // Step 3: Validate integration ID is registered. + $integration_id = $request->get_param( 'integration_id' ); + $integrations = self::get_frontend_registration_integrations(); + if ( empty( $integration_id ) || ! isset( $integrations[ $integration_id ] ) ) { + Logger::log( 'Frontend registration rejected: invalid integration ID "' . $integration_id . '"' ); + return new \WP_Error( + 'invalid_integration', + __( 'Invalid integration.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + // Step 4: Validate integration key. + $integration_key = $request->get_param( 'integration_key' ); + $expected_key = self::get_frontend_registration_key( $integration_id ); + if ( ! hash_equals( $expected_key, $integration_key ) ) { + Logger::log( 'Frontend registration rejected: invalid key for integration "' . $integration_id . '"' ); + return new \WP_Error( + 'invalid_integration_key', + __( 'Invalid integration key.', 'newspack-plugin' ), + [ 'status' => 403 ] + ); + } + + // Step 5: Honeypot — the `email` field must be empty. Real email is in `npe`. + $honeypot = $request->get_param( 'email' ); + if ( ! empty( $honeypot ) ) { + // Return fake success to avoid revealing the honeypot to bots. + // @todo Consider returning the npe value instead of the honeypot value to make + // the fake response indistinguishable from a real one. + return new \WP_REST_Response( + [ + 'success' => true, + 'status' => 'created', + 'email' => $honeypot, + ], + 200 + ); + } + + // Step 6: Per-IP rate limit. Checked before reCAPTCHA to avoid + // triggering external verification calls for rate-limited IPs. + $rate_check = self::check_registration_rate_limit(); + if ( \is_wp_error( $rate_check ) ) { + return $rate_check; + } + + // Step 7: reCAPTCHA (when configured). + $recaptcha_token = $request->get_param( 'g-recaptcha-response' ); + $should_verify = \apply_filters( 'newspack_recaptcha_verify_captcha', Recaptcha::can_use_captcha(), '', 'integration_registration' ); + if ( $should_verify ) { + // Bridge: verify_captcha() reads from $_POST. + // @todo Refactor Recaptcha::verify_captcha() to accept an optional $token parameter, eliminating this $_POST mutation. + $_POST['g-recaptcha-response'] = $recaptcha_token; // phpcs:ignore WordPress.Security.NonceVerification.Missing + $captcha_result = Recaptcha::verify_captcha(); + unset( $_POST['g-recaptcha-response'] ); + if ( \is_wp_error( $captcha_result ) ) { + return new \WP_Error( + 'recaptcha_failed', + $captcha_result->get_error_message(), + [ 'status' => 403 ] + ); + } + } + + // Step 8: Validate email. + $email = $request->get_param( 'npe' ); + if ( empty( $email ) ) { + return new \WP_Error( + 'invalid_email', + __( 'A valid email address is required.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + // Build display name from profile fields. + $first_name = $request->get_param( 'first_name' ); + $last_name = $request->get_param( 'last_name' ); + $display_name = trim( $first_name . ' ' . $last_name ); + + // Build metadata. Normalize referer to a local path, matching process_auth_form(). + $referer = \wp_parse_url( \wp_get_referer() ); + $referer = is_array( $referer ) ? $referer : []; + $current_page_url = ! empty( $referer['path'] ) ? \esc_url( \home_url( $referer['path'] ) ) : ''; + $metadata = [ + 'registration_method' => 'integration-registration-' . $integration_id, + 'current_page_url' => $current_page_url, + ]; + + $result = Reader_Activation::register_reader( $email, $display_name, true, $metadata ); + + if ( \is_wp_error( $result ) ) { + // Race condition: concurrent requests for the same email can cause + // wp_insert_user() or wc_create_new_customer() to return an "existing + // user" error instead of register_reader() returning false. + $existing_user_codes = [ 'existing_user_email', 'existing_user_login', 'registration-error-email-exists' ]; + if ( array_intersect( $result->get_error_codes(), $existing_user_codes ) ) { + return new \WP_Error( + 'reader_already_exists', + __( 'A reader with this email address is already registered.', 'newspack-plugin' ), + [ 'status' => 409 ] + ); + } + + return new \WP_Error( + 'registration_failed', + $result->get_error_message(), + [ 'status' => 500 ] + ); + } + + // @todo register_reader() returns false for both existing readers (sends magic link) + // and existing non-reader accounts (sends login reminder). This 409 treats both + // identically. Consider distinguishing these cases to avoid disclosing account type. + if ( false === $result ) { + return new \WP_Error( + 'reader_already_exists', + __( 'A reader with this email address is already registered.', 'newspack-plugin' ), + [ 'status' => 409 ] + ); + } + + // Apply profile fields after creation. + if ( ! empty( $first_name ) || ! empty( $last_name ) ) { + \wp_update_user( + [ + 'ID' => $result, + 'first_name' => $first_name, + 'last_name' => $last_name, + ] + ); + } + + return new \WP_REST_Response( + [ + 'success' => true, + 'status' => 'created', + 'email' => $email, + ], + 201 + ); + } +} +Reader_Registration::init(); From 43417391cbf366da3f10336edb0034a2cc91c21b Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 9 Apr 2026 15:21:37 -0700 Subject: [PATCH 34/45] refactor: delegate frontend registration to Reader_Registration class --- includes/class-newspack.php | 1 + .../class-reader-activation.php | 316 +----------------- 2 files changed, 2 insertions(+), 315 deletions(-) diff --git a/includes/class-newspack.php b/includes/class-newspack.php index a47a58dbb2..d47c13bd1e 100644 --- a/includes/class-newspack.php +++ b/includes/class-newspack.php @@ -98,6 +98,7 @@ private function includes() { include_once NEWSPACK_ABSPATH . 'includes/class-admin-plugins-screen.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-reader-activation-emails.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-reader-activation.php'; + include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-reader-registration.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/class-reader-data.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/sync/class-sync.php'; include_once NEWSPACK_ABSPATH . 'includes/reader-activation/sync/class-metadata.php'; diff --git a/includes/reader-activation/class-reader-activation.php b/includes/reader-activation/class-reader-activation.php index 15c5159419..f1e8093e85 100644 --- a/includes/reader-activation/class-reader-activation.php +++ b/includes/reader-activation/class-reader-activation.php @@ -147,18 +147,7 @@ public static function enqueue_scripts() { 'is_ras_enabled' => self::is_enabled(), ]; - $frontend_integrations = self::get_frontend_registration_integrations(); - if ( self::is_enabled() && ! empty( $frontend_integrations ) ) { - $integrations_config = []; - foreach ( $frontend_integrations as $id => $label ) { - $integrations_config[ $id ] = [ - 'key' => self::get_frontend_registration_key( $id ), - 'label' => $label, - ]; - } - $script_data['frontend_registration_integrations'] = $integrations_config; - $script_data['frontend_registration_url'] = \rest_url( NEWSPACK_API_NAMESPACE . '/reader-activation/register' ); - } + $script_data = array_merge( $script_data, Reader_Registration::get_script_data() ); if ( Recaptcha::can_use_captcha() ) { $recaptcha_version = Recaptcha::get_setting( 'version' ); @@ -272,309 +261,6 @@ public static function register_routes() { ], ] ); - - \register_rest_route( - NEWSPACK_API_NAMESPACE, - '/reader-activation/register', - [ - 'methods' => \WP_REST_Server::CREATABLE, - 'callback' => [ __CLASS__, 'api_frontend_register_reader' ], - 'permission_callback' => '__return_true', - 'args' => [ - 'npe' => [ - 'type' => 'string', - 'sanitize_callback' => 'sanitize_email', - 'default' => '', - ], - 'email' => [ - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'default' => '', - ], - 'integration_id' => [ - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'default' => '', - ], - 'integration_key' => [ - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'default' => '', - ], - 'first_name' => [ - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'default' => '', - ], - 'last_name' => [ - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'default' => '', - ], - 'g-recaptcha-response' => [ - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'default' => '', - ], - ], - ] - ); - } - - /** - * Get registered frontend registration integrations. - * - * @return array Map of integration ID => label. - */ - public static function get_frontend_registration_integrations(): array { - /** - * Filters the list of integrations that can trigger frontend reader registration. - * - * @param array $integrations Map of integration ID => display label. - */ - return \apply_filters( 'newspack_frontend_registration_integrations', [] ); - } - - /** - * Generate an HMAC key for a frontend registration integration. - * - * The key is deterministic (safe for page caching) and unique per - * integration ID and site. It is not a secret — it is output to the - * page source — but it binds registration requests to a PHP-registered - * integration, preventing arbitrary callers. - * - * @param string $integration_id Integration identifier. - * @return string HMAC-SHA256 hex string. - */ - public static function get_frontend_registration_key( string $integration_id ): string { - return hash_hmac( 'sha256', $integration_id, \wp_salt( 'auth' ) ); - } - - /** - * Check and increment the per-IP rate limit for frontend registration. - * - * @return bool|\WP_Error True if under limit, WP_Error if exceeded. - */ - private static function check_registration_rate_limit(): bool|\WP_Error { - // @todo REMOTE_ADDR may be a proxy/load-balancer IP in some environments. - // On WordPress VIP/Atomic this is the real client IP. For other hosts, - // consider parsing forwarded headers or providing a filter to override IP resolution. - // See WooCommerce_Connection::get_client_ip() for a forwarded-header approach. - $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '127.0.0.1'; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders,WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__REMOTE_ADDR__ - $cache_key = 'newspack_reg_ip_' . md5( $ip ); - - /** - * Filters the maximum number of frontend registration attempts per IP per hour. - * - * @param int $limit Maximum attempts. Default 10. - * @param string $ip The client IP address. - */ - $limit = \apply_filters( 'newspack_frontend_registration_rate_limit', 10, $ip ); - - if ( \wp_using_ext_object_cache() ) { - $cache_group = 'newspack_rate_limit'; - \wp_cache_add( $cache_key, 0, $cache_group, HOUR_IN_SECONDS ); - $attempts = \wp_cache_incr( $cache_key, 1, $cache_group ); - } else { - $attempts = (int) \get_transient( $cache_key ); - \set_transient( $cache_key, $attempts + 1, HOUR_IN_SECONDS ); - $attempts++; - } - - if ( $attempts > $limit ) { - Logger::log( 'Frontend registration rate limit exceeded for IP ' . $ip ); - return new \WP_Error( - 'rate_limit_exceeded', - __( 'Too many registration attempts. Please try again later.', 'newspack-plugin' ), - [ 'status' => 429 ] - ); - } - - return true; - } - - /** - * REST API handler for frontend integration reader registration. - * - * Validation sequence: - * 1. Already logged in — return current reader data - * 2. Reader Activation is enabled - * 3. Integration ID is registered - * 4. Integration key matches HMAC - * 5. Honeypot field is empty - * 6. Per-IP rate limit - * 7. reCAPTCHA (when configured) - * 8. Email is valid - * - * @param \WP_REST_Request $request Request object. - * @return \WP_REST_Response|\WP_Error - */ - public static function api_frontend_register_reader( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - // Step 1: If caller is already logged in, return current reader data. - // This makes the API idempotent — integrations don't need to check - // authentication state before calling register(). - if ( \is_user_logged_in() ) { - $current_user = \wp_get_current_user(); - return new \WP_REST_Response( - [ - 'success' => true, - 'status' => 'existing', - 'email' => $current_user->user_email, - ], - 200 - ); - } - - // Step 2: Check RAS is enabled. - if ( ! self::is_enabled() ) { - return new \WP_Error( - 'reader_activation_disabled', - __( 'Reader Activation is not enabled.', 'newspack-plugin' ), - [ 'status' => 403 ] - ); - } - - // Step 3: Validate integration ID is registered. - $integration_id = $request->get_param( 'integration_id' ); - $integrations = self::get_frontend_registration_integrations(); - if ( empty( $integration_id ) || ! isset( $integrations[ $integration_id ] ) ) { - Logger::log( 'Frontend registration rejected: invalid integration ID "' . $integration_id . '"' ); - return new \WP_Error( - 'invalid_integration', - __( 'Invalid integration.', 'newspack-plugin' ), - [ 'status' => 400 ] - ); - } - - // Step 4: Validate integration key. - $integration_key = $request->get_param( 'integration_key' ); - $expected_key = self::get_frontend_registration_key( $integration_id ); - if ( ! hash_equals( $expected_key, $integration_key ) ) { - Logger::log( 'Frontend registration rejected: invalid key for integration "' . $integration_id . '"' ); - return new \WP_Error( - 'invalid_integration_key', - __( 'Invalid integration key.', 'newspack-plugin' ), - [ 'status' => 403 ] - ); - } - - // Step 5: Honeypot — the `email` field must be empty. Real email is in `npe`. - $honeypot = $request->get_param( 'email' ); - if ( ! empty( $honeypot ) ) { - // Return fake success to avoid revealing the honeypot to bots. - // @todo Consider returning the npe value instead of the honeypot value to make - // the fake response indistinguishable from a real one. - return new \WP_REST_Response( - [ - 'success' => true, - 'status' => 'created', - 'email' => $honeypot, - ], - 200 - ); - } - - // Step 6: Per-IP rate limit. Checked before reCAPTCHA to avoid - // triggering external verification calls for rate-limited IPs. - $rate_check = self::check_registration_rate_limit(); - if ( \is_wp_error( $rate_check ) ) { - return $rate_check; - } - - // Step 7: reCAPTCHA (when configured). - $recaptcha_token = $request->get_param( 'g-recaptcha-response' ); - $should_verify = \apply_filters( 'newspack_recaptcha_verify_captcha', Recaptcha::can_use_captcha(), '', 'integration_registration' ); - if ( $should_verify ) { - // Bridge: verify_captcha() reads from $_POST. - // @todo Refactor Recaptcha::verify_captcha() to accept an optional $token parameter, eliminating this $_POST mutation. - $_POST['g-recaptcha-response'] = $recaptcha_token; // phpcs:ignore WordPress.Security.NonceVerification.Missing - $captcha_result = Recaptcha::verify_captcha(); - unset( $_POST['g-recaptcha-response'] ); - if ( \is_wp_error( $captcha_result ) ) { - return new \WP_Error( - 'recaptcha_failed', - $captcha_result->get_error_message(), - [ 'status' => 403 ] - ); - } - } - - // Step 8: Validate email. - $email = $request->get_param( 'npe' ); - if ( empty( $email ) ) { - return new \WP_Error( - 'invalid_email', - __( 'A valid email address is required.', 'newspack-plugin' ), - [ 'status' => 400 ] - ); - } - - // Build display name from profile fields. - $first_name = $request->get_param( 'first_name' ); - $last_name = $request->get_param( 'last_name' ); - $display_name = trim( $first_name . ' ' . $last_name ); - - // Build metadata. Normalize referer to a local path, matching process_auth_form(). - $referer = \wp_parse_url( \wp_get_referer() ); - $referer = is_array( $referer ) ? $referer : []; - $current_page_url = ! empty( $referer['path'] ) ? \esc_url( \home_url( $referer['path'] ) ) : ''; - $metadata = [ - 'registration_method' => 'integration-registration-' . $integration_id, - 'current_page_url' => $current_page_url, - ]; - - $result = self::register_reader( $email, $display_name, true, $metadata ); - - if ( \is_wp_error( $result ) ) { - // Race condition: concurrent requests for the same email can cause - // wp_insert_user() or wc_create_new_customer() to return an "existing - // user" error instead of register_reader() returning false. - $existing_user_codes = [ 'existing_user_email', 'existing_user_login', 'registration-error-email-exists' ]; - if ( array_intersect( $result->get_error_codes(), $existing_user_codes ) ) { - return new \WP_Error( - 'reader_already_exists', - __( 'A reader with this email address is already registered.', 'newspack-plugin' ), - [ 'status' => 409 ] - ); - } - - return new \WP_Error( - 'registration_failed', - $result->get_error_message(), - [ 'status' => 500 ] - ); - } - - // @todo register_reader() returns false for both existing readers (sends magic link) - // and existing non-reader accounts (sends login reminder). This 409 treats both - // identically. Consider distinguishing these cases to avoid disclosing account type. - if ( false === $result ) { - return new \WP_Error( - 'reader_already_exists', - __( 'A reader with this email address is already registered.', 'newspack-plugin' ), - [ 'status' => 409 ] - ); - } - - // Apply profile fields after creation. - if ( ! empty( $first_name ) || ! empty( $last_name ) ) { - \wp_update_user( - [ - 'ID' => $result, - 'first_name' => $first_name, - 'last_name' => $last_name, - ] - ); - } - - return new \WP_REST_Response( - [ - 'success' => true, - 'status' => 'created', - 'email' => $email, - ], - 201 - ); } /** From b22440144210591ddcb55611c85e981384ec6225 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 9 Apr 2026 15:24:07 -0700 Subject: [PATCH 35/45] test: update references to Reader_Registration class --- tests/unit-tests/reader-registration-endpoint.php | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php index 2efa3f3eec..b783bd6cc3 100644 --- a/tests/unit-tests/reader-registration-endpoint.php +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -6,6 +6,7 @@ */ use Newspack\Reader_Activation; +use Newspack\Reader_Registration; /** * Tests the Frontend Reader Registration REST endpoint. @@ -73,7 +74,7 @@ public function set_up() { add_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); // Ensure routes are registered — Reader_Activation::init() may have run // before IS_TEST_ENV was defined, skipping the rest_api_init hook. - add_action( 'rest_api_init', [ Reader_Activation::class, 'register_routes' ] ); + add_action( 'rest_api_init', [ Reader_Registration::class, 'register_routes' ] ); do_action( 'rest_api_init' ); wp_set_current_user( 0 ); } @@ -85,7 +86,7 @@ public function tear_down() { global $wp_rest_server; $wp_rest_server = null; remove_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); - remove_action( 'rest_api_init', [ Reader_Activation::class, 'register_routes' ] ); + remove_action( 'rest_api_init', [ Reader_Registration::class, 'register_routes' ] ); $user = get_user_by( 'email', self::$reader_email ); if ( $user ) { wp_delete_user( $user->ID ); @@ -538,7 +539,7 @@ public function test_recaptcha_filter_disables_verification() { */ public function test_get_frontend_registration_integrations() { // The test integration is registered via the filter in set_up(). - $integrations = Reader_Activation::get_frontend_registration_integrations(); + $integrations = Reader_Registration::get_frontend_registration_integrations(); $this->assertArrayHasKey( self::$integration_id, $integrations ); $this->assertEquals( 'Test Integration', $integrations[ self::$integration_id ] ); } @@ -549,7 +550,7 @@ public function test_get_frontend_registration_integrations() { public function test_get_frontend_registration_integrations_empty_without_filter() { remove_filter( 'newspack_frontend_registration_integrations', [ __CLASS__, 'register_test_integration' ] ); - $integrations = Reader_Activation::get_frontend_registration_integrations(); + $integrations = Reader_Registration::get_frontend_registration_integrations(); $this->assertEmpty( $integrations ); // Re-add for tear_down consistency. @@ -560,9 +561,9 @@ public function test_get_frontend_registration_integrations_empty_without_filter * Test that integration key generation is deterministic and unique per ID. */ public function test_integration_key_determinism_and_uniqueness() { - $key_a_first = Reader_Activation::get_frontend_registration_key( 'integration-a' ); - $key_a_second = Reader_Activation::get_frontend_registration_key( 'integration-a' ); - $key_b = Reader_Activation::get_frontend_registration_key( 'integration-b' ); + $key_a_first = Reader_Registration::get_frontend_registration_key( 'integration-a' ); + $key_a_second = Reader_Registration::get_frontend_registration_key( 'integration-a' ); + $key_b = Reader_Registration::get_frontend_registration_key( 'integration-b' ); // Same ID produces the same key. $this->assertEquals( $key_a_first, $key_a_second ); From b0074c0e8183d13e000b5624dd7389d8911bf0c9 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 9 Apr 2026 15:30:14 -0700 Subject: [PATCH 36/45] fix: add `use` statements for clarity --- includes/reader-activation/class-reader-registration.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/includes/reader-activation/class-reader-registration.php b/includes/reader-activation/class-reader-registration.php index 55c096d863..cc47aa6909 100644 --- a/includes/reader-activation/class-reader-registration.php +++ b/includes/reader-activation/class-reader-registration.php @@ -10,6 +10,9 @@ namespace Newspack; +use Newspack\Recaptcha; +use Newspack\Logger; + defined( 'ABSPATH' ) || exit; /** From 2b4d04fd813b9b1df0f55f30432c7fe43b0506ae Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 9 Apr 2026 16:00:29 -0700 Subject: [PATCH 37/45] feat: add overridable registration key methods to Integration base class --- .../integrations/class-integration.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/includes/reader-activation/integrations/class-integration.php b/includes/reader-activation/integrations/class-integration.php index 949a57c6e9..9ea6f3f36d 100644 --- a/includes/reader-activation/integrations/class-integration.php +++ b/includes/reader-activation/integrations/class-integration.php @@ -126,6 +126,45 @@ public function get_description() { return $this->description; } + /** + * Whether this integration supports frontend reader registration. + * + * Integrations that return true will have their key output to the page + * and will be accepted by the frontend registration endpoint. + * + * @return bool + */ + public function supports_frontend_registration(): bool { + return false; + } + + /** + * Generate the registration key for this integration. + * + * The default implementation uses HMAC-SHA256 with the site's auth salt. + * Subclasses can override this to implement custom key schemes + * (e.g., asymmetric key pairs, time-bounded tokens). + * + * @return string The registration key. + */ + public function get_registration_key(): string { + return hash_hmac( 'sha256', $this->id, \wp_salt( 'auth' ) ); + } + + /** + * Validate a submitted registration key for this integration. + * + * The default implementation uses timing-safe comparison against + * the HMAC key. Subclasses can override this to implement custom + * validation (e.g., signature verification, token decryption). + * + * @param string $key The submitted key to validate. + * @return bool Whether the key is valid. + */ + public function validate_registration_key( string $key ): bool { + return hash_equals( $this->get_registration_key(), $key ); + } + /** * Initialize the integration, performing any necessary setup or validation. * From 6aba7468915e603f278e3ff7b3a8684cae6ef646 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 9 Apr 2026 16:02:09 -0700 Subject: [PATCH 38/45] feat: delegate key generation and validation to Integration instances --- .../class-reader-registration.php | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/includes/reader-activation/class-reader-registration.php b/includes/reader-activation/class-reader-registration.php index cc47aa6909..22deac86f0 100644 --- a/includes/reader-activation/class-reader-registration.php +++ b/includes/reader-activation/class-reader-registration.php @@ -12,6 +12,7 @@ use Newspack\Recaptcha; use Newspack\Logger; +use Newspack\Reader_Activation\Integrations; defined( 'ABSPATH' ) || exit; @@ -92,7 +93,16 @@ public static function get_frontend_registration_integrations(): array { * * @param array $integrations Map of integration ID => display label. */ - return \apply_filters( 'newspack_frontend_registration_integrations', [] ); + $integrations = \apply_filters( 'newspack_frontend_registration_integrations', [] ); + + // Also include Integration subclasses that opt in. + foreach ( Integrations::get_available_integrations() as $integration ) { + if ( $integration->supports_frontend_registration() && ! isset( $integrations[ $integration->get_id() ] ) ) { + $integrations[ $integration->get_id() ] = $integration->get_name(); + } + } + + return $integrations; } /** @@ -107,6 +117,11 @@ public static function get_frontend_registration_integrations(): array { * @return string HMAC-SHA256 hex string. */ public static function get_frontend_registration_key( string $integration_id ): string { + $integration = Integrations::get_integration( $integration_id ); + if ( $integration && $integration->supports_frontend_registration() ) { + return $integration->get_registration_key(); + } + // Fallback for filter-only registrations. return hash_hmac( 'sha256', $integration_id, \wp_salt( 'auth' ) ); } @@ -239,9 +254,16 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) } // Step 4: Validate integration key. - $integration_key = $request->get_param( 'integration_key' ); - $expected_key = self::get_frontend_registration_key( $integration_id ); - if ( ! hash_equals( $expected_key, $integration_key ) ) { + $integration_key = $request->get_param( 'integration_key' ); + $integration_instance = Integrations::get_integration( $integration_id ); + if ( $integration_instance && $integration_instance->supports_frontend_registration() ) { + $key_valid = $integration_instance->validate_registration_key( $integration_key ); + } else { + // Fallback for filter-only registrations. + $expected_key = self::get_frontend_registration_key( $integration_id ); + $key_valid = hash_equals( $expected_key, $integration_key ); + } + if ( ! $key_valid ) { Logger::log( 'Frontend registration rejected: invalid key for integration "' . $integration_id . '"' ); return new \WP_Error( 'invalid_integration_key', From c3ad7350c0158c8e1d8108049b4758c6c7ce81dc Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 9 Apr 2026 16:26:33 -0700 Subject: [PATCH 39/45] test: add tests for Integration-based key generation and validation --- .../reader-registration-endpoint.php | 184 +++++++++++++++++- 1 file changed, 183 insertions(+), 1 deletion(-) diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php index b783bd6cc3..425dad573f 100644 --- a/tests/unit-tests/reader-registration-endpoint.php +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -1,4 +1,4 @@ -assertMatchesRegularExpression( '/^[a-f0-9]{64}$/', $key_a_first ); $this->assertMatchesRegularExpression( '/^[a-f0-9]{64}$/', $key_b ); } + + /** + * Test registration via an Integration subclass with default HMAC key. + */ + public function test_register_via_integration_subclass() { + $integration = new Test_Frontend_Integration( 'subclass-test', 'Subclass Test' ); + Integrations::register( $integration ); + + $response = $this->do_register_request( + [ + 'npe' => 'subclass@test.com', + 'integration_id' => 'subclass-test', + 'integration_key' => $integration->get_registration_key(), + ] + ); + $this->assertEquals( 201, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['success'] ); + + $user = get_user_by( 'email', 'subclass@test.com' ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that a custom key validation override is used. + */ + public function test_custom_key_validation() { + $integration = new Test_Custom_Key_Integration( 'custom-key-test', 'Custom Key Test' ); + Integrations::register( $integration ); + + // The public key (from get_registration_key) should NOT validate. + $response = $this->do_register_request( + [ + 'npe' => 'custom@test.com', + 'integration_id' => 'custom-key-test', + 'integration_key' => 'custom-public-key', + ] + ); + $this->assertEquals( 403, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( 'invalid_integration_key', $data['code'] ); + + // The secret key should validate via the custom validate_registration_key(). + $response = $this->do_register_request( + [ + 'npe' => 'custom@test.com', + 'integration_id' => 'custom-key-test', + 'integration_key' => 'custom-secret-key', + ] + ); + $this->assertEquals( 201, $response->get_status() ); + + $user = get_user_by( 'email', 'custom@test.com' ); + if ( $user ) { + wp_delete_user( $user->ID ); + } + } + + /** + * Test that Integration subclass is included in get_frontend_registration_integrations(). + */ + public function test_integration_subclass_in_registry() { + // subclass-test was registered in test_register_via_integration_subclass. + $integrations = Reader_Registration::get_frontend_registration_integrations(); + $this->assertArrayHasKey( 'subclass-test', $integrations ); + $this->assertEquals( 'Subclass Test', $integrations['subclass-test'] ); + } } From 9e48c6ea423869a24c2d4ac62ed11161193a3ea6 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 9 Apr 2026 16:31:50 -0700 Subject: [PATCH 40/45] test: register integration within test because apparently PHPUnit does not guarantee execution order --- tests/unit-tests/reader-registration-endpoint.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php index 425dad573f..021aedc0fa 100644 --- a/tests/unit-tests/reader-registration-endpoint.php +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -750,9 +750,11 @@ public function test_custom_key_validation() { * Test that Integration subclass is included in get_frontend_registration_integrations(). */ public function test_integration_subclass_in_registry() { - // subclass-test was registered in test_register_via_integration_subclass. + $integration = new Test_Frontend_Integration( 'registry-test', 'Registry Test' ); + Integrations::register( $integration ); + $integrations = Reader_Registration::get_frontend_registration_integrations(); - $this->assertArrayHasKey( 'subclass-test', $integrations ); - $this->assertEquals( 'Subclass Test', $integrations['subclass-test'] ); + $this->assertArrayHasKey( 'registry-test', $integrations ); + $this->assertEquals( 'Registry Test', $integrations['registry-test'] ); } } From 09236d8394385eb6cbfb96b80e8dd70926176eb8 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 9 Apr 2026 16:37:22 -0700 Subject: [PATCH 41/45] docs: make it clear that overriding the validation step means writing key generation too --- .../reader-activation/integrations/class-integration.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/includes/reader-activation/integrations/class-integration.php b/includes/reader-activation/integrations/class-integration.php index 9ea6f3f36d..543b894197 100644 --- a/includes/reader-activation/integrations/class-integration.php +++ b/includes/reader-activation/integrations/class-integration.php @@ -158,6 +158,11 @@ public function get_registration_key(): string { * the HMAC key. Subclasses can override this to implement custom * validation (e.g., signature verification, token decryption). * + * Note: The built-in JS client (newspackReaderActivation.register()) + * always sends the value from get_registration_key(). Integrations + * that override this method to accept a different value must provide + * their own client-side code to compute and submit the correct key. + * * @param string $key The submitted key to validate. * @return bool Whether the key is valid. */ From 4c23648a7e4c56e54076ce907d06cabba0387fd2 Mon Sep 17 00:00:00 2001 From: leogermani Date: Tue, 14 Apr 2026 20:13:23 -0300 Subject: [PATCH 42/45] feat: more options for registration --- .../class-reader-registration.php | 37 ++++++++++++++++++- .../integrations/class-integration.php | 13 +++++-- src/reader-activation/index.js | 8 ++-- .../reader-registration-endpoint.php | 9 +++-- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/includes/reader-activation/class-reader-registration.php b/includes/reader-activation/class-reader-registration.php index 22deac86f0..8915deafbd 100644 --- a/includes/reader-activation/class-reader-registration.php +++ b/includes/reader-activation/class-reader-registration.php @@ -72,6 +72,11 @@ public static function register_routes() { 'sanitize_callback' => 'sanitize_text_field', 'default' => '', ], + 'metadata' => [ + 'type' => 'object', + 'default' => [], + 'sanitize_callback' => [ __CLASS__, 'sanitize_metadata' ], + ], 'g-recaptcha-response' => [ 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', @@ -82,6 +87,28 @@ public static function register_routes() { ); } + /** + * Sanitize the metadata parameter. + * + * Ensures all keys and values are sanitized strings. + * + * @param array $metadata Raw metadata from the request. + * @return array Sanitized metadata. + */ + public static function sanitize_metadata( $metadata ) { + if ( ! is_array( $metadata ) ) { + return []; + } + $sanitized = []; + foreach ( $metadata as $key => $value ) { + $key = sanitize_key( $key ); + if ( ! empty( $key ) ) { + $sanitized[ $key ] = \sanitize_text_field( $value ); + } + } + return $sanitized; + } + /** * Get registered frontend registration integrations. * @@ -257,7 +284,7 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) $integration_key = $request->get_param( 'integration_key' ); $integration_instance = Integrations::get_integration( $integration_id ); if ( $integration_instance && $integration_instance->supports_frontend_registration() ) { - $key_valid = $integration_instance->validate_registration_key( $integration_key ); + $key_valid = $integration_instance->validate_registration_request( $integration_key, $request ); } else { // Fallback for filter-only registrations. $expected_key = self::get_frontend_registration_key( $integration_id ); @@ -381,6 +408,14 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) ); } + // Save arbitrary user metadata. + $user_metadata = $request->get_param( 'metadata' ); + if ( ! empty( $user_metadata ) ) { + foreach ( $user_metadata as $meta_key => $meta_value ) { + \update_user_meta( $result, $meta_key, $meta_value ); + } + } + return new \WP_REST_Response( [ 'success' => true, diff --git a/includes/reader-activation/integrations/class-integration.php b/includes/reader-activation/integrations/class-integration.php index 8c7e589f6d..b8bd66d773 100644 --- a/includes/reader-activation/integrations/class-integration.php +++ b/includes/reader-activation/integrations/class-integration.php @@ -163,10 +163,15 @@ public function get_registration_key(): string { * that override this method to accept a different value must provide * their own client-side code to compute and submit the correct key. * - * @param string $key The submitted key to validate. - * @return bool Whether the key is valid. + * The default implementation validates the HMAC key. Subclasses can override + * this method to perform additional checks on the request (e.g. verifying + * custom headers, validating metadata, or enforcing integration-specific rules). + * + * @param string $key The submitted key to validate. + * @param \WP_REST_Request $request The full registration request. + * @return bool Whether the registration request is valid. */ - public function validate_registration_key( string $key ): bool { + public function validate_registration_request( string $key, $request ): bool { return hash_equals( $this->get_registration_key(), $key ); } @@ -794,7 +799,7 @@ private function get_settings_field_by_key( $key ) { * @param mixed $value The value to sanitize. * @return mixed The sanitized value. */ - private function sanitize_settings_field_value( $field, $value ) { + protected function sanitize_settings_field_value( $field, $value ) { $type = $field['type'] ?? 'text'; switch ( $type ) { case 'checkbox': diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index 8967b9738f..0b18a815a5 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -501,9 +501,10 @@ function acquireV2InvisibleToken( siteKey ) { /** * Register a reader via a frontend integration. * - * @param {string} email Reader email address. - * @param {string} integrationId Registered integration ID. - * @param {Object} profileFields Optional profile fields: { first_name, last_name }. + * @param {string} email Reader email address. + * @param {string} integrationId Registered integration ID. + * @param {Object} profileFields Optional profile fields: { first_name, last_name, metadata }. + * @param {Object} profileFields.metadata Optional arbitrary key-value pairs to store as user meta. * @return {Promise} Resolves with reader data on success, rejects with error on failure. */ function register( email, integrationId, profileFields = {} ) { @@ -528,6 +529,7 @@ function register( email, integrationId, profileFields = {} ) { integration_key: integration.key, first_name: profileFields.first_name || '', last_name: profileFields.last_name || '', + metadata: profileFields.metadata || {}, }; // Acquire reCAPTCHA token if configured, using the appropriate version flow. diff --git a/tests/unit-tests/reader-registration-endpoint.php b/tests/unit-tests/reader-registration-endpoint.php index 021aedc0fa..195334983f 100644 --- a/tests/unit-tests/reader-registration-endpoint.php +++ b/tests/unit-tests/reader-registration-endpoint.php @@ -80,12 +80,13 @@ public function get_registration_key(): string { } /** - * Validate the registration key using a different secret key. + * Validate the registration request using a different secret key. * - * @param string $key Key to validate. + * @param string $key Key to validate. + * @param \WP_REST_Request $request The registration request. * @return bool */ - public function validate_registration_key( string $key ): bool { + public function validate_registration_request( string $key, $request ): bool { return $key === 'custom-secret-key'; } @@ -730,7 +731,7 @@ public function test_custom_key_validation() { $data = $response->get_data(); $this->assertEquals( 'invalid_integration_key', $data['code'] ); - // The secret key should validate via the custom validate_registration_key(). + // The secret key should validate via the custom validate_registration_request(). $response = $this->do_register_request( [ 'npe' => 'custom@test.com', From 8676e2d2a5d3e4ed2d0ad0f41ba1d7be19c9680d Mon Sep 17 00:00:00 2001 From: leogermani Date: Tue, 14 Apr 2026 20:18:08 -0300 Subject: [PATCH 43/45] fix: textarea support --- .../audience/views/integrations/settings-field.js | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/wizards/audience/views/integrations/settings-field.js b/src/wizards/audience/views/integrations/settings-field.js index ba2514d063..89fe188d1b 100644 --- a/src/wizards/audience/views/integrations/settings-field.js +++ b/src/wizards/audience/views/integrations/settings-field.js @@ -2,7 +2,7 @@ * WordPress dependencies. */ import { __ } from '@wordpress/i18n'; -import { CheckboxControl, ExternalLink } from '@wordpress/components'; +import { CheckboxControl, ExternalLink, TextareaControl } from '@wordpress/components'; /** * Internal dependencies. @@ -72,15 +72,7 @@ export const SettingsField = ( { field, value, onChange } ) => { ); case 'textarea': return ( - + ); case 'number': return ( From 167383ae864de4761ff1544353676f0b24f7a64b Mon Sep 17 00:00:00 2001 From: leogermani Date: Thu, 16 Apr 2026 17:01:36 -0300 Subject: [PATCH 44/45] feat: trigger a hook and invoke an integration callback when logged in user registers --- .../reader-activation/class-integrations.php | 19 ++++++++ .../class-reader-registration.php | 46 ++++++++++++------- .../integrations/class-integration.php | 14 ++++++ 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/includes/reader-activation/class-integrations.php b/includes/reader-activation/class-integrations.php index a65e4315dc..cfd0e3b8a3 100644 --- a/includes/reader-activation/class-integrations.php +++ b/includes/reader-activation/class-integrations.php @@ -90,6 +90,7 @@ public static function init() { add_action( 'init', [ __CLASS__, 'register_my_account_endpoints' ], 6 ); add_filter( 'woocommerce_account_menu_items', [ __CLASS__, 'filter_my_account_menu_items' ] ); add_filter( 'query_vars', [ __CLASS__, 'filter_my_account_query_vars' ] ); + add_action( 'newspack_frontend_registration_existing_user', [ __CLASS__, 'handle_existing_user_registration' ], 10, 3 ); add_action( 'init', [ __CLASS__, 'schedule_health_check' ] ); add_action( self::HEALTH_CHECK_CRON_HOOK, [ __CLASS__, 'run_health_checks' ] ); add_filter( 'newspack_data_events_handler_action_group', [ __CLASS__, 'filter_handler_action_group' ], 10, 3 ); @@ -656,6 +657,24 @@ public static function filter_my_account_query_vars( $vars ) { return $vars; } + /** + * Handle an existing user attempting to register via a frontend integration. + * + * Delegates to the integration's handle_logged_in_user_registration() method if it exists, + * allowing integrations to update user data on repeated registration attempts + * (e.g. recording a new donation for a returning donor). + * + * @param \WP_User $user The logged-in user. + * @param \WP_REST_Request $request The registration request. + * @param \Newspack\Reader_Activation\Integration|null $integration_instance The integration instance, or null for filter-only registrations. + */ + public static function handle_existing_user_registration( $user, $request, $integration_instance ) { + if ( ! $integration_instance instanceof Integration ) { + return; + } + $integration_instance->handle_logged_in_user_registration( $user, $request ); + } + /** * Schedule the hourly health check cron event. * diff --git a/includes/reader-activation/class-reader-registration.php b/includes/reader-activation/class-reader-registration.php index 8915deafbd..e2e1e16ed8 100644 --- a/includes/reader-activation/class-reader-registration.php +++ b/includes/reader-activation/class-reader-registration.php @@ -244,11 +244,38 @@ private static function check_registration_rate_limit(): bool|\WP_Error { * @return \WP_REST_Response|\WP_Error */ public static function api_frontend_register_reader( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { - // Step 1: If caller is already logged in, return current reader data. + + // Step 1: Validate integration ID is registered. + $integration_id = $request->get_param( 'integration_id' ); + $integrations = self::get_frontend_registration_integrations(); + $integration_instance = Integrations::get_integration( $integration_id ); + + if ( empty( $integration_id ) || ! isset( $integrations[ $integration_id ] ) ) { + Logger::log( 'Frontend registration rejected: invalid integration ID "' . $integration_id . '"' ); + return new \WP_Error( + 'invalid_integration', + __( 'Invalid integration.', 'newspack-plugin' ), + [ 'status' => 400 ] + ); + } + + // Step 2: If caller is already logged in, return current reader data. // This makes the API idempotent — integrations don't need to check // authentication state before calling register(). if ( \is_user_logged_in() ) { $current_user = \wp_get_current_user(); + + /** + * Action triggered when a logged-in user attempts to register via the frontend registration endpoint. + * + * Integrations can hook into this action to handle cases where an existing user attempts to register again via the frontend registration flow. For example, an integration might want to link the existing user account to the integration or log this event for analytics purposes. + * + * @param \WP_User $current_user The currently logged-in user. + * @param \WP_REST_Request $request The original registration request. + * @param Integration|null $integration_instance The integration instance associated with the registration attempt, or null if the integration was registered via filter only. + */ + do_action( 'newspack_frontend_registration_existing_user', $current_user, $request, $integration_instance ); + return new \WP_REST_Response( [ 'success' => true, @@ -259,7 +286,7 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) ); } - // Step 2: Check RAS is enabled. + // Step 3: Check RAS is enabled. if ( ! Reader_Activation::is_enabled() ) { return new \WP_Error( 'reader_activation_disabled', @@ -268,21 +295,8 @@ public static function api_frontend_register_reader( \WP_REST_Request $request ) ); } - // Step 3: Validate integration ID is registered. - $integration_id = $request->get_param( 'integration_id' ); - $integrations = self::get_frontend_registration_integrations(); - if ( empty( $integration_id ) || ! isset( $integrations[ $integration_id ] ) ) { - Logger::log( 'Frontend registration rejected: invalid integration ID "' . $integration_id . '"' ); - return new \WP_Error( - 'invalid_integration', - __( 'Invalid integration.', 'newspack-plugin' ), - [ 'status' => 400 ] - ); - } - // Step 4: Validate integration key. - $integration_key = $request->get_param( 'integration_key' ); - $integration_instance = Integrations::get_integration( $integration_id ); + $integration_key = $request->get_param( 'integration_key' ); if ( $integration_instance && $integration_instance->supports_frontend_registration() ) { $key_valid = $integration_instance->validate_registration_request( $integration_key, $request ); } else { diff --git a/includes/reader-activation/integrations/class-integration.php b/includes/reader-activation/integrations/class-integration.php index b8bd66d773..a5da448d45 100644 --- a/includes/reader-activation/integrations/class-integration.php +++ b/includes/reader-activation/integrations/class-integration.php @@ -218,6 +218,20 @@ abstract public function can_sync( $return_errors = false ); */ abstract public function push_contact_data( $contact, $context = '', $existing_contact = null ); + /** + * Handle a logged-in user attempting to register again via the frontend registration flow. + * + * Integrations can override this method to update user data or perform other actions when an existing user attempts to register again via the frontend registration flow. For example, an integration might want to link the existing user account to the integration, record a new donation for a returning donor, or log this event for analytics purposes. + * + * The default implementation is a no-op. + * + * @param \WP_User $user The currently logged-in user attempting to register again. + * @param \WP_REST_Request $request The original registration request. + */ + public function handle_logged_in_user_registration( $user, $request ) { + // By default, do nothing. Integrations can override this to handle cases where a logged-in user attempts to register again via the frontend registration flow. + } + /** * Register data event handlers for this integration. * From ab57d81c405e0fc894693b5796301fa65f7b09ae Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 16 Apr 2026 14:39:27 -0700 Subject: [PATCH 45/45] fix: send nonce, if available, with registration request --- src/reader-activation/index.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/reader-activation/index.js b/src/reader-activation/index.js index 0b18a815a5..358e526087 100644 --- a/src/reader-activation/index.js +++ b/src/reader-activation/index.js @@ -13,7 +13,7 @@ import setupArticleViewsAggregates from './article-view.js'; import setupEngagement from './engagement.js'; import initSubscriptionTiersForm from './subscription-tiers-form.js'; import { openAuthModal as _openAuthModal } from '../reader-activation-auth/auth-modal.js'; -import { hydrateSession } from './session.js'; +import { getApiNonce, hydrateSession } from './session.js'; /** * Reader Activation Library. @@ -570,9 +570,14 @@ function register( email, integrationId, profileFields = {} ) { if ( token ) { body[ 'g-recaptcha-response' ] = token; } + const headers = { 'Content-Type': 'application/json' }; + const nonce = getApiNonce(); + if ( nonce ) { + headers[ 'X-WP-Nonce' ] = nonce; + } return fetch( newspack_ras_config.frontend_registration_url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, credentials: 'same-origin', body: JSON.stringify( body ), } );