diff --git a/admin/class-tmdo-deprecated-addons-notice.php b/admin/class-tmdo-deprecated-addons-notice.php
index a49f72b..ecc665a 100644
--- a/admin/class-tmdo-deprecated-addons-notice.php
+++ b/admin/class-tmdo-deprecated-addons-notice.php
@@ -6,7 +6,8 @@
* mobile-bridge, playlist, quotation, spoke) only registered tables and fields
* on the partner's behalf. Partner plugins now register themselves with the
* TMDO_Anti_EAV_Aware trait (docs/PARTNER-REGISTRATION.md), so these AddOns
- * are deprecated and will be removed.
+ * are deprecated and will be removed. The hub and spoke AddOns are replaced
+ * by 2meet-data-optimizer-sso-addon.
*
* @package WP_Data_Optimizer
*/
@@ -79,5 +80,11 @@ final class TMDO_Deprecated_Addons_Notice {
),
esc_html__( '改寫方式見 2meet-data-optimizer 外掛內的 docs/PARTNER-REGISTRATION.md;partner 外掛完成自行註冊後即可停用對應 AddOn。', '2meet-data-optimizer' )
);
+ if ( array_intersect( $active, array( 'hub', 'spoke' ) ) ) {
+ printf(
+ '
',
+ esc_html__( 'Hub / Spoke AddOn 由 2meet-data-optimizer-sso-addon 取代:啟用它之後即可停用這兩個 AddOn。', '2meet-data-optimizer' )
+ );
+ }
}
}
diff --git a/includes/class-tmdo-compatibility.php b/includes/class-tmdo-compatibility.php
index c8891eb..cda622f 100644
--- a/includes/class-tmdo-compatibility.php
+++ b/includes/class-tmdo-compatibility.php
@@ -85,6 +85,10 @@ class TMDO_Compatibility {
if ( self::is_woocommerce_active() && ! class_exists( 'TMDO_Woocommerce_Bootstrap' ) ) {
$missing['WooCommerce'] = '2meet-data-optimizer-woocommerce-addon';
}
+ // 2meet-hub-core / 2meet-spoke-sso read and write the user `sso` group it owns.
+ if ( ( class_exists( 'TMHC_Plugin', false ) || class_exists( 'TMSO_Plugin', false ) ) && ! class_exists( 'TMDO_SSO_Integration', false ) ) {
+ $missing['2meet Hub / Spoke SSO'] = '2meet-data-optimizer-sso-addon';
+ }
return $missing;
}
diff --git a/includes/integrations/class-tmdo-member-fields.php b/includes/integrations/class-tmdo-member-fields.php
index 776d9cd..5cd847e 100644
--- a/includes/integrations/class-tmdo-member-fields.php
+++ b/includes/integrations/class-tmdo-member-fields.php
@@ -15,13 +15,13 @@ if ( ! defined( 'ABSPATH' ) ) {
}
/**
- * Registers four user entity groups for membership, activity, profile, and SSO.
+ * Registers the user entity groups for membership, activity and profile.
*
* Groups → flat tables:
* membership → wp_wpdo_user_membership (tier, points, expiry — high-freq search)
* activity → wp_wpdo_user_activity (login counters, last-active — high-freq write)
* profile → wp_wpdo_user_profile (display fields, specialties — low-freq write)
- * sso → wp_wpdo_user_sso (Hub token cache, replaces _tmso_* usermeta)
+ * (sso → wp_wpdo_user_sso is owned by 2meet-data-optimizer-sso-addon)
*
* Called from TMDO_Core::run() before wpdo_register_entity_fields fires.
* Pattern mirrors TMDO_WooCommerce::register() / register_user_entity_fields().
@@ -61,7 +61,6 @@ final class TMDO_Member_Fields {
self::register_membership_group();
self::register_activity_group();
self::register_profile_group();
- self::register_sso_group();
self::register_core_profile_group();
self::register_social_group();
self::register_commerce_group();
@@ -213,70 +212,6 @@ final class TMDO_Member_Fields {
);
}
- /**
- * Hub/Spoke SSO token cache — replaces _tmso_* usermeta.
- *
- * Silent refresh fires every 15 min per user; at 10M users this is a
- * high-frequency EAV hot-spot. A flat table + object cache hit cuts DB
- * load 10–50× versus a wp_usermeta EAV scan per refresh.
- *
- * last_id_token is NOT stored in plaintext (privacy + volume). Only the
- * SHA-256 hash is kept for SLO token comparison.
- *
- * @return void
- */
- private static function register_sso_group(): void {
- TMDO_Entity_Registry::register_group(
- 'user',
- 'sso',
- array(
- array(
- 'key' => 'hub_global_user_id',
- 'type' => 'text',
- 'searchable' => true,
- 'label' => 'Hub global user UUID (2mso_user_mapping.global_user_id bridge key)',
- ),
- array(
- 'key' => 'picture_url',
- 'type' => 'text',
- 'label' => 'Social / SSO profile picture URL',
- ),
- array(
- 'key' => 'last_id_token_hash',
- 'type' => 'text',
- 'label' => 'SHA-256(last_id_token) for SLO comparison — no plaintext stored',
- ),
- array(
- 'key' => 'refresh_token_enc',
- 'type' => 'textarea',
- 'label' => 'Encrypted refresh token (TMSO_Crypto — key-versioned enc_vN:ciphertext)',
- ),
- array(
- 'key' => 'token_expires_at',
- 'type' => 'datetime',
- 'searchable' => true,
- 'label' => 'SSO token expiry (set to past to force re-auth on next request)',
- ),
- array(
- 'key' => 'sso_last_login_at',
- 'type' => 'datetime',
- 'label' => 'Last SSO-initiated login datetime',
- ),
- array(
- 'key' => 'sso_login_count',
- 'type' => 'integer',
- 'default' => 0,
- 'label' => 'SSO login count',
- ),
- array(
- 'key' => 'known_login_ips',
- 'type' => 'textarea',
- 'label' => 'Recent login IPs JSON array (new-device notification) — replaces _tmso_known_ips usermeta',
- ),
- )
- );
- }
-
/**
* WP core user fields stored as multi-row EAV in wp_usermeta.
*
diff --git a/tests/unit/DeprecatedAddonsNoticeTest.php b/tests/unit/DeprecatedAddonsNoticeTest.php
index fe2e5fe..c858e90 100644
--- a/tests/unit/DeprecatedAddonsNoticeTest.php
+++ b/tests/unit/DeprecatedAddonsNoticeTest.php
@@ -43,6 +43,17 @@ class DeprecatedAddonsNoticeTest extends TestCase {
$this->assertStringContainsString( 'PARTNER-REGISTRATION.md', $html );
}
+ public function test_hub_and_spoke_point_to_the_sso_addon(): void {
+ $GLOBALS['_wp_options']['active_plugins'] = array( '2meet-data-optimizer-spoke-addon/2meet-data-optimizer-spoke-addon.php' );
+
+ ob_start();
+ TMDO_Deprecated_Addons_Notice::render();
+ $html = (string) ob_get_clean();
+
+ $this->assertStringContainsString( '2meet-data-optimizer-spoke-addon', $html );
+ $this->assertStringContainsString( '2meet-data-optimizer-sso-addon', $html );
+ }
+
public function test_no_notice_when_none_active_or_user_cannot_manage(): void {
ob_start();
TMDO_Deprecated_Addons_Notice::render();
diff --git a/tests/unit/MemberFields/MemberFieldsRegistrationTest.php b/tests/unit/MemberFields/MemberFieldsRegistrationTest.php
index f3dcaea..0e871fa 100644
--- a/tests/unit/MemberFields/MemberFieldsRegistrationTest.php
+++ b/tests/unit/MemberFields/MemberFieldsRegistrationTest.php
@@ -39,12 +39,18 @@ class MemberFieldsRegistrationTest extends TestCase {
public function test_all_four_groups_are_registered(): void {
WPDO_Member_Fields::register_entity_fields();
- foreach ( array( 'membership', 'activity', 'profile', 'sso' ) as $group ) {
+ foreach ( array( 'membership', 'activity', 'profile' ) as $group ) {
$fields = WPDO_Entity_Registry::get_group_fields( 'user', $group );
$this->assertNotEmpty( $fields, "Group '{$group}' should have registered fields." );
}
}
+ public function test_core_leaves_the_sso_group_to_the_sso_addon(): void {
+ WPDO_Member_Fields::register_entity_fields();
+
+ $this->assertSame( array(), WPDO_Entity_Registry::get_group_fields( 'user', 'sso' ) );
+ }
+
public function test_core_registers_no_hp_user_group(): void {
WPDO_Member_Fields::register_entity_fields();
@@ -148,41 +154,6 @@ class MemberFieldsRegistrationTest extends TestCase {
$this->assertTrue( (bool) $field['fulltext'] );
}
- // ── sso group ────────────────────────────────────────────────────────────
-
- public function test_sso_group_has_eight_fields(): void {
- WPDO_Member_Fields::register_entity_fields();
- $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
- // v3.4.4: known_login_ips registered (was leaking to wp_usermeta via set_entity).
- $this->assertCount( 8, $fields );
- $this->assertNotNull( $this->find_field( $fields, 'known_login_ips' ) );
- }
-
- public function test_hub_global_user_id_is_searchable(): void {
- WPDO_Member_Fields::register_entity_fields();
- $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
- $field = $this->find_field( $fields, 'hub_global_user_id' );
-
- $this->assertTrue( (bool) $field['searchable'] );
- }
-
- public function test_token_expires_at_is_datetime_searchable(): void {
- WPDO_Member_Fields::register_entity_fields();
- $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
- $field = $this->find_field( $fields, 'token_expires_at' );
-
- $this->assertSame( 'datetime', $field['type'] );
- $this->assertTrue( (bool) $field['searchable'] );
- }
-
- public function test_refresh_token_enc_is_textarea(): void {
- WPDO_Member_Fields::register_entity_fields();
- $fields = WPDO_Entity_Registry::get_group_fields( 'user', 'sso' );
- $field = $this->find_field( $fields, 'refresh_token_enc' );
-
- $this->assertSame( 'textarea', $field['type'] );
- }
-
// ── register() hooks add_action ──────────────────────────────────────────
public function test_register_hooks_wpdo_register_entity_fields(): void {
diff --git a/tests/unit/MissingIntegrationAddonsTest.php b/tests/unit/MissingIntegrationAddonsTest.php
index 9237221..ae93ead 100644
--- a/tests/unit/MissingIntegrationAddonsTest.php
+++ b/tests/unit/MissingIntegrationAddonsTest.php
@@ -79,6 +79,27 @@ class MissingIntegrationAddonsTest extends TestCase {
$this->assertSame( array( 'WooCommerce' => '2meet-data-optimizer-woocommerce-addon' ), TMDO_Compatibility::missing_integration_addons() );
}
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_hub_or_spoke_without_the_sso_addon_is_reported(): void {
+ eval( 'final class TMSO_Plugin {}' );
+
+ $this->assertSame( array( '2meet Hub / Spoke SSO' => '2meet-data-optimizer-sso-addon' ), TMDO_Compatibility::missing_integration_addons() );
+ }
+
+ /**
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_hub_with_the_sso_addon_is_fine(): void {
+ eval( 'final class TMHC_Plugin {}' );
+ eval( 'final class TMDO_SSO_Integration {}' );
+
+ $this->assertSame( array(), TMDO_Compatibility::missing_integration_addons() );
+ }
+
/**
* @runInSeparateProcess
* @preserveGlobalState disabled