prefix . 'wpdo_user_' . $group; $exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); $rows = $exists // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared ? (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" ) : -1; $items[] = array( 'group' => $group, 'table' => $table, 'exists' => $exists ? 'yes' : 'NO', 'rows' => $exists ? $rows : '(table missing)', ); } WP_CLI\Utils\format_items( 'table', $items, array( 'group', 'table', 'exists', 'rows' ) ); WP_CLI::log( '' ); // Shadow diff rate. $shadow_table = $wpdb->prefix . 'wpdo_shadow_diffs'; $shadow_exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( 'SHOW TABLES LIKE %s', $shadow_table ) ); if ( $shadow_exists ) { $total = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching "SELECT COUNT(*) FROM `{$shadow_table}` WHERE entity_type = 'user'" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared ); WP_CLI::log( "Shadow diffs (entity_type=user): {$total}" ); } else { WP_CLI::log( 'Shadow diffs table: not present.' ); } } // ── member-backfill ─────────────────────────────────────────────────────── /** * Backfill wp_usermeta rows into the specified user flat-table group. * * Uses TMDO_Entity_Migration_Engine::migrate_group() — cursor-based, * 500 rows/batch, checkpoint stored in wpdo_migration_status. * * ## OPTIONS * * [--group=] * : Which group to backfill: membership, activity, profile, sso. Default: membership. * * [--batch-size=] * : Rows per batch (default 500, max 2000). * * [--dry-run] * : Preview without writing. * * [--reset] * : Clear checkpoint and restart from the beginning. * * ## EXAMPLES * * wp wpdo member-backfill --group=membership --dry-run * wp wpdo member-backfill --group=membership * wp wpdo member-backfill --group=sso --reset * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function member_backfill( $args, $assoc_args ): void { $group = (string) ( $assoc_args['group'] ?? 'membership' ); $batch_size = min( 2000, max( 1, (int) ( $assoc_args['batch-size'] ?? 500 ) ) ); $dry_run = isset( $assoc_args['dry-run'] ); $reset = isset( $assoc_args['reset'] ); if ( ! in_array( $group, self::VALID_GROUPS, true ) ) { WP_CLI::error( 'Invalid --group. Choose: ' . implode( ', ', self::VALID_GROUPS ) ); } if ( $reset ) { TMDO_Entity_Migration_Engine::reset_checkpoint( 'user', $group ); WP_CLI::log( "Checkpoint cleared for user/{$group}." ); } WP_CLI::log( $dry_run ? "[dry-run] Backfill user/{$group} ..." : "Backfill user/{$group} ..." ); $result = TMDO_Entity_Migration_Engine::migrate_group( 'user', $group, array( 'batch_size' => $batch_size, 'dry_run' => $dry_run, 'resume' => ! $reset, ) ); // Pre-flight failure (adapter/group/keys/table missing). if ( ! empty( $result['error'] ) ) { WP_CLI::error( $result['error'] ); } $migrated = (int) ( $result['migrated'] ?? 0 ); $errors = (int) ( $result['errors'] ?? 0 ); $skipped = (int) ( $result['skipped'] ?? 0 ); // Row-level failures: migrate_group() catches Throwables per-entity, increments // $stats['errors'], and continues. The returned $stats has no 'error' key, // so without this branch the CLI would silently report success. if ( $errors > 0 && 0 === $migrated ) { WP_CLI::error( sprintf( 'All %d row(s) failed during migration — see error_log for details.', $errors ) ); } if ( $errors > 0 ) { WP_CLI::warning( sprintf( '%d row(s) failed during migration — see error_log for details.', $errors ) ); } WP_CLI::success( sprintf( 'Migrated %d rows, %d errors, %d skipped (%.2f sec)%s.', $migrated, $errors, $skipped, $result['elapsed_sec'] ?? 0, $dry_run ? ' [dry-run, no changes written]' : '' ) ); } // ── member-shadow-report ────────────────────────────────────────────────── /** * Display recent shadow_diffs rows for entity_type=user. * * ## OPTIONS * * [--limit=] * : Max rows to display (default 20). * * [--format=] * : Output format: table, json, csv. Default: table. * * ## EXAMPLES * * wp wpdo member-shadow-report * wp wpdo member-shadow-report --limit=50 --format=json * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function member_shadow_report( $args, $assoc_args ): void { global $wpdb; $limit = min( 500, max( 1, (int) ( $assoc_args['limit'] ?? 20 ) ) ); $format = (string) ( $assoc_args['format'] ?? 'table' ); $table = $wpdb->prefix . 'wpdo_shadow_diffs'; $exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); if ( ! $exists ) { WP_CLI::warning( 'wpdo_shadow_diffs table not present — no shadow diffs collected yet.' ); return; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $rows = $wpdb->get_results( $wpdb->prepare( "SELECT id, entity_id, meta_key, postmeta_value, zone_value, diff_hash, ts FROM `{$table}` WHERE entity_type = 'user' ORDER BY ts DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $limit ), ARRAY_A ); if ( empty( $rows ) ) { WP_CLI::success( 'No shadow diffs found for entity_type=user.' ); return; } WP_CLI::log( count( $rows ) . " diff(s) found (limit {$limit}):" ); WP_CLI\Utils\format_items( $format, $rows, array( 'id', 'entity_id', 'meta_key', 'diff_hash', 'ts' ) ); } // ── member-cutover ──────────────────────────────────────────────────────── /** * Switch the user bridge mode to aeav_only (reads and writes go to flat table only). * * Requires --confirm to prevent accidental invocation. * Recommended only after shadow diff rate drops below 0.1%. * * ## OPTIONS * * [--confirm] * : Required confirmation flag. * * ## EXAMPLES * * wp wpdo member-cutover --confirm * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function member_cutover( $args, $assoc_args ): void { if ( ! isset( $assoc_args['confirm'] ) ) { WP_CLI::error( 'You must pass --confirm to cut over. Check shadow diff rate first: wp wpdo member-shadow-report' ); } $current = TMDO_Mode_Manager::get( 'user' ); WP_CLI::log( "Current user mode: {$current}" ); if ( 'aeav_only' === $current ) { WP_CLI::success( 'User mode is already aeav_only. Nothing to do.' ); return; } $result = TMDO_Mode_Manager::set( 'user', 'aeav_only' ); if ( isset( $result['error'] ) ) { WP_CLI::error( 'Cutover failed: ' . $result['error'] ); } WP_CLI::success( 'User bridge mode set to aeav_only. All user meta reads/writes now go through flat tables.' ); } // ── member-points-check ─────────────────────────────────────────────────── /** * Show points balance and recent ledger entries for a user. * * ## OPTIONS * * * : WordPress user ID. * * [--limit=] * : Ledger rows to show (default 10). * * ## EXAMPLES * * wp wpdo member-points-check 42 * wp wpdo member-points-check 42 --limit=20 * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function member_points_check( $args, $assoc_args ): void { $user_id = (int) ( $args[0] ?? 0 ); if ( $user_id <= 0 ) { WP_CLI::error( 'Usage: wp wpdo member-points-check ' ); } $limit = min( 100, max( 1, (int) ( $assoc_args['limit'] ?? 10 ) ) ); $balance = TMDO_Points_Manager::get_balance( $user_id ); WP_CLI::log( "User {$user_id} — points balance: {$balance}" ); WP_CLI::log( '' ); $ledger = TMDO_Points_Manager::get_ledger( $user_id, $limit ); if ( empty( $ledger ) ) { WP_CLI::log( 'No ledger entries found.' ); return; } WP_CLI\Utils\format_items( 'table', $ledger, array( 'id', 'delta', 'balance_after', 'reason', 'ref_type', 'ref_id', 'created_at' ) ); } // ── member-sso-sync ─────────────────────────────────────────────────────── /** * Sync Hub membership claims into the user flat table. * * Reads _tmso_refresh_token / _tmso_picture_url / _tmso_last_id_token * from usermeta and writes them into the sso group flat table. * Intended as a one-shot sync until 2meet-spoke-sso v1.14+ starts writing * directly to the sso group via the Hook Bus. * * ## OPTIONS * * [--user-id=] * : Single user ID to sync. * * [--all] * : Sync all users that have _tmso_refresh_token in usermeta (batch 500). * * [--dry-run] * : Preview without writing. * * ## EXAMPLES * * wp wpdo member-sso-sync --user-id=42 * wp wpdo member-sso-sync --all --dry-run * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function member_sso_sync( $args, $assoc_args ): void { global $wpdb; $user_id = isset( $assoc_args['user-id'] ) ? (int) $assoc_args['user-id'] : 0; $all = isset( $assoc_args['all'] ); $dry_run = isset( $assoc_args['dry-run'] ); if ( ! $user_id && ! $all ) { WP_CLI::error( 'Provide --user-id= or --all.' ); } $sso_table = $wpdb->prefix . 'wpdo_user_sso'; $table_exists = (bool) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $wpdb->prepare( 'SHOW TABLES LIKE %s', $sso_table ) ); if ( ! $table_exists ) { WP_CLI::error( "SSO flat table {$sso_table} does not exist. Run `wp wpdo doctor` first." ); } $user_ids = array(); if ( $user_id ) { $user_ids = array( $user_id ); } else { // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $user_ids = $wpdb->get_col( "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = '_tmso_refresh_token' LIMIT 5000" // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared ); } if ( empty( $user_ids ) ) { WP_CLI::log( 'No users with _tmso_refresh_token found.' ); return; } $synced = 0; $skipped = 0; $sso_keys = array( 'hub_global_user_id' => '_tmso_global_user_id', 'picture_url' => '_tmso_picture_url', 'refresh_token_enc' => '_tmso_refresh_token', ); foreach ( $user_ids as $uid ) { $uid = (int) $uid; $data = array( 'user_id' => $uid ); foreach ( $sso_keys as $flat_col => $meta_key ) { $val = get_user_meta( $uid, $meta_key, true ); if ( '' !== $val ) { // Reject plaintext refresh tokens — must carry the enc_vN: envelope // written by TMSO_Crypto::encrypt(). Storing cleartext here would // silently bypass the encryption layer. if ( 'refresh_token_enc' === $flat_col && ! preg_match( '/^enc_v\d+:/', (string) $val ) ) { TMDO_Logger::error( 'sso_sync', 'plaintext_token_rejected', "user_id={$uid} meta_key={$meta_key}" ); continue; } $data[ $flat_col ] = $val; } } if ( count( $data ) <= 1 ) { ++$skipped; continue; } if ( ! $dry_run ) { $wpdb->replace( $sso_table, $data ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching } ++$synced; } WP_CLI::success( sprintf( 'SSO sync complete — synced: %d, skipped (no meta): %d%s.', $synced, $skipped, $dry_run ? ' [dry-run]' : '' ) ); } // ── member-force-logout ─────────────────────────────────────────────────── /** * Force re-authentication by setting token_expires_at to a past datetime. * * The Spoke silent-refresh logic detects an expired token_expires_at and * redirects the user to re-authenticate against the Hub. If the Hub has * the account blocked, the user is logged out from all Spokes. * * ## OPTIONS * * [--user-id=] * : WordPress local user ID to force-logout. * * [--global-user-id=] * : Hub global_user_id to force-logout (looks up via hub_global_user_id column). * * [--confirm] * : Required safety flag — confirms intent to invalidate live SSO tokens. * * ## EXAMPLES * * wp wpdo member-force-logout --user-id=42 --confirm * wp wpdo member-force-logout --global-user-id=abc-123 --confirm * * @param array $args Positional arguments. * @param array $assoc_args Named arguments. */ public function member_force_logout( $args, $assoc_args ): void { if ( ! isset( $assoc_args['confirm'] ) ) { WP_CLI::error( 'This invalidates live SSO tokens. Add --confirm to proceed.' ); } global $wpdb; $sso_table = $wpdb->prefix . 'wpdo_user_sso'; $past = '2000-01-01 00:00:00'; if ( isset( $assoc_args['user-id'] ) ) { $uid = (int) $assoc_args['user-id']; // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $updated = $wpdb->query( $wpdb->prepare( "UPDATE `{$sso_table}` SET token_expires_at = %s WHERE user_id = %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $past, $uid ) ); if ( $updated ) { TMDO_Logger::info( 'member_force_logout', array( 'user_id' => $uid, 'via' => 'user-id', ) ); WP_CLI::success( "Force-logout applied to user_id={$uid}. Token invalidated." ); } else { WP_CLI::warning( "No SSO row found for user_id={$uid}." ); } return; } if ( isset( $assoc_args['global-user-id'] ) ) { $global_id = sanitize_text_field( (string) $assoc_args['global-user-id'] ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery,WordPress.DB.DirectDatabaseQuery.NoCaching $updated = $wpdb->query( $wpdb->prepare( "UPDATE `{$sso_table}` SET token_expires_at = %s WHERE hub_global_user_id = %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared $past, $global_id ) ); if ( $updated ) { TMDO_Logger::info( 'member_force_logout', array( 'global_user_id' => $global_id, 'affected_rows' => $updated, 'via' => 'global-user-id', ) ); WP_CLI::success( "Force-logout applied to global_user_id={$global_id}. Affected rows: {$updated}." ); } else { WP_CLI::warning( "No SSO row found for global_user_id={$global_id}." ); } return; } WP_CLI::error( 'Provide --user-id= or --global-user-id=.' ); } } // ── Register subcommands ────────────────────────────────────────────────────── WP_CLI::add_command( 'wpdo member-audit', array( 'TMDO_CLI_Member', 'member_audit' ) ); WP_CLI::add_command( 'tmdo member-audit', array( 'TMDO_CLI_Member', 'member_audit' ) ); WP_CLI::add_command( 'wpdo member-backfill', array( 'TMDO_CLI_Member', 'member_backfill' ) ); WP_CLI::add_command( 'tmdo member-backfill', array( 'TMDO_CLI_Member', 'member_backfill' ) ); WP_CLI::add_command( 'wpdo member-shadow-report', array( 'TMDO_CLI_Member', 'member_shadow_report' ) ); WP_CLI::add_command( 'tmdo member-shadow-report', array( 'TMDO_CLI_Member', 'member_shadow_report' ) ); WP_CLI::add_command( 'wpdo member-cutover', array( 'TMDO_CLI_Member', 'member_cutover' ) ); WP_CLI::add_command( 'tmdo member-cutover', array( 'TMDO_CLI_Member', 'member_cutover' ) ); WP_CLI::add_command( 'wpdo member-points-check', array( 'TMDO_CLI_Member', 'member_points_check' ) ); WP_CLI::add_command( 'tmdo member-points-check', array( 'TMDO_CLI_Member', 'member_points_check' ) ); WP_CLI::add_command( 'wpdo member-sso-sync', array( 'TMDO_CLI_Member', 'member_sso_sync' ) ); WP_CLI::add_command( 'tmdo member-sso-sync', array( 'TMDO_CLI_Member', 'member_sso_sync' ) ); WP_CLI::add_command( 'wpdo member-force-logout', array( 'TMDO_CLI_Member', 'member_force_logout' ) ); WP_CLI::add_command( 'tmdo member-force-logout', array( 'TMDO_CLI_Member', 'member_force_logout' ) );