1, 'preset' => 'balanced', 'disabled' => array(), 'maintenance' => 'none', 'expires_at' => 0, ); } /** * Declare the bounded audit event for collection-control changes. * * @return array Registered collection event schema with finite facts and no value diffs. */ public static function definitions(): array { return array( 'configuration.collection_changed' => array( 'category' => 'configuration', 'action' => 'collection_changed', 'severity' => 'notice', 'object_type' => 'site', 'fields' => array( 'disabled_categories' => array( 'list' => self::CATEGORIES ), 'preset' => self::PRESETS, 'maintenance' => self::LABELS, 'expires_at' => 'count', ), 'diffs' => array(), ), ); } /** * Describe the local collection presets and their administrative retention choices. * * @return array Preset titles, disabled categories and event-retention durations. */ public static function presets(): array { return array( 'balanced' => array( 'title' => __( 'Balanced', 'logged-ai' ), 'disabled' => array(), 'event_days' => 90, ), 'security' => array( 'title' => __( 'Security review', 'logged-ai' ), 'disabled' => array( 'commerce', 'content' ), 'event_days' => 90, ), 'troubleshooting' => array( 'title' => __( 'Troubleshooting', 'logged-ai' ), 'disabled' => array(), 'event_days' => 14, ), 'woocommerce' => array( 'title' => __( 'WooCommerce accountability', 'logged-ai' ), 'disabled' => array( 'content' ), 'event_days' => 90, ), 'agency' => array( 'title' => __( 'Agency auditing', 'logged-ai' ), 'disabled' => array(), 'event_days' => 180, ), ); } /** * Validate the exact collection-control schema and maintenance expiry relationship. * * @param mixed $value Supplied value to inspect without narrowing its native input contract. * * @return bool Whether every control field has the supported representation and bounds. */ private static function valid( mixed $value ): bool { if ( ! is_array( $value ) || array_diff( array_keys( self::defaults() ), array_keys( $value ) ) || array_diff( array_keys( $value ), array_keys( self::defaults() ) ) || ! is_int( $value['revision'] ) || $value['revision'] < 1 || $value['revision'] >= 2147483647 || ! is_string( $value['preset'] ) || ! in_array( $value['preset'], self::PRESETS, true ) || ! is_array( $value['disabled'] ) || ! array_is_list( $value['disabled'] ) || count( $value['disabled'] ) > 2 || count( array_unique( $value['disabled'], SORT_REGULAR ) ) !== count( $value['disabled'] ) || ! in_array( $value['maintenance'], self::LABELS, true ) || ! is_int( $value['expires_at'] ) || $value['expires_at'] < 0 ) { return false; } foreach ( $value['disabled'] as $category ) { if ( ! in_array( $category, self::CATEGORIES, true ) ) { return false; } } return ( 'none' === $value['maintenance'] ) === ( 0 === $value['expires_at'] ); } /** * Read and validate saved collection controls or their defaults. * * @return array|\WP_Error Current controls, or a configuration error that leaves essential collection available. */ public static function get(): array|\WP_Error { $value = get_option( self::OPTION, self::defaults() ); return self::valid( $value ) ? $value : new \WP_Error( 'config', 'Collection controls are unreadable. Essential collection remains enabled; review settings.' ); } /** * Project current exclusions and any unexpired maintenance label. * * @return array Collection status with enabled fallback when saved controls are unavailable. */ public static function status(): array { $value = self::get(); if ( is_wp_error( $value ) ) { return array( 'state' => 'configuration_unavailable_collecting_all', 'disabled_categories' => array(), 'maintenance' => null, ); } $maintenance = 'none' !== $value['maintenance'] && $value['expires_at'] > time() ? array( 'label' => $value['maintenance'], 'expires_at' => $value['expires_at'], ) : null; return array( 'state' => $value['disabled'] ? 'selected_categories_excluded' : 'all_supported_categories_enabled', 'disabled_categories' => $value['disabled'], 'maintenance' => $maintenance, ); } /** * Check whether the selected category remains enabled by current collection controls. * * @param string $category Event category whose current collection eligibility is requested. * * @return bool Whether the category is absent from the permitted exclusion list. */ public static function allows( string $category ): bool { // Essential identity/configuration/privacy evidence cannot be suppressed by these controls. return ! in_array( $category, self::status()['disabled_categories'], true ); } /** * Read current validated collection controls through the bound option mutation. * * @param OptionMutation $guard Current option-mutation guard bound to collection storage. * * @return array|\WP_Error Current controls or defaults for absence, or a read/configuration error. */ private static function mutation_value( OptionMutation $guard ): array|\WP_Error { $value = $guard->value(); if ( is_wp_error( $value ) ) { return $value; } if ( null === $value ) { return self::defaults(); } return self::valid( $value ) ? $value : new \WP_Error( 'config', 'Saved collection controls are unreadable.' ); } /** * Save confirmed controls with exact revision checks, verified readback and auditing. * * @param Scope $scope Current native authorization scope selected for this operation. * @param array $input Complete proposed controls carrying the displayed current revision. * @param bool $confirmed Whether the operator explicitly confirmed the reviewed collection changes. * * @return array|\WP_Error Current saved controls, or an authority, conflict, storage or audit error. */ public static function save( Scope $scope, array $input, bool $confirmed ): array|\WP_Error { require_once __DIR__ . '/class-optionmutation.php'; return OptionMutation::run( $scope, 'collection', static function ( OptionMutation $guard ) use ( $scope, $input, $confirmed ) { if ( ! $confirmed || ! self::valid( $input ) || ( $input['expires_at'] && ( $input['expires_at'] <= time() || $input['expires_at'] > time() + 7 * DAY_IN_SECONDS ) ) ) { return new \WP_Error( 'input', 'Review valid collection controls and confirm their effect.' ); } $actor = $guard->read( static fn() => Store::actor_context() ); if ( is_wp_error( $actor ) ) { return $actor; } $old = $guard->read( static fn() => self::mutation_value( $guard ) ); if ( is_wp_error( $old ) ) { return $old; } $old_binding = $guard->fingerprint(); if ( $input['revision'] !== $old['revision'] ) { return new \WP_Error( 'conflict', 'Collection controls changed. Reload and review them.', array( 'status' => 409 ) ); } sort( $input['disabled'] ); if ( $input === $old ) { return $old; } if ( $input['revision'] >= 2147483646 ) { return new \WP_Error( 'config', 'Collection settings revision capacity was reached.' ); } $input['revision']++; $saved = $guard->update( $input, static fn() => self::mutation_value( $guard ) === $old && $guard->fingerprint() === $old_binding ); if ( is_wp_error( $saved ) ) { return $saved; } if ( ! $saved ) { return new \WP_Error( 'storage', 'Collection controls could not be saved.' ); } $verified = $guard->read( static fn() => self::mutation_value( $guard ) === $input ? true : new \WP_Error( 'storage', 'Saved collection controls could not be verified. Reload their current state.' ), false ); if ( is_wp_error( $verified ) ) { return $verified; } $audited = $guard->write( static fn() => Store::capture( array( 'code' => 'configuration.collection_changed', 'actor_context' => $actor, 'outcome' => 'stored_change', 'object_id' => $scope->site_id, 'payload' => array( 'observed_via' => 'logged_ai_collection', 'facts' => array( 'disabled_categories' => $input['disabled'], 'preset' => $input['preset'], 'maintenance' => $input['maintenance'], 'expires_at' => $input['expires_at'], ), ), ) ) ); if ( is_wp_error( $audited ) ) { return $audited; } if ( ! $audited ) { return new \WP_Error( 'audit_failed', 'Collection controls saved, but their audit record failed. Review System status.' ); } return $guard->read( static fn() => self::mutation_value( $guard ) === $input ? $input : new \WP_Error( 'stale', 'Saved collection controls changed during auditing.' ) ); }, static fn( OptionMutation $guard, array $result ): bool => self::mutation_value( $guard ) === $result ); } /** * Apply a reviewed preset to retention before updating its collection categories. * * @param Scope $scope Current native authorization scope selected for this operation. * @param string $preset Selected finite local collection preset key. * @param int $revision Collection revision captured by the displayed form. * @param int $settings_revision Retention-settings revision captured by the displayed form. * @param bool $confirmed Whether the operator explicitly confirmed the reviewed collection changes. * @param bool $confirm_shorter Separate confirmation permitting a shorter retention policy. * * @return array|\WP_Error Saved controls, or an input/conflict/error including explicitly partial completion. */ public static function apply_preset( Scope $scope, string $preset, int $revision, int $settings_revision, bool $confirmed, bool $confirm_shorter ): array|\WP_Error { if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) || ! $confirmed ) { return new \WP_Error( 'forbidden', 'Review and confirm the preset with settings authority.' ); } $presets = self::presets(); if ( ! isset( $presets[ $preset ] ) ) { return new \WP_Error( 'input', 'Choose a listed preset.' ); } $collection = self::get(); $settings = Settings::get(); if ( is_wp_error( $collection ) || $collection['revision'] !== $revision || $settings['revision'] !== $settings_revision ) { return new \WP_Error( 'conflict', 'Settings changed. Reload and review the preset.', array( 'status' => 409 ) ); } $settings['event_days'] = $presets[ $preset ]['event_days']; $result = Settings::save( $scope, $settings, $confirm_shorter ); if ( is_wp_error( $result ) ) { return $result; } if ( $collection['expires_at'] && $collection['expires_at'] <= time() ) { $collection['maintenance'] = 'none'; $collection['expires_at'] = 0; } $collection['preset'] = $preset; $collection['disabled'] = $presets[ $preset ]['disabled']; $result = self::save( $scope, $collection, true ); return is_wp_error( $result ) ? new \WP_Error( 'partial', 'Retention was saved, but collection controls did not complete. Reload both settings before retrying.' ) : $result; } /** * Register the collection setup page and its authenticated form handler. * * @return void */ public static function boot(): void { add_action( 'admin_menu', static function () { add_submenu_page( 'logged-ai', __( 'Collection controls', 'logged-ai' ), __( 'Collection controls', 'logged-ai' ), 'logged_ai_manage_settings', 'logged-ai-collection', array( self::class, 'page' ) ); }, 18 ); add_action( 'admin_post_logged_ai_collection', array( self::class, 'submit' ) ); } /** * Validate the complete collection form and dispatch the selected confirmed operation. * * @return void */ public static function submit(): void { $request_form = \LoggedAI\AdminInput::form(); $scope = Scope::current( 'logged_ai_manage_settings' ); if ( is_wp_error( $scope ) || 'POST' !== \LoggedAI\AdminInput::method() ) { wp_die( esc_html__( 'Collection settings access denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'logged_ai_collection' ); $input = $request_form; foreach ( array( 'revision', 'settings_revision' ) as $key ) { if ( ! isset( $input[ $key ] ) || ! is_string( $input[ $key ] ) || ! preg_match( '/\A[1-9][0-9]{0,9}\z/D', $input[ $key ] ) ) { wp_die( esc_html__( 'Incomplete settings form.', 'logged-ai' ), '', array( 'response' => 400 ) ); } } if ( '1' !== ( $input['complete'] ?? null ) || ! is_string( $input['operation'] ?? null ) ) { wp_die( esc_html__( 'Incomplete settings form.', 'logged-ai' ), '', array( 'response' => 400 ) ); } $confirmed = '1' === ( $input['confirmed'] ?? null ); if ( 'preset' === $input['operation'] && is_string( $input['preset'] ?? null ) ) { // phpcs:ignore WordPress.PHP.YodaConditions.NotYoda -- This confirmation argument already compares the literal '1' on the left; the sniff includes a preceding variable argument. $result = self::apply_preset( $scope, $input['preset'], (int) $input['revision'], (int) $input['settings_revision'], $confirmed, '1' === ( $input['confirm_shorter'] ?? null ) ); } elseif ( 'controls' === $input['operation'] && is_string( $input['maintenance'] ?? null ) && in_array( $input['ttl'] ?? null, array( '3600', '86400', '604800' ), true ) ) { $value = array( 'revision' => (int) $input['revision'], 'preset' => 'custom', 'disabled' => $input['disabled'] ?? array(), 'maintenance' => $input['maintenance'], 'expires_at' => 'none' === $input['maintenance'] ? 0 : time() + (int) $input['ttl'], ); $result = self::save( $scope, $value, $confirmed ); } else { $result = new \WP_Error( 'input', 'Invalid settings form.' ); } if ( is_wp_error( $result ) ) { wp_die( esc_html( PresentationTranslations::text( $result->get_error_message() ) ), '', array( 'response' => (int) ( $result->get_error_data()['status'] ?? 400 ), 'back_link' => true, ) ); } wp_safe_redirect( admin_url( 'admin.php?page=logged-ai-collection' ) ); exit; } /** * Render the collection form action, current revisions and nonce fields. * * @param array $value Validated current collection controls supplying the displayed revision. * @param string $operation Finite form operation selecting preset or explicit-control saving. * * @return void */ private static function form_start( array $value, string $operation ): void { echo '
'; } /** * Render authorized local presets, collection exclusions and maintenance-label controls. * * @return void */ public static function page(): void { $scope = Scope::current( 'logged_ai_manage_settings' ); if ( is_wp_error( $scope ) ) { wp_die( esc_html__( 'Collection access denied.', 'logged-ai' ), '', array( 'response' => 403 ) ); } nocache_headers(); $value = self::get(); echo '' . esc_html__( 'Setup is optional. No account or email registration is required. Supported administrative changes and bounded request observation start locally. A preset changes only the collection categories and administrative retention listed below; it preserves IP privacy, other retention settings, alert choices, response policies and maintenance labels.', 'logged-ai' ) . '
'; if ( is_wp_error( $value ) ) { echo ( '' . ( esc_html( PresentationTranslations::text( $value->get_error_message() ) ) ) . '
' . '' . ( esc_html( sprintf( /* translators: 1: Value shown in this message. 2: Value shown in this message. 3: Value shown in this message. */ __( 'Current preset: %1$s. Administrative retention: %2$s days. Excluded collection categories: %3$s.', 'logged-ai' ), PresentationTranslations::text( $value['preset'] ), Settings::get()['event_days'], $disabled_text ? $disabled_text : PresentationTranslations::text( 'none' ) ) ) ) . '
' . '' . '' . '
' ); } echo ''; self::form_end(); echo '' . esc_html__( 'Excluding a category stops storing its new events and prevents alerts based on those omitted events. Existing evidence remains. Identity, configuration and privacy collection stay enabled. Viewer filters only hide rows; alert rule controls only change sending. These are separate controls.', 'logged-ai' ) . '
'; self::form_start( $value, 'controls' ); foreach ( self::CATEGORIES as $category ) { echo ( '' . '' . '
' ); } echo '' . ' ' . '' . '' . ( esc_html__( 'A maintenance label adds context to newly captured events. It does not suppress collection, alerting, findings or response. It expires during capture even when cron is stopped.', 'logged-ai' ) ) . '
' ); self::form_end(); echo '' . esc_html__( 'Use WordPress Users to change a disposable test account’s role. Open Activity, filter by that affected user ID, inspect the actor and before/after values, then export the filtered result. This guide does not create a fake event.', 'logged-ai' ) . '
'; } }