'POST', 'callback' => [ __CLASS__, 'emit' ], 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, 'args' => [ 'surface' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], 'ref' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], 'page' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ], // 'mode' selects which target the dev verification flow exercises. // Currently accepted: 'default' (the configured / scaffold-failure target) // and 'success_mock' (routes to the dev-receive endpoint, dev-only). // In non-dev environments, 'success_mock' silently falls back to default. 'mode' => [ 'required' => false, 'type' => 'string', 'sanitize_callback' => 'sanitize_key' ], ], ] ); // Dev-only mock receiver. Registered ONLY when the environment is // explicitly local or development. In production this route does not // exist — the registration itself is gated, not just the handler. if ( class_exists( 'SA_Orch_Env' ) && SA_Orch_Env::is_local_or_dev() ) { register_rest_route( 'sa-orch/v1', '/attention/dev-receive', [ 'methods' => 'POST', 'callback' => [ __CLASS__, 'dev_receive' ], // No capability check — this is a dev mock for self-call testing. // The whole route is only registered in dev environments. 'permission_callback' => '__return_true', ] ); } } /** * Dev-only mock receiver. Returns HTTP 200 with a confirmation body so * the success path can be exercised end-to-end without a real oversight * site standing up. Defense in depth: handler also bails if env is not * dev (in case the route somehow survives a misconfigured deploy). */ public static function dev_receive( WP_REST_Request $request ) { if ( ! class_exists( 'SA_Orch_Env' ) || ! SA_Orch_Env::is_local_or_dev() ) { return new WP_Error( 'dev_receive_not_available', 'dev-receive is only available in local/development environments.', [ 'status' => 404 ] ); } return rest_ensure_response( [ 'received' => true, 'env' => SA_Orch_Env::type(), 'message' => 'Dev mock receiver acknowledged the attention packet.', 'received_at' => gmdate( 'c' ), ] ); } /** * Read configuration with classification-aware precedence: * * 1. Defined constant (production-safe; human-authorized) * 2. Filter override (testing override; same shape as the constant) * 3. Dev scaffold default (only when env is local/development; clearly * non-production values; no real privileged * system exposed; tokens labeled dev-only) * 4. Empty string (production with no constants set; emit will * fail with attention_config_missing — preserved * behavior for the unconfigured production case) * * Per the Environment Configuration Classification rule, dev scaffolding * is allowed because: * - the values are clearly non-production (sa-dev-local / discard-port URL / dev-only token) * - no real external privileged system is exposed (target is unreachable) * - the token is explicitly labeled dev-only * - the env must be EXPLICITLY local/development (default 'production' never scaffolds) */ public static function get_config(): array { $defaults = self::dev_scaffold_defaults(); return [ 'site_id' => self::resolve_value( 'SA_SITE_ID', 'sa_orch_attention_site_id', $defaults['site_id'] ), 'target_url' => self::resolve_value( 'SA_ATTENTION_TARGET_URL', 'sa_orch_attention_target_url', $defaults['target_url'] ), 'token' => self::resolve_value( 'SA_ATTENTION_TOKEN', 'sa_orch_attention_token', $defaults['token'] ), 'tenant' => self::resolve_value( 'SA_TENANT', 'sa_orch_attention_tenant', $defaults['tenant'] ), ]; } /** * Resolve a single config value. Constants beat filters beat dev scaffolds * beat empty. The dev_default is only non-empty when env is local/dev. */ private static function resolve_value( string $constant_name, string $filter_name, string $dev_default ): string { if ( defined( $constant_name ) ) { return (string) constant( $constant_name ); } $filtered = apply_filters( $filter_name, '' ); if ( $filtered !== '' && $filtered !== null ) { return (string) $filtered; } return $dev_default; } /** * Dev-mode scaffolds. Empty in production. Populated in local/dev only. * * Values chosen to be unambiguously non-production: * site_id — 'sa-dev-local' (slug clearly marks origin as dev) * target_url — '://:9/sa-attention-dev-null' derived from * home_url() so the scaffold scales with the install's own * hostname (works on wordpress.localhost, dev.example.com, * etc.). Port 9 is the IANA discard service; reliably * refuses connections regardless of host. No real system * is exposed; emit attempts cleanly fail with status=failed. * token — 'dev-only-not-a-real-secret' (string self-labels its kind) * tenant — 'dev-tenant' */ private static function dev_scaffold_defaults(): array { if ( ! class_exists( 'SA_Orch_Env' ) || ! SA_Orch_Env::is_local_or_dev() ) { return [ 'site_id' => '', 'target_url' => '', 'token' => '', 'tenant' => '' ]; } return [ 'site_id' => 'sa-dev-local', 'target_url' => self::dev_failure_target_url(), 'token' => 'dev-only-not-a-real-secret', 'tenant' => 'dev-tenant', ]; } /** * Compute the dev failure-target URL from the install's own host. * Scales with deploy: same code yields wordpress.localhost:9 in local * dev, dev.example.com:9 in remote dev, etc. Production never calls this. */ private static function dev_failure_target_url(): string { $parts = wp_parse_url( home_url() ); $scheme = $parts['scheme'] ?? 'http'; $host = $parts['host'] ?? '127.0.0.1'; return $scheme . '://' . $host . ':9/sa-attention-dev-null'; } /** * The URL of the dev mock receiver — same install, REST endpoint at * sa-orch/v1/attention/dev-receive. Returns empty string in non-dev * environments so the success-mock mode silently falls back to default. */ private static function dev_success_mock_url(): string { if ( ! class_exists( 'SA_Orch_Env' ) || ! SA_Orch_Env::is_local_or_dev() ) { return ''; } return rest_url( 'sa-orch/v1/attention/dev-receive' ); } /** * Resolve the outbound target URL given the configured target and the * caller-requested mode. Returns: * [ url, mode_label ] * mode_label is a short human-readable string for UI display: * 'configured' — the configured / scaffold-failure target * 'dev failure target' — same as 'configured' when env is dev (annotated) * 'dev success mock' — the dev-receive URL (only when env is dev) */ private static function resolve_outbound_target( string $configured_url, string $mode ): array { $is_dev = class_exists( 'SA_Orch_Env' ) && SA_Orch_Env::is_local_or_dev(); if ( $mode === 'success_mock' ) { $mock = self::dev_success_mock_url(); if ( $is_dev && $mock !== '' ) { return [ $mock, 'dev success mock' ]; } // Production / non-dev silently falls back to configured. Production // safety: success_mock has no effect outside dev. } return [ $configured_url, $is_dev ? 'dev failure target' : 'configured', ]; } /** * Build the v1 attention payload. Pure function — no I/O. */ public static function build_payload( array $cfg, array $input ): array { $user = wp_get_current_user(); $user_id = isset( $user->ID ) ? (int) $user->ID : 0; $user_name = isset( $user->user_login ) ? (string) $user->user_login : ''; return [ 'schema_version' => self::SCHEMA_VERSION, 'site_id' => (string) $cfg['site_id'], 'tenant' => (string) $cfg['tenant'], 'surface' => isset( $input['surface'] ) && $input['surface'] !== '' ? (string) $input['surface'] : null, 'ref' => isset( $input['ref'] ) && $input['ref'] !== '' ? (string) $input['ref'] : null, 'page' => isset( $input['page'] ) && $input['page'] !== '' ? (string) $input['page'] : null, 'user' => [ 'id' => $user_id, 'login' => $user_name, ], 'observed_at' => gmdate( 'c' ), ]; } /** * REST emit handler. Assembles payload, POSTs to target URL with bearer auth, * logs the attempt to wp_sa_attention_log. Returns the result to the caller * so the UI can render success/error feedback. */ public static function emit( WP_REST_Request $request ) { $cfg = self::get_config(); // Required-config check. Surface missing config explicitly so the // operator knows what to set, rather than silently failing. $missing = []; if ( $cfg['site_id'] === '' ) $missing[] = 'SA_SITE_ID'; if ( $cfg['target_url'] === '' ) $missing[] = 'SA_ATTENTION_TARGET_URL'; if ( $cfg['token'] === '' ) $missing[] = 'SA_ATTENTION_TOKEN'; if ( $missing ) { return new WP_Error( 'attention_config_missing', 'Attention Bridge configuration missing: ' . implode( ', ', $missing ) . '. Define these constants in wp-config.php.', [ 'status' => 500 ] ); } $input = [ 'surface' => (string) $request->get_param( 'surface' ), 'ref' => (string) $request->get_param( 'ref' ), 'page' => (string) $request->get_param( 'page' ), ]; $payload = self::build_payload( $cfg, $input ); // Resolve which target this emit hits. 'success_mock' routes to the // dev-receive endpoint (dev-only); otherwise the configured target. $mode = (string) $request->get_param( 'mode' ); list( $outbound_url, $mode_label ) = self::resolve_outbound_target( $cfg['target_url'], $mode ); // POST with bearer auth. wp_remote_post handles transport errors gracefully. $resp = wp_remote_post( $outbound_url, [ 'method' => 'POST', 'timeout' => 10, 'headers' => [ 'Authorization' => 'Bearer ' . $cfg['token'], 'Content-Type' => 'application/json', 'X-SA-Schema-Version' => self::SCHEMA_VERSION, ], 'body' => wp_json_encode( $payload ), ] ); $http_status = null; $response_excerpt = null; $error_message = null; if ( is_wp_error( $resp ) ) { $error_message = $resp->get_error_message(); } else { $http_status = (int) wp_remote_retrieve_response_code( $resp ); $body = (string) wp_remote_retrieve_body( $resp ); $response_excerpt = function_exists( 'mb_substr' ) ? mb_substr( $body, 0, 500 ) : substr( $body, 0, 500 ); } // Determine status. Distinguishes "the receiver accepted the packet" from // "the attempt was made but did not arrive cleanly". This is the difference // between an attempted-emission log and a successful-receipt log; we // explicitly want the former so the operator can audit transport reality. // // success — HTTP 2xx received from the configured target // failed — anything else: transport error (connection/DNS/timeout) OR // a non-2xx HTTP response (4xx/5xx etc.) // // 'blocked_config' is intentionally NOT used here. Config validation // failed earlier returns a WP_Error with no I/O; no row is written. $is_2xx = ( $error_message === null && $http_status !== null && $http_status >= 200 && $http_status < 300 ); $status = $is_2xx ? 'success' : 'failed'; // Log the attempt. Log-only — no analytics, no aggregation, no replay. // target_url logs the actual URL hit (which is the resolved outbound, // not necessarily the configured one — so dev success_mock attempts // are honestly recorded as having gone to the dev-receive endpoint). global $wpdb; $tbl = $wpdb->prefix . SA_ORCH_DB_PREFIX . 'attention_log'; $wpdb->insert( $tbl, [ 'site_id' => (string) $cfg['site_id'], 'surface' => $payload['surface'], 'ref' => $payload['ref'], 'target_url' => (string) $outbound_url, 'payload_json' => wp_json_encode( $payload ), 'status' => $status, 'http_status' => $http_status, 'response_excerpt' => $response_excerpt, 'error_message' => $error_message, 'recorded_at' => current_time( 'mysql', true ), ] ); $log_id = (int) $wpdb->insert_id; return rest_ensure_response( [ 'log_id' => $log_id, 'status' => $status, 'http_status' => $http_status, 'response_excerpt' => $response_excerpt, 'error_message' => $error_message, 'payload' => $payload, // echo back so UI can confirm what went out 'mode_label' => $mode_label, // 'dev failure target' / 'dev success mock' / 'configured' 'target_url' => $outbound_url, // the URL actually hit, for UI display 'ok' => $is_2xx, ] ); } /** * Enqueue the floating "Send Attention" button on the same admin * surfaces where Sara appears, gated to manage_options. * Sara's bubble lives bottom-right; this button lives bottom-left. */ public static function maybe_enqueue( $hook ) { if ( ! current_user_can( 'manage_options' ) ) { return; } $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : ''; $is_target = ( $page === 'fluent-boards' || $page === 'fluent-support' || strpos( $page, 'sa-orchestration' ) === 0 ); if ( ! $is_target ) { return; } wp_enqueue_script( self::HANDLE_JS, SA_ORCH_URL . 'assets/attention-bridge.js', [], SA_ORCH_VERSION, true ); $cfg = self::get_config(); $is_dev = class_exists( 'SA_Orch_Env' ) && SA_Orch_Env::is_local_or_dev(); wp_localize_script( self::HANDLE_JS, 'SA_ORCH_ATTENTION', [ 'rest_url' => esc_url_raw( rest_url( 'sa-orch/v1/attention/emit' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'page' => $page, 'site_id' => $cfg['site_id'], // for display only — not used as auth 'configured' => $cfg['site_id'] !== '' && $cfg['target_url'] !== '' && $cfg['token'] !== '', 'is_dev' => $is_dev, // Board #21 dev verification: when true, a second // "Send to dev mock" button appears alongside // the default "Send Attention". Production never // sees this flag; the second button never renders. ] ); } }