read = new ResourceCapacityRepository(); } public function present(): bool { // Fresh reader: never cache an absent/verified store across a caller's mutations. if ( ! ( new CalendarBusyRepository() )->present() ) { return false; } global $wpdb; $expected = array( 'calendar_connections' => array( 'id', 'provider', 'owner_user_id', 'staff_id', 'authority', 'timezone', 'calendars', 'credential', 'enabled', 'consent_revision', 'revision', 'sync_revision', 'coverage_start', 'coverage_end', 'last_attempt', 'last_success', 'next_attempt', 'error_code', 'created_at' ), 'calendar_busy' => array( 'id', 'connection_id', 'calendar_digest', 'start_utc', 'end_utc', 'sync_revision' ), 'calendar_oauth_states' => array( 'token_hash', 'user_id', 'staff_id', 'provider', 'purpose', 'state_payload', 'expires_at' ) ); foreach ( $expected as $table => $names ) { $actual = array(); foreach ( Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $this->read->table( $table ) ), ARRAY_A ), 'retained_capacity_storage_unavailable' ) as $column ) { $actual[] = $column['Field']; if ( $column['Field'] === 'id' && $column['Extra'] !== 'auto_increment' ) { throw new \RuntimeException( 'calendar_retained_shape' ); } } sort( $names ); sort( $actual ); if ( $names !== $actual ) { throw new \RuntimeException( 'calendar_retained_shape' ); } $primary = array(); foreach ( Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SHOW INDEX FROM %i', $this->read->table( $table ) ), ARRAY_A ), 'retained_capacity_storage_unavailable' ) as $index ) { if ( $index['Key_name'] === 'PRIMARY' ) { $primary[ (int) $index['Seq_in_index'] ] = $index['Column_name']; } } if ( $primary !== array( 1 => $table === 'calendar_oauth_states' ? 'token_hash' : 'id' ) ) { throw new \RuntimeException( 'calendar_retained_primary' ); } } foreach ( array( $this->read->table( 'activity_log' ), $wpdb->options ) as $table ) { $engine = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=%s', $table ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); if ( strtoupper( (string) ( $engine[0]['ENGINE'] ?? '' ) ) !== 'INNODB' ) { throw new \RuntimeException( 'calendar_retained_audit_engine' ); } } $unique = array(); foreach ( Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SHOW INDEX FROM %i', $wpdb->options ), ARRAY_A ), 'retained_capacity_storage_unavailable' ) as $index ) { if ( (int) $index['Non_unique'] === 0 ) { $unique[ $index['Key_name'] ][ (int) $index['Seq_in_index'] ] = $index['Column_name']; } } $found = false; foreach ( $unique as $key ) { ksort( $key ); if ( array_values( $key ) === array( 'option_name' ) ) { $found = true; } } if ( ! $found ) { throw new \RuntimeException( 'calendar_retained_epoch_index' ); } return true; } private static function number( $value, bool $positive = false ): int { if ( ( ! is_int( $value ) && ! is_string( $value ) ) || ! preg_match( '/^(0|[1-9][0-9]*)$/D', (string) $value ) ) { throw new \DomainException( 'calendar_retained_input' ); } $parsed = filter_var( $value, FILTER_VALIDATE_INT, array( 'options' => array( 'min_range' => $positive ? 1 : 0, 'max_range' => PHP_INT_MAX - 1 ) ) ); if ( $parsed === false ) { throw new \DomainException( 'calendar_retained_input' ); } return $parsed; } private static function state( array $row ): array { if ( ! in_array( $row['provider'], array( 'google', 'outlook' ), true ) || ! in_array( $row['authority'], array( 'manager', 'own_staff', 'revoked' ), true ) || ! in_array( (string) $row['enabled'], array( '0', '1' ), true ) || ! preg_match( '/^[a-z_]{0,32}$/D', $row['error_code'] ) ) { throw new \RuntimeException( 'calendar_retained_state' ); } foreach ( array( 'id', 'staff_id', 'consent_revision', 'revision' ) as $key ) { self::number( $row[ $key ], true ); } self::number( $row['sync_revision'] ); new \DateTimeZone( $row['timezone'] ); foreach ( array( 'coverage_start', 'coverage_end', 'last_attempt', 'last_success', 'next_attempt' ) as $key ) { if ( $row[ $key ] !== null ) { $date = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i:s', $row[ $key ], new \DateTimeZone( 'UTC' ) ); if ( ! $date || $date->format( 'Y-m-d H:i:s' ) !== $row[ $key ] ) { throw new \RuntimeException( 'calendar_retained_time' ); } } } if ( ( $row['coverage_start'] === null ) !== ( $row['coverage_end'] === null ) || ( $row['coverage_start'] !== null && $row['coverage_start'] >= $row['coverage_end'] ) || ( (int) $row['enabled'] === 1 && ( $row['authority'] === 'revoked' || (int) $row['consent_revision'] !== 1 ) ) ) { throw new \RuntimeException( 'calendar_retained_state' ); } return $row; } /** 25 visible rows, plus one continuation sentinel. No secrets/calendar IDs selected. */ public function listing( int $staff, int $page = 1 ): array { self::number( $staff, true ); self::number( $page, true ); if ( $page > intdiv( PHP_INT_MAX, 25 ) ) { throw new \DomainException( 'calendar_retained_page' ); } if ( ! $this->present() ) { return array(); } global $wpdb; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id,provider,staff_id,authority,timezone,enabled,consent_revision,revision,sync_revision,coverage_start,coverage_end,last_attempt,last_success,next_attempt,error_code FROM %i WHERE staff_id=%d ORDER BY id DESC LIMIT 26 OFFSET %d', $this->read->table( 'calendar_connections' ), $staff, ( $page - 1 ) * 25 ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); return array_map( array( self::class, 'state' ), $rows ); } /** Bounded identifiers let managers reach retained profiles after switching to Free. */ public function staff_ids( int $page = 1 ): array { self::number( $page, true ); if ( $page > 1000000 ) { throw new \DomainException( 'calendar_retained_page' ); } if ( ! $this->present() ) { return array(); } global $wpdb; return array_map( 'intval', array_column( Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT DISTINCT staff_id FROM %i ORDER BY staff_id LIMIT 26 OFFSET %d', $this->read->table( 'calendar_connections' ), ( $page - 1 ) * 25 ), ARRAY_A ), 'retained_capacity_storage_unavailable' ), 'staff_id' ) ); } private function write( $result ): void { global $wpdb; if ( $result === false || $wpdb->last_error !== '' ) { throw new \RuntimeException( 'calendar_retained_write' ); } } private function transaction( callable $run ) { global $wpdb; $owned = ! BookingLock::is_held(); if ( $owned && ! BookingLock::acquire() ) { throw new \RuntimeException( 'calendar_retained_lock' ); } $started = false; $suppressed = $wpdb->suppress_errors( true ); try { if ( self::$active ) { throw new \RuntimeException( 'calendar_retained_transaction' ); } TransactionGuard::prepare_write(); if ( ! $this->present() ) { throw new \RuntimeException( 'calendar_retained_missing' ); } $this->write( $wpdb->query( 'START TRANSACTION' ) ); $started = true; self::$active = true; $result = $run( static fn(): bool=>self::$active && BookingLock::is_held() ); $this->write( $wpdb->query( 'COMMIT' ) ); $started = false; return $result; } catch ( \Throwable $e ) { if ( $started ) { $wpdb->query( 'ROLLBACK' ); } throw $e; } finally { self::$active = false; $wpdb->suppress_errors( $suppressed ); foreach ( array_keys( $this->epochUsers ) as $user ) { self::clearAuthorityCaches( (int) $user ); } $this->epochUsers = array(); if ( $owned ) { BookingLock::release(); } } } private function witness( callable $owner ): void { if ( ! BookingLock::is_held() || $owner() !== true ) { throw new \RuntimeException( 'calendar_retained_owner' ); } if ( ! $this->present() ) { throw new \RuntimeException( 'calendar_retained_missing' ); } } public static function epochOption( int $user ): string { self::number( $user, true ); return 'abientot_calendar_authority_epoch_' . $user; } public static function clearAuthorityCaches( int $user ): void { $name = self::epochOption( $user ); wp_cache_delete( $name, 'options' ); wp_cache_delete( 'alloptions', 'options' ); wp_cache_delete( 'notoptions', 'options' ); } private function rotateEpoch( int $user, callable $owner ): void { $this->witness( $owner ); global $wpdb; $name = self::epochOption( $user ); $epoch = bin2hex( random_bytes( 32 ) ); $this->epochUsers[ $user ] = true; $this->write( $wpdb->query( $wpdb->prepare( "INSERT INTO {$wpdb->options} (option_name,option_value,autoload) VALUES (%s,%s,'no') ON DUPLICATE KEY UPDATE option_value=VALUES(option_value),autoload='no'", $name, $epoch ) ) ); $stored = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( "SELECT option_value,autoload FROM {$wpdb->options} WHERE option_name=%s", $name ), ARRAY_A ), 'retained_capacity_storage_unavailable' )[0] ?? null; if ( ! $stored || ! hash_equals( $epoch, (string) $stored['option_value'] ) || $stored['autoload'] !== 'no' ) { throw new \RuntimeException( 'calendar_retained_epoch' ); } } private function audit( int $id, string $event ): void { global $wpdb; $this->write( $wpdb->insert( $this->read->table( 'activity_log' ), array( 'object_type' => 'calendar_connection', 'object_id' => $id, 'event' => $event, 'actor_type' => get_current_user_id() ? 'user' : 'system', 'actor_id' => get_current_user_id() ?: null, 'message' => __( 'Calendar protection changed.', 'abientot' ), 'context_json' => null, 'created_at' => gmdate( 'Y-m-d H:i:s' ) ) ) ); } /** Private mutation only; revisions invalidate network work started under old authority. */ private function scrub( array $row, callable $owner, bool $erasing ): array { $this->witness( $owner ); self::state( $row ); global $wpdb; $id = (int) $row['id']; $revision = self::number( $row['revision'], true ); $consent = self::number( $row['consent_revision'], true ); $data = array( 'enabled' => 0, 'authority' => 'revoked', 'revision' => $revision + 1, 'consent_revision' => $consent + 1, 'credential' => '', 'calendars' => '', 'coverage_start' => null, 'coverage_end' => null, 'last_attempt' => null, 'last_success' => null, 'next_attempt' => null, 'error_code' => $erasing ? 'erased' : 'disconnected' ); if ( $erasing ) { $data['owner_user_id'] = 0; } $changed = $wpdb->update( $this->read->table( 'calendar_connections' ), $data, array( 'id' => $id, 'revision' => $revision, 'owner_user_id' => (int) $row['owner_user_id'] ) ); $this->write( $changed ); if ( $changed !== 1 ) { throw new \RuntimeException( 'calendar_retained_stale' ); } $this->write( $wpdb->delete( $this->read->table( 'calendar_busy' ), array( 'connection_id' => $id ) ) ); $this->write( $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE user_id=%d AND staff_id=%d AND provider=%s AND purpose=%s', $this->read->table( 'calendar_oauth_states' ), (int) $row['owner_user_id'], (int) $row['staff_id'], $row['provider'], 'connect' ) ) ); $this->audit( $id, $erasing ? 'calendar_owner_erased' : 'calendar_disconnected_local' ); $this->witness( $owner ); return array( 'connection_id' => $id, 'revision' => $revision + 1, 'consent_revision' => $consent + 1 ); } public function disconnect( int $id, int $expected, bool $riskAccepted, ?callable $authority = null ): array { self::number( $id, true ); self::number( $expected, true ); if ( ! $riskAccepted || ( $authority === null && ! current_user_can( 'abientot_manage_settings' ) ) ) { throw new \DomainException( 'calendar_retained_permission' ); } return $this->transaction( function ( callable $owner ) use ( $id, $expected, $authority ): array { if ( $authority === null && ! current_user_can( 'abientot_manage_settings' ) ) { throw new \DomainException( 'calendar_retained_permission' ); } global $wpdb; $row = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id,provider,staff_id,authority,timezone,enabled,consent_revision,revision,sync_revision,coverage_start,coverage_end,last_attempt,last_success,next_attempt,error_code,owner_user_id FROM %i WHERE id=%d', $this->read->table( 'calendar_connections' ), $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' )[0] ?? null; if ( ! $row || (int) $row['revision'] !== $expected ) { throw new \DomainException( 'calendar_retained_stale' ); } if ( $authority !== null && $authority( $row ) !== true ) { throw new \DomainException( 'calendar_retained_permission' ); } if ( (int) $row['owner_user_id'] > 0 ) { $this->rotateEpoch( (int) $row['owner_user_id'], $owner ); } return $this->scrub( $row, $owner, false ); } ); } /** Trusted verified personal-data controller; pass witness to join its checked transaction. */ public function erase_user( int $user, ?callable $owner = null ): bool { self::number( $user, true ); if ( ! $this->present() ) { return false; } $run = function ( callable $witness ) use ( $user ): bool { $this->witness( $witness ); $this->rotateEpoch( $user, $witness ); global $wpdb; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id,provider,staff_id,authority,timezone,enabled,consent_revision,revision,sync_revision,coverage_start,coverage_end,last_attempt,last_success,next_attempt,error_code,owner_user_id FROM %i WHERE owner_user_id=%d ORDER BY id', $this->read->table( 'calendar_connections' ), $user ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); $states = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT token_hash FROM %i WHERE user_id=%d AND purpose=%s LIMIT 1', $this->read->table( 'calendar_oauth_states' ), $user, 'connect' ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); foreach ( $rows as $row ) { $this->scrub( $row, $witness, true ); } $this->write( $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE user_id=%d AND purpose=%s', $this->read->table( 'calendar_oauth_states' ), $user, 'connect' ) ) ); $this->witness( $witness ); return (bool) ( $rows || $states ); }; return $owner !== null ? $run( $owner ) : $this->transaction( $run ); } /** Minimal private export; caller supplies verified canonical WP user identity. */ public function export_user( int $user, int $page = 1 ): array { self::number( $user, true ); self::number( $page, true ); if ( $page > 1000000 ) { throw new \DomainException( 'calendar_retained_page' ); } if ( ! $this->present() ) { return array(); } global $wpdb; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id,provider,staff_id,authority,timezone,enabled,consent_revision,revision,sync_revision,coverage_start,coverage_end,last_attempt,last_success,next_attempt,error_code FROM %i WHERE owner_user_id=%d ORDER BY id LIMIT 100 OFFSET %d', $this->read->table( 'calendar_connections' ), $user, ( $page - 1 ) * 100 ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); return array_map( array( self::class, 'state' ), $rows ); } /** Exact connection export for a capability-checked independently verified manual request. */ public function export_connection( int $id ): array { self::number( $id, true ); if ( ! current_user_can( 'abientot_manage_settings' ) || ! current_user_can( 'abientot_manage_customers' ) ) { throw new \DomainException( 'calendar_retained_permission' ); } if ( ! $this->present() ) { return array(); } global $wpdb; $rows = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id,provider,staff_id,authority,timezone,enabled,consent_revision,revision,sync_revision,coverage_start,coverage_end,last_attempt,last_success,next_attempt,error_code FROM %i WHERE id=%d', $this->read->table( 'calendar_connections' ), $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' ); return array_map( array( self::class, 'state' ), $rows ); } /** Reuse checked local revocation, epoch rotation and stale-work witnesses. */ public function erase_connection( int $id ): bool { self::number( $id, true ); if ( ! current_user_can( 'abientot_manage_settings' ) || ! current_user_can( 'abientot_manage_customers' ) ) { throw new \DomainException( 'calendar_retained_permission' ); } if ( ! $this->present() ) { return false; } return $this->transaction( function ( callable $owner ) use ( $id ): bool { if ( ! current_user_can( 'abientot_manage_settings' ) || ! current_user_can( 'abientot_manage_customers' ) ) { throw new \DomainException( 'calendar_retained_permission' ); } global $wpdb; $row = Db::rows( $wpdb->suppress_errors( true ), $wpdb->get_results( $wpdb->prepare( 'SELECT id,provider,staff_id,authority,timezone,enabled,consent_revision,revision,sync_revision,coverage_start,coverage_end,last_attempt,last_success,next_attempt,error_code,owner_user_id FROM %i WHERE id=%d', $this->read->table( 'calendar_connections' ), $id ), ARRAY_A ), 'retained_capacity_storage_unavailable' )[0] ?? null; if ( ! $row ) { return false; } if ( (int) $row['owner_user_id'] > 0 ) { $this->rotateEpoch( (int) $row['owner_user_id'], $owner ); } $this->scrub( $row, $owner, true ); return true; } ); } }