RcmEmailAutomation / sa-orchestration /includes /class-sa-attention-bridge.php
NujacsaintS's picture
reseed
f51c224
Raw
History Blame Contribute Delete
19.2 kB
<?php
defined( 'ABSPATH' ) || exit;
/**
* Attention Bridge — emit-only MVP.
*
* Cross-site attention routing channel. The local site (RCM) emits
* structured context packets to a configured oversight URL. The
* receiving side is OUT OF SCOPE for this Board — emit-only MVP.
*
* Architectural rules (stakeholder, locked):
* Attention is portable. Authority is local. Oversight is routed.
* Local site owns its operational truth.
* SleeperAgents owns oversight routing.
* SA-Core governs the exchange.
*
* Doctrine constraints honored:
* - User-triggered emission ONLY. No auto-emit. No Sara coupling.
* - Bearer-token auth for the outbound POST (shared secret).
* - Schema versioning baked into the payload: 'sa.attention.v1'.
* - Log-only storage: wp_sa_attention_log captures what was sent + whether it arrived.
* - "Don't overbuild." This file does emit + log. Nothing else.
*
* Configuration (wp-config.php constants — required):
* define('SA_SITE_ID', 'rcm');
* define('SA_ATTENTION_TARGET_URL', 'https://sleeperagents.org/wp-json/oversight/v1/attention');
* define('SA_ATTENTION_TOKEN', 'shared-secret-here');
*
* Optional:
* define('SA_TENANT', 'org-slug'); // single-field tenant per locked spec
*
* Each constant is also filterable for testing / runtime override:
* sa_orch_attention_site_id, sa_orch_attention_target_url,
* sa_orch_attention_token, sa_orch_attention_tenant
*/
class SA_Orch_Attention_Bridge {
const SCHEMA_VERSION = 'sa.attention.v1';
const HANDLE_JS = 'sa-orch-attention-bridge';
const HANDLE_CSS = 'sa-orch-attention-bridge';
/** Hook registration — REST + admin enqueue. */
public static function register_hooks() {
add_action( 'rest_api_init', [ __CLASS__, 'register_rest_routes' ] );
add_action( 'admin_enqueue_scripts', [ __CLASS__, 'maybe_enqueue' ] );
}
/**
* REST endpoints:
* POST /sa-orch/v1/attention/emit — always (production-safe)
* POST /sa-orch/v1/attention/dev-receive — local/development only (mock 200 receiver)
*/
public static function register_rest_routes() {
register_rest_route( 'sa-orch/v1', '/attention/emit', [
'methods' => '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 — '<scheme>://<host>: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.
] );
}
}