From 078b3fea4779643efe500b0cf0d656d2ec95c596 Mon Sep 17 00:00:00 2001 From: Titus TC Date: Wed, 30 Sep 2026 23:19:07 +0300 Subject: [PATCH] feat(onboarding): ADR-011 consent + per-site usage-data setting; Connect step fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-011 (telemetry: one grant, given by the Terms, refused per site): - the consent step asks about usage data only on an UNCONNECTED FREE plugin, once per site; licensed/connected accounts are never asked (their Terms are the grant), whatever an older server block says - one-tier wording (telemetry v2); drop the paid 'basics already shared' line; heading says 'One optional thing' when only one is asked - new onboarding/site-telemetry.php: 'Don't send usage data from this site' — one site answer in the consent outbox, delivered like any answer (server stores it as the site's exclusion); shown in Tangible Home's 'This site' block, render_site_telemetry_setting() for plugins Connect: - step id 'connect' -> 'tangible_connect' (label 'tangible.one'): SearchSync's backend step is also 'connect', which fatals resolve_plan - pending (approved but unverified) is its own screen: says why (local dev site / unreachable REST API), 'Check again' re-confirms, Skip moves on without dropping the pending connection - app/API URLs default from TANGIBLE_CLOUD_URL when the Connect constants are unset, so a staging-pointed site no longer Connects to prod Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01JAdkRhF6yVmWWZBdUgV8Si --- connect/flow.php | 2 +- connect/index.php | 41 +++++++++++++--- connect/step.php | 54 +++++++++++++++++++-- hub/page.php | 4 ++ onboarding/index.php | 1 + onboarding/site-telemetry.php | 78 ++++++++++++++++++++++++++++++ onboarding/steps.php | 37 +++++++-------- tests/phpunit/connect.php | 89 ++++++++++++++++++++++++++++++++--- tests/phpunit/wizard.php | 6 ++- 9 files changed, 273 insertions(+), 39 deletions(-) create mode 100644 onboarding/site-telemetry.php diff --git a/connect/flow.php b/connect/flow.php index b80e2db..3c759b3 100644 --- a/connect/flow.php +++ b/connect/flow.php @@ -96,7 +96,7 @@ function confirm($context_slug = '') { /** Everything that should happen once a site is connected. */ function after_connected($plugin_name) { if ($plugin_name !== '' && function_exists('tangible\\onboarding\\mark')) { - \tangible\onboarding\mark($plugin_name, 'connect', 'done'); + \tangible\onboarding\mark($plugin_name, STEP_ID, 'done'); } // The update check is cached ~12h; refresh soon so the onboarding block // arrives — in cron, not in this request (which is already waiting on the diff --git a/connect/index.php b/connect/index.php index 6c59d8f..8720730 100644 --- a/connect/index.php +++ b/connect/index.php @@ -31,16 +31,45 @@ const HEADER = 'X-Tangible-Site-Token'; const FLOW_TTL = 15 * MINUTE_IN_SECONDS; -/** API host. Override per site with TANGIBLE_CLOUD_API in wp-config.php. */ +/** + * The origin of TANGIBLE_CLOUD_URL — the updater's switch, e.g. + * `https://api.staging.tangible.one/api/edd` → `https://api.staging.tangible.one`. + * A site pointed at staging for licences must not Connect to prod by default. + */ +function cloud_url_origin() { + return defined('TANGIBLE_CLOUD_URL') ? url_origin((string) TANGIBLE_CLOUD_URL) : ''; +} + +/** scheme://host of a URL, or '' when it has neither. */ +function url_origin($url) { + $parts = wp_parse_url($url); + if (empty($parts['scheme']) || empty($parts['host'])) return ''; + return $parts['scheme'] . '://' . $parts['host']; +} + +/** The app for an API origin: its `api.` label dropped. */ +function app_for_api_origin($origin) { + return preg_replace('#^(https?://)api\.#', '$1', $origin); +} + +/** + * API host. Override per site with TANGIBLE_CLOUD_API in wp-config.php; else + * the origin of TANGIBLE_CLOUD_URL; else production. + */ function api_base() { - $base = defined('TANGIBLE_CLOUD_API') ? TANGIBLE_CLOUD_API : 'https://api.tangible.one/'; - return rtrim((string) $base, '/'); + if (defined('TANGIBLE_CLOUD_API')) return rtrim((string) TANGIBLE_CLOUD_API, '/'); + return cloud_url_origin() ?: 'https://api.tangible.one'; } -/** The tangible.one app (consent screen). Override with TANGIBLE_CONNECT_APP_URL. */ +/** + * The tangible.one app (consent screen). Override with TANGIBLE_CONNECT_APP_URL; + * else TANGIBLE_CLOUD_URL's host with its `api.` label dropped + * (api.staging.tangible.one → staging.tangible.one); else production. + */ function app_base() { - $base = defined('TANGIBLE_CONNECT_APP_URL') ? TANGIBLE_CONNECT_APP_URL : 'https://tangible.one'; - return rtrim((string) $base, '/'); + if (defined('TANGIBLE_CONNECT_APP_URL')) return rtrim((string) TANGIBLE_CONNECT_APP_URL, '/'); + $origin = cloud_url_origin(); + return $origin ? app_for_api_origin($origin) : 'https://tangible.one'; } function get_state() { diff --git a/connect/step.php b/connect/step.php index 1b973d5..2ffbb26 100644 --- a/connect/step.php +++ b/connect/step.php @@ -11,12 +11,33 @@ * leaves wp-admin by top-level navigation. It returns false so nothing is * recorded yet; the return handler marks the step done once the site is * actually connected. + * + * Pending — approved on tangible.one, but the platform could not reach this + * site to verify it (a local dev site, a blocked REST API) — is its own state: + * the step says so plainly, offers "Check again", and Skip moves on without + * dropping the pending connection, which completes whenever verification does. + * + * The id is `tangible_connect`, not `connect`: plugins own the short names, and + * SearchSync's backend step is already called `connect` (two steps with one id + * is a fatal in resolve_plan). */ namespace tangible\connect; use tangible\framework; use tangible\onboarding; +const STEP_ID = 'tangible_connect'; + +/** Why a pending connection is still pending, in words. */ +function pending_reason() { + $host = strtolower((string) wp_parse_url(home_url(), PHP_URL_HOST)); + $local = function_exists('tangible\\onboarding\\is_dev_site') && onboarding\is_dev_site(); + if ($local) { + return sprintf('tangible.one has to reach %s to verify it, and a local development site is not reachable from the internet.', $host); + } + return sprintf('tangible.one could not reach %s/wp-json/ to verify it. Check that the site is public and its REST API is not blocked.', $host); +} + add_filter('tangible_onboarding_steps', function ($steps, $facts, $plugin_name = '') { if (!function_exists('tangible\\onboarding\\registered_wizards')) return $steps; if (empty(onboarding\registered_wizards()[$plugin_name])) return $steps; @@ -24,8 +45,8 @@ if (!onboarding\is_free_distribution($plugin)) return $steps; $steps[] = [ - 'id' => 'connect', - 'label' => 'connect', + 'id' => STEP_ID, + 'label' => 'tangible.one', 'weight' => 15, // after licence (10), before consent (20) 'scope' => 'site', 'skippable' => true, @@ -34,6 +55,23 @@ 'submit_label' => 'Connect to tangible.one', 'render' => function () { $pending = get_token() !== '' && !is_connected(); + if ($pending) : + $account = (string) (get_state()['account_name'] ?? ''); + ?> +

Approved — waiting for tangible.one to verify this site

+

You approved the connection' . esc_html($account) . '' : ''; ?>. One step is left: +

+
+ +

+

Connect this site to tangible.one

Optional. Connecting links this site — and every free Tangible plugin @@ -45,12 +83,18 @@

  • No licence key, and licensed plugins are not affected.
  • Disconnect any time from the Tangible Home page or your tangible.one account.
  • - -

    A connection is waiting for tangible.one to verify this site. Continuing starts a fresh one.

    - function ($plugin) use ($plugin_name) { if (!current_user_can('manage_options')) return false; + // "Check again" on a pending connection: ask the platform to verify. + if (!empty($_POST['connect_check']) && get_token() !== '' && !is_connected()) { + if (confirm($plugin_name) === 'active') { + after_connected($plugin_name); + return true; + } + return new \WP_Error('tangible_connect_pending', 'Still waiting: ' . pending_reason()); + } $return_to = function_exists('tangible\\onboarding\\get_setup_url') && $plugin ? onboarding\get_setup_url($plugin) : admin_url('admin.php?page=tangible-home'); onboarding\pending_redirect(start_flow(get_current_user_id(), $return_to, $plugin_name)); diff --git a/hub/page.php b/hub/page.php index 1b830dd..3ffb851 100644 --- a/hub/page.php +++ b/hub/page.php @@ -253,6 +253,10 @@ function render_site_block($steward, $connect) { 'client' ]); ?> +
    tangible.one
    diff --git a/onboarding/index.php b/onboarding/index.php index 6794122..a024eb9 100644 --- a/onboarding/index.php +++ b/onboarding/index.php @@ -284,3 +284,4 @@ function unmark($plugin_name, $step_id) { require_once __DIR__ . '/wizard.php'; require_once __DIR__ . '/steps.php'; +require_once __DIR__ . '/site-telemetry.php'; diff --git a/onboarding/site-telemetry.php b/onboarding/site-telemetry.php new file mode 100644 index 0000000..35dafc1 --- /dev/null +++ b/onboarding/site-telemetry.php @@ -0,0 +1,78 @@ + +

    + not sent from this site, by any Tangible plugin.' + : 'Tangible plugins on this site send usage data — performance timings, which features are used, content counts. Never content, users or visitors.'; ?> +

    +
    + + + + +
    + ask['telemetry_extended'] ?? null) === 'ask'; + // Telemetry (ADR-011 §4): only an UNCONNECTED FREE plugin is asked, once + // per site. A licensed or connected site's account accepted the Terms, which + // are the grant — whatever an older server's block says. Its refusal is the + // per-site setting (site-telemetry.php), not a wizard question. + $wizard_plugin = function_exists('tangible\\framework\\get_plugin') ? framework\get_plugin($plugin_name) : null; + $ask_telemetry = is_anonymous($wizard_plugin) + && ($facts->ask['telemetry_extended'] ?? null) === 'ask'; $ask_marketing = ($facts->ask['marketing'] ?? null) === 'ask'; $steps[] = [ @@ -434,11 +441,8 @@ function render_answer_pair($field, $question, $detail) { }, 'skip_note' => 'on file', 'render' => function ($plugin) use ($ask_telemetry, $ask_marketing) { - // The tier boundary, kept visibly straight: on a paid build, BASIC - // telemetry (WP/PHP/plugin versions, site URL) is already held as a - // term of purchase — checkout was the portal for it. This step asks - // for EXTENDED — usage data — so the payload shown is the extended - // payload: real values from this site, none of them version basics. + // The payload shown is real values from this site: the facts that + // would be sent, not a policy link. $roles = function_exists('wp_roles') ? count(wp_roles()->roles) : 0; $posts = wp_count_posts('post'); $pages = wp_count_posts('page'); $payload = [ @@ -447,18 +451,17 @@ function render_answer_pair($field, $question, $detail) { 'active plugins' => number_format_i18n(count((array) get_option('active_plugins', []))), 'locale' => get_locale(), ]; - $free = is_free_distribution($plugin); - $paid = !empty($plugin->cloud_id) && !$free; + $both = $ask_telemetry && $ask_marketing; ?> -

    Two optional things

    -

    No is a complete answer to both — the plugin works +

    +

    No is a complete answer — the plugin works exactly the same either way. We ask rather than assume, so you do have to answer.

    @@ -469,11 +472,7 @@ function render_answer_pair($field, $question, $detail) {
    - -

    Version and environment basics (WordPress, PHP, - plugin version) are already shared under your licence terms — this question is about - the rest.

    - assertSame('https://api.staging.tangible.one', $origin); + $this->assertSame('https://staging.tangible.one', connect\app_for_api_origin($origin)); + $this->assertSame('https://dev-site.tangible.one', connect\app_for_api_origin('https://dev-site.tangible.one')); + $this->assertSame('', connect\url_origin('not a url')); + } + function test_disconnect_revokes_server_side_then_clears_even_if_that_fails() { connect\save_state([ 'token' => self::TOKEN, 'status' => 'active' ]); $this->fake_platform([]); // every call fails @@ -230,23 +238,23 @@ private function ids() { function test_free_and_not_connected_shows_connect_before_consent() { $ids = $this->ids(); - $this->assertContains('connect', $ids); - $this->assertLessThan(array_search('consent', $ids, true), array_search('connect', $ids, true)); + $this->assertContains(connect\STEP_ID, $ids); + $this->assertLessThan(array_search('consent', $ids, true), array_search(connect\STEP_ID, $ids, true)); } function test_connected_or_licensed_hides_it() { connect\save_state([ 'token' => Connect_TestCase::TOKEN, 'status' => 'active' ]); - $this->assertNotContains('connect', $this->ids()); + $this->assertNotContains(connect\STEP_ID, $this->ids()); connect\clear(); update_option('freeconnect_distribution', 'licensed', false); - $this->assertNotContains('connect', $this->ids()); + $this->assertNotContains(connect\STEP_ID, $this->ids()); } function test_continue_starts_the_grant_and_records_nothing_yet() { wp_set_current_user(self::factory()->user->create([ 'role' => 'administrator' ])); - $this->assertNull(onboarding\handle_step_submission('freeconnect', [ 'step' => 'connect', 'do' => 'continue' ])); + $this->assertNull(onboarding\handle_step_submission('freeconnect', [ 'step' => connect\STEP_ID, 'do' => 'continue' ])); $this->assertStringStartsWith(connect\app_base() . '/connect/authorize?', onboarding\pending_redirect()); - $this->assertContains('connect', $this->ids()); + $this->assertContains(connect\STEP_ID, $this->ids()); // tangible.one is an allowed redirect host only inside the step submission. $this->assertSame('nope', wp_validate_redirect(onboarding\pending_redirect(), 'nope')); } @@ -266,6 +274,75 @@ function test_connected_consent_sync_sends_the_token_header_and_no_key() { $this->assertTrue(get_option(onboarding\CONSENT_OUTBOX)['telemetry_extended']['synced']); } + private function consent_needed() { + // Marketing answered elsewhere, so `needed` is the telemetry question alone. + $facts = onboarding\build_facts('freeconnect'); + $facts->ask['marketing'] = 'skip'; + foreach (onboarding\resolve_plan('freeconnect', $facts)['steps'] as $s) { + if ($s['id'] === 'consent') return ($s['needed'])($facts); + } + return false; + } + + function test_only_an_unconnected_free_site_is_asked_about_usage_data() { + // ADR-011 §4: licensed and connected accounts accepted the Terms. + $this->assertTrue($this->consent_needed()); + connect\save_state([ 'token' => Connect_TestCase::TOKEN, 'status' => 'active' ]); + $this->assertFalse($this->consent_needed()); + connect\clear(); + update_option('freeconnect_distribution', 'licensed', false); + $this->assertFalse($this->consent_needed()); + } + + function test_the_site_setting_is_one_answer_for_the_site() { + add_filter('pre_http_request', function () { return new \WP_Error('offline', 'no network in tests'); }); + onboarding\set_site_telemetry(false); + $this->assertTrue(onboarding\site_telemetry_refused()); + $entry = get_option(onboarding\CONSENT_OUTBOX)['telemetry_extended']; + $this->assertSame('declined', $entry['answer']); + $this->assertSame(onboarding\SITE_TELEMETRY_OFF_TEXT, $entry['consent_text']); + // Answered, so the wizard never asks it again. + $this->assertFalse($this->consent_needed()); + onboarding\set_site_telemetry(true); + $this->assertFalse(onboarding\site_telemetry_refused()); + } + + function test_coexists_with_a_plugin_step_named_connect() { + // SearchSync's backend step is `connect`; the framework's must not collide. + add_filter('tangible_onboarding_steps', function ($steps, $facts, $name = '') { + if ($name === 'freeconnect') $steps[] = [ 'id' => 'connect', 'label' => 'Backend', 'weight' => 50 ]; + return $steps; + }, 10, 3); + $ids = $this->ids(); + $this->assertContains('connect', $ids); + $this->assertContains(connect\STEP_ID, $ids); + } + + function test_pending_shows_why_and_check_again_stays_open_until_verified() { + wp_set_current_user(self::factory()->user->create([ 'role' => 'administrator' ])); + connect\save_state([ 'token' => Connect_TestCase::TOKEN, 'status' => 'pending', 'account_name' => 'Acme' ]); + $step = null; + foreach (onboarding\resolve_plan('freeconnect', onboarding\build_facts('freeconnect'))['steps'] as $s) { + if ($s['id'] === connect\STEP_ID) $step = $s; + } + ob_start(); ($step['render'])($this->plugin); $html = ob_get_clean(); + $this->assertStringContainsString('waiting for tangible.one to verify', $html); + $this->assertStringContainsString('Check again', $html); + + add_filter('pre_http_request', function () { + return [ 'response' => [ 'code' => 200 ], 'body' => wp_json_encode([ 'status' => 'pending' ]) ]; + }); + $_POST['connect_check'] = '1'; + $this->assertInstanceOf(\WP_Error::class, ($step['handle'])($this->plugin)); + remove_all_filters('pre_http_request'); + add_filter('pre_http_request', function () { + return [ 'response' => [ 'code' => 200 ], 'body' => wp_json_encode([ 'status' => 'active' ]) ]; + }); + $this->assertTrue(($step['handle'])($this->plugin)); + $this->assertTrue(connect\is_connected()); + unset($_POST['connect_check']); + } + function test_a_revoked_token_on_consent_sync_falls_back_to_anonymous() { onboarding\record_consent_answer('telemetry_extended', 'granted', 'wording v1'); connect\save_state([ 'token' => Connect_TestCase::TOKEN, 'status' => 'active' ]); diff --git a/tests/phpunit/wizard.php b/tests/phpunit/wizard.php index 7893fe4..403a920 100644 --- a/tests/phpunit/wizard.php +++ b/tests/phpunit/wizard.php @@ -188,11 +188,13 @@ function test_consent_records_both_and_silences_the_ask() { $this->assertSame('consent', $recorded); $outbox = get_option(onboarding\CONSENT_OUTBOX); - $this->assertSame('granted', $outbox['telemetry_extended']['answer']); + // A licensed build is never asked about usage data (ADR-011 §4): the + // posted telemetry answer is ignored, marketing is recorded. + $this->assertArrayNotHasKey('telemetry_extended', $outbox); $this->assertSame('declined', $outbox['marketing']['answer']); // The exact wording shown rides with the answer — provable later. $this->assertNotEmpty($outbox['marketing']['consent_text']); - $this->assertFalse($outbox['telemetry_extended']['synced']); + $this->assertFalse($outbox['marketing']['synced']); // A declined marketing ask is an ANSWER: the wizard never re-asks it. $plan = onboarding\resolve_plan('coretest', onboarding\build_facts('coretest'));