Skip to main content

scp_node/
bridge_handlers.rs

1//! HTTP handlers for bridge endpoints.
2//!
3//! Implements the REST API surface for bridge operations such as shadow
4//! identity creation. All endpoints require bridge authentication via
5//! DID-signed JWT (see [`bridge_auth`](crate::bridge_auth)).
6//!
7//! See spec section 12.10 and ADR-023 in `.docs/adrs/phase-5.md`.
8
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11use std::time::{SystemTime, UNIX_EPOCH};
12
13use axum::extract::{Path, State};
14use axum::http::StatusCode;
15use axum::response::IntoResponse;
16use axum::{
17    Extension, Json, Router,
18    routing::{delete, get, post},
19};
20use scp_core::bridge::shadow::{CreateShadowParams, ShadowRegistry, create_shadow};
21use scp_core::bridge::{BridgeMode, ShadowProvenanceStatus};
22use scp_core::crypto::sender_keys::SenderKeyStore;
23use serde::{Deserialize, Serialize};
24use tokio::sync::RwLock;
25
26use crate::error::ApiError;
27
28// ---------------------------------------------------------------------------
29// Shared state for bridge operations
30// ---------------------------------------------------------------------------
31
32/// A stored platform identity attestation.
33#[derive(Debug, Clone, Serialize)]
34pub struct StoredAttestation {
35    pub attestation_id: String,
36    pub status: String,
37    pub bridge_id: String,
38    pub platform_handle: String,
39    pub platform_user_id: String,
40    pub evidence: AttestationEvidence,
41    pub issued_at: u64,
42    pub expires_at: u64,
43}
44
45/// Shared state for bridge shadow operations.
46///
47/// Holds per-context shadow registries, the sender key store,
48/// platform identity attestations, webhook event deduplication,
49/// and emitted messages, protected by async `RwLock`s for
50/// concurrent handler access.
51#[derive(Debug)]
52pub struct BridgeState {
53    /// Per-context shadow registries, keyed by context ID.
54    pub registries: RwLock<HashMap<String, ShadowRegistry>>,
55
56    /// Sender key store for shadow identity encryption keys.
57    pub sender_key_store: RwLock<SenderKeyStore>,
58
59    /// Platform identity attestations, keyed by attestation ID.
60    pub attestations: RwLock<HashMap<String, StoredAttestation>>,
61
62    /// Set of deleted shadow IDs (historical actions remain in event log).
63    pub deleted_shadows: RwLock<HashSet<String>>,
64
65    /// Set of webhook event IDs already processed (deduplication).
66    pub processed_event_ids: RwLock<HashSet<String>>,
67
68    /// Emitted messages, keyed by message ID.
69    pub messages: RwLock<Vec<EmittedMessage>>,
70
71    /// Monotonically increasing sequence counter for emitted messages.
72    pub message_sequence: RwLock<u64>,
73}
74
75/// A stored emitted message for tracking purposes.
76#[derive(Debug, Clone, Serialize)]
77pub struct EmittedMessage {
78    /// Unique message ID.
79    pub message_id: String,
80    /// Shadow ID that emitted the message.
81    pub shadow_id: String,
82    /// Message content.
83    pub content: String,
84    /// Content type.
85    pub content_type: String,
86    /// Sequence number.
87    pub sequence: u64,
88    /// Bridge provenance metadata.
89    pub bridge_provenance: BridgeProvenanceResponse,
90}
91
92impl BridgeState {
93    /// Creates a new empty bridge state.
94    #[must_use]
95    pub fn new() -> Self {
96        Self {
97            registries: RwLock::new(HashMap::new()),
98            sender_key_store: RwLock::new(SenderKeyStore::new()),
99            attestations: RwLock::new(HashMap::new()),
100            deleted_shadows: RwLock::new(HashSet::new()),
101            processed_event_ids: RwLock::new(HashSet::new()),
102            messages: RwLock::new(Vec::new()),
103            message_sequence: RwLock::new(0),
104        }
105    }
106}
107
108impl Default for BridgeState {
109    fn default() -> Self {
110        Self::new()
111    }
112}
113
114// ---------------------------------------------------------------------------
115// Request / response types
116// ---------------------------------------------------------------------------
117
118/// Request body for `POST /v1/scp/bridge/shadow`.
119#[derive(Debug, Deserialize)]
120pub struct CreateShadowRequest {
121    /// The external platform handle (e.g., `"@alice#1234"`).
122    pub platform_handle: String,
123
124    /// The platform-specific user identifier, used for idempotency.
125    /// If a shadow already exists for this ID on the authenticated bridge,
126    /// the existing shadow is returned with HTTP 200.
127    pub platform_user_id: String,
128
129    /// Optional metadata associated with the shadow identity.
130    #[serde(default)]
131    pub metadata: Option<serde_json::Value>,
132}
133
134/// Response body for `POST /v1/scp/bridge/shadow`.
135#[derive(Debug, Serialize)]
136pub struct CreateShadowResponse {
137    /// The unique shadow identity ID.
138    pub shadow_id: String,
139
140    /// The external platform handle.
141    pub platform_handle: String,
142
143    /// The platform-specific user identifier.
144    pub platform_user_id: String,
145
146    /// The role attributed to this shadow (defaults to `"observer"`).
147    pub attributed_role: String,
148
149    /// Unix timestamp (seconds) when the shadow was created.
150    pub created_at: u64,
151}
152
153/// Request body for `POST /v1/scp/bridge/attest`.
154#[derive(Debug, Deserialize)]
155pub struct AttestRequest {
156    /// The user's handle on the external platform.
157    pub platform_handle: String,
158
159    /// The platform's internal user identifier.
160    pub platform_user_id: String,
161
162    /// Evidence supporting the identity assertion.
163    pub attestation_evidence: AttestationEvidence,
164}
165
166/// Evidence supporting a platform identity attestation.
167#[derive(Debug, Clone, Deserialize, Serialize)]
168pub struct AttestationEvidence {
169    /// Type of evidence (`platform-verified`, `oauth2`, `signed-challenge`).
170    pub evidence_type: String,
171
172    /// How the platform verified the user.
173    pub verification_method: String,
174
175    /// Unix timestamp (seconds) of verification.
176    pub verified_at: u64,
177
178    /// Confidence level: `"high"`, `"medium"`, or `"low"`.
179    pub platform_confidence: String,
180
181    /// Platform-specific trust signals.
182    #[serde(default)]
183    pub additional_signals: Option<serde_json::Value>,
184}
185
186/// Response body for `POST /v1/scp/bridge/attest`.
187#[derive(Debug, Serialize)]
188pub struct AttestResponse {
189    /// The unique attestation ID.
190    pub attestation_id: String,
191
192    /// Attestation status (always `"active"` on creation).
193    pub status: String,
194
195    /// The user's handle on the external platform.
196    pub platform_handle: String,
197
198    /// Unix timestamp (seconds) when the attestation was issued.
199    pub issued_at: u64,
200
201    /// Unix timestamp (seconds) when the attestation expires.
202    pub expires_at: u64,
203}
204
205/// Default attestation TTL: 24 hours in seconds.
206const ATTESTATION_TTL_SECS: u64 = 86_400;
207
208// ---------------------------------------------------------------------------
209// Message endpoint types (SCP-BCH-003)
210// ---------------------------------------------------------------------------
211
212/// Request body for `POST /v1/scp/bridge/message`.
213#[derive(Debug, Deserialize)]
214pub struct EmitMessageRequest {
215    /// The shadow identity emitting the message.
216    pub shadow_id: String,
217
218    /// Message content.
219    pub content: String,
220
221    /// MIME content type (e.g., `"text/plain"`).
222    pub content_type: String,
223
224    /// Optional platform-specific message ID for correlation.
225    #[serde(default)]
226    pub platform_message_id: Option<String>,
227
228    /// Optional platform-reported timestamp.
229    #[serde(default)]
230    pub platform_timestamp: Option<u64>,
231}
232
233/// Serializable bridge provenance in API responses.
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct BridgeProvenanceResponse {
236    /// Originating platform name.
237    pub originating_platform: String,
238    /// Bridge operating mode.
239    pub bridge_mode: String,
240    /// Shadow provenance status.
241    pub shadow_status: String,
242    /// Operator DID string.
243    pub operator_did: String,
244}
245
246/// Response body for `POST /v1/scp/bridge/message`.
247#[derive(Debug, Serialize)]
248pub struct EmitMessageResponse {
249    /// The unique message ID.
250    pub message_id: String,
251    /// Sequence number of the message.
252    pub sequence: u64,
253    /// Bridge provenance metadata.
254    pub bridge_provenance: BridgeProvenanceResponse,
255}
256
257// ---------------------------------------------------------------------------
258// Status endpoint types (SCP-BCH-005)
259// ---------------------------------------------------------------------------
260
261/// Response body for `GET /v1/scp/bridge/status`.
262#[derive(Debug, Serialize)]
263pub struct BridgeStatusResponse {
264    /// Bridge instance ID.
265    pub bridge_id: String,
266    /// Current status (Active, Suspended, Revoked).
267    pub status: String,
268    /// External platform name.
269    pub platform: String,
270    /// Operating mode.
271    pub mode: String,
272    /// Operator DID.
273    pub operator_did: String,
274    /// Registration timestamp.
275    pub registered_at: u64,
276    /// Number of active shadows.
277    pub shadow_count: usize,
278    /// Shadows list.
279    pub shadows: Vec<ShadowSummary>,
280}
281
282/// Summary of a shadow identity in the status response.
283#[derive(Debug, Serialize)]
284pub struct ShadowSummary {
285    /// Shadow identity ID.
286    pub shadow_id: String,
287    /// Platform handle.
288    pub platform_handle: String,
289    /// Attributed role.
290    pub attributed_role: String,
291    /// Provenance status.
292    pub provenance_status: String,
293    /// Creation timestamp.
294    pub created_at: u64,
295}
296
297// ---------------------------------------------------------------------------
298// Webhook endpoint types (SCP-BCH-006)
299// ---------------------------------------------------------------------------
300
301/// Request body for `POST /v1/scp/bridge/webhook`.
302#[derive(Debug, Deserialize)]
303pub struct WebhookRequest {
304    /// Event type.
305    pub event_type: String,
306    /// Unique event ID for deduplication.
307    pub event_id: String,
308    /// Event timestamp.
309    pub timestamp: u64,
310    /// Event-specific payload.
311    pub payload: serde_json::Value,
312}
313
314/// Response body for `POST /v1/scp/bridge/webhook`.
315#[derive(Debug, Serialize)]
316pub struct WebhookResponse {
317    /// Whether the event was accepted.
318    pub accepted: bool,
319    /// Event ID echo.
320    pub event_id: String,
321    /// Optional rejection reason.
322    #[serde(skip_serializing_if = "Option::is_none")]
323    pub reason: Option<String>,
324}
325
326/// Supported webhook event types.
327const VALID_EVENT_TYPES: &[&str] = &[
328    "message",
329    "presence",
330    "identity_update",
331    "user_departed",
332    "message_edit",
333    "message_delete",
334];
335
336// ---------------------------------------------------------------------------
337// Handler
338// ---------------------------------------------------------------------------
339
340/// Derives a deterministic shadow ID from bridge and platform user IDs.
341///
342/// The shadow ID is scoped to the bridge to prevent cross-bridge collisions.
343fn derive_shadow_id(bridge_id: &str, platform_user_id: &str) -> String {
344    format!("shadow:{bridge_id}:{platform_user_id}")
345}
346
347/// Handler for `POST /v1/scp/bridge/shadow`.
348///
349/// Creates a shadow identity for an external platform participant. The
350/// handler is idempotent: if a shadow already exists for the given
351/// `platform_user_id` on the authenticated bridge, the existing shadow
352/// is returned with HTTP 200.
353///
354/// Requires bridge authentication via the `bridge_auth_middleware`.
355/// The authenticated bridge context is extracted from request extensions.
356///
357/// See SCP-BCH-002 and spec section 12.10.
358#[allow(clippy::significant_drop_tightening)] // false positive on async RwLock guard scope
359async fn create_shadow_handler(
360    State(bridge_state): State<Arc<BridgeState>>,
361    Extension(auth_ctx): Extension<crate::bridge_auth::BridgeAuthContext>,
362    Json(body): Json<CreateShadowRequest>,
363) -> impl IntoResponse {
364    // Validate required fields (serde handles presence, but check emptiness).
365    if body.platform_handle.is_empty() {
366        return ApiError::bad_request("platform_handle must not be empty").into_response();
367    }
368    if body.platform_user_id.is_empty() {
369        return ApiError::bad_request("platform_user_id must not be empty").into_response();
370    }
371
372    let bridge_id = &auth_ctx.claims.scp_bridge_id;
373    let context_id = &auth_ctx.claims.scp_context_id;
374    let shadow_id = derive_shadow_id(bridge_id, &body.platform_user_id);
375
376    let mut registries = bridge_state.registries.write().await;
377
378    // Ensure a registry exists for this context.
379    let registry = registries
380        .entry(context_id.clone())
381        .or_insert_with(|| ShadowRegistry::new(context_id.clone()));
382
383    // Idempotency: if a shadow with this ID already exists, return 200.
384    if let Some(existing) = registry.shadows().iter().find(|s| s.shadow_id == shadow_id) {
385        return (
386            StatusCode::OK,
387            Json(CreateShadowResponse {
388                shadow_id: existing.shadow_id.clone(),
389                platform_handle: existing.platform_handle.clone(),
390                platform_user_id: body.platform_user_id,
391                attributed_role: existing.attributed_role.clone(),
392                created_at: existing.created_at,
393            }),
394        )
395            .into_response();
396    }
397
398    let now = SystemTime::now()
399        .duration_since(UNIX_EPOCH)
400        .map(|d| d.as_secs())
401        .unwrap_or(0);
402
403    let bridge_mode = auth_ctx.bridge.mode.clone();
404
405    let params = CreateShadowParams {
406        shadow_id: &shadow_id,
407        bridge_id,
408        bridge_mode,
409        platform_handle: &body.platform_handle,
410        context_member_dids: &[],
411        timestamp: now,
412    };
413
414    let mut sender_key_store = bridge_state.sender_key_store.write().await;
415
416    match create_shadow(registry, &mut sender_key_store, &params) {
417        Ok((shadow, _event)) => (
418            StatusCode::CREATED,
419            Json(CreateShadowResponse {
420                shadow_id: shadow.shadow_id,
421                platform_handle: shadow.platform_handle,
422                platform_user_id: body.platform_user_id,
423                attributed_role: shadow.attributed_role,
424                created_at: shadow.created_at,
425            }),
426        )
427            .into_response(),
428        Err(e) => ApiError::internal_error(e.to_string()).into_response(),
429    }
430}
431
432/// Validates the `platform_confidence` field value.
433fn is_valid_confidence(value: &str) -> bool {
434    matches!(value, "high" | "medium" | "low")
435}
436
437/// Handler for `POST /v1/scp/bridge/attest`.
438///
439/// Creates a platform identity attestation signed by the bridge operator.
440/// The attestation asserts the platform's confidence in the mapping between
441/// the platform handle and the user.
442///
443/// Requires bridge authentication via the `bridge_auth_middleware`.
444///
445/// See SCP-BCH-004 and spec section 12.10.
446#[allow(clippy::significant_drop_tightening)] // false positive on async RwLock guard scope
447async fn attest_handler(
448    State(bridge_state): State<Arc<BridgeState>>,
449    Extension(auth_ctx): Extension<crate::bridge_auth::BridgeAuthContext>,
450    Json(body): Json<AttestRequest>,
451) -> impl IntoResponse {
452    if body.platform_handle.is_empty() {
453        return ApiError::bad_request("platform_handle must not be empty").into_response();
454    }
455    if body.platform_user_id.is_empty() {
456        return ApiError::bad_request("platform_user_id must not be empty").into_response();
457    }
458    if body.attestation_evidence.evidence_type.is_empty() {
459        return ApiError::bad_request("attestation_evidence.evidence_type must not be empty")
460            .into_response();
461    }
462    if body.attestation_evidence.verification_method.is_empty() {
463        return ApiError::bad_request("attestation_evidence.verification_method must not be empty")
464            .into_response();
465    }
466    if !is_valid_confidence(&body.attestation_evidence.platform_confidence) {
467        return ApiError::bad_request(
468            "attestation_evidence.platform_confidence must be \"high\", \"medium\", or \"low\"",
469        )
470        .into_response();
471    }
472
473    let bridge_id = &auth_ctx.claims.scp_bridge_id;
474    let attestation_id = format!("attest:{bridge_id}:{}", body.platform_user_id);
475
476    let now = SystemTime::now()
477        .duration_since(UNIX_EPOCH)
478        .map(|d| d.as_secs())
479        .unwrap_or(0);
480
481    let stored = StoredAttestation {
482        attestation_id: attestation_id.clone(),
483        status: "active".to_owned(),
484        bridge_id: bridge_id.clone(),
485        platform_handle: body.platform_handle.clone(),
486        platform_user_id: body.platform_user_id,
487        evidence: body.attestation_evidence,
488        issued_at: now,
489        expires_at: now + ATTESTATION_TTL_SECS,
490    };
491
492    let response = AttestResponse {
493        attestation_id: stored.attestation_id.clone(),
494        status: stored.status.clone(),
495        platform_handle: stored.platform_handle.clone(),
496        issued_at: stored.issued_at,
497        expires_at: stored.expires_at,
498    };
499
500    let mut attestations = bridge_state.attestations.write().await;
501    attestations.insert(attestation_id, stored);
502
503    (StatusCode::CREATED, Json(response)).into_response()
504}
505
506// ---------------------------------------------------------------------------
507// Message handler (SCP-BCH-003)
508// ---------------------------------------------------------------------------
509
510/// Finds a shadow identity across all registries and returns it with context info.
511fn find_shadow(
512    registries: &HashMap<String, ShadowRegistry>,
513    shadow_id: &str,
514) -> Option<(String, scp_core::bridge::ShadowIdentity)> {
515    for (ctx_id, registry) in registries {
516        if let Some(shadow) = registry.shadows().iter().find(|s| s.shadow_id == shadow_id) {
517            return Some((ctx_id.clone(), shadow.clone()));
518        }
519    }
520    None
521}
522
523/// Handler for `POST /v1/scp/bridge/message`.
524///
525/// Emits a message on behalf of a shadow identity with full bridge
526/// provenance. Returns 202 Accepted with message ID, sequence, and
527/// provenance metadata.
528///
529/// See SCP-BCH-003 and spec section 12.10.4.
530async fn emit_message_handler(
531    State(bridge_state): State<Arc<BridgeState>>,
532    Extension(auth_ctx): Extension<crate::bridge_auth::BridgeAuthContext>,
533    Json(body): Json<EmitMessageRequest>,
534) -> impl IntoResponse {
535    if body.shadow_id.is_empty() {
536        return ApiError::bad_request("shadow_id must not be empty").into_response();
537    }
538    if body.content.is_empty() {
539        return ApiError::bad_request("content must not be empty").into_response();
540    }
541    if body.content_type.is_empty() {
542        return ApiError::bad_request("content_type must not be empty").into_response();
543    }
544
545    let registries = bridge_state.registries.read().await;
546    let deleted = bridge_state.deleted_shadows.read().await;
547
548    // Check if shadow was deleted.
549    if deleted.contains(&body.shadow_id) {
550        return ApiError::not_found("SHADOW_NOT_FOUND: shadow has been deleted").into_response();
551    }
552
553    let shadow_info = find_shadow(&registries, &body.shadow_id);
554    drop(registries);
555    drop(deleted);
556
557    let Some((_ctx_id, shadow)) = shadow_info else {
558        return (
559            StatusCode::NOT_FOUND,
560            Json(ApiError {
561                error: "shadow not found".to_owned(),
562                code: "SHADOW_NOT_FOUND".to_owned(),
563            }),
564        )
565            .into_response();
566    };
567
568    let shadow_status = match shadow.provenance_status {
569        ShadowProvenanceStatus::Shadow => "Shadow",
570        ShadowProvenanceStatus::Claimed => "Claimed",
571    };
572
573    let bridge_mode_str = match auth_ctx.bridge.mode {
574        BridgeMode::Relay => "Relay",
575        BridgeMode::Puppet => "Puppet",
576        BridgeMode::Api => "Api",
577        BridgeMode::Cooperative => "Cooperative",
578    };
579
580    let provenance_resp = BridgeProvenanceResponse {
581        originating_platform: auth_ctx.bridge.platform.clone(),
582        bridge_mode: bridge_mode_str.to_owned(),
583        shadow_status: shadow_status.to_owned(),
584        operator_did: auth_ctx.bridge.operator_did.0.clone(),
585    };
586
587    let mut seq = bridge_state.message_sequence.write().await;
588    *seq += 1;
589    let sequence = *seq;
590    drop(seq);
591
592    let message_id = format!("msg:{}:{sequence}", auth_ctx.claims.scp_bridge_id);
593
594    let emitted = EmittedMessage {
595        message_id: message_id.clone(),
596        shadow_id: body.shadow_id,
597        content: body.content,
598        content_type: body.content_type,
599        sequence,
600        bridge_provenance: provenance_resp.clone(),
601    };
602
603    bridge_state.messages.write().await.push(emitted);
604
605    (
606        StatusCode::ACCEPTED,
607        Json(EmitMessageResponse {
608            message_id,
609            sequence,
610            bridge_provenance: provenance_resp,
611        }),
612    )
613        .into_response()
614}
615
616// ---------------------------------------------------------------------------
617// Status handler (SCP-BCH-005)
618// ---------------------------------------------------------------------------
619
620/// Handler for `GET /v1/scp/bridge/status`.
621///
622/// Returns bridge status including shadow list, registration info, and
623/// rate limits.
624///
625/// See SCP-BCH-005 and spec section 12.10.4.
626#[allow(clippy::significant_drop_tightening)] // false positive on async RwLock guard scope
627async fn status_handler(
628    State(bridge_state): State<Arc<BridgeState>>,
629    Extension(auth_ctx): Extension<crate::bridge_auth::BridgeAuthContext>,
630) -> impl IntoResponse {
631    let registries = bridge_state.registries.read().await;
632    let deleted = bridge_state.deleted_shadows.read().await;
633
634    let mut shadows = Vec::new();
635    for registry in registries.values() {
636        for shadow in registry.shadows() {
637            if !deleted.contains(&shadow.shadow_id) {
638                let status_str = match shadow.provenance_status {
639                    ShadowProvenanceStatus::Shadow => "Shadow",
640                    ShadowProvenanceStatus::Claimed => "Claimed",
641                };
642                shadows.push(ShadowSummary {
643                    shadow_id: shadow.shadow_id.clone(),
644                    platform_handle: shadow.platform_handle.clone(),
645                    attributed_role: shadow.attributed_role.clone(),
646                    provenance_status: status_str.to_owned(),
647                    created_at: shadow.created_at,
648                });
649            }
650        }
651    }
652
653    let bridge_mode_str = match auth_ctx.bridge.mode {
654        BridgeMode::Relay => "Relay",
655        BridgeMode::Puppet => "Puppet",
656        BridgeMode::Api => "Api",
657        BridgeMode::Cooperative => "Cooperative",
658    };
659
660    let status_str = match auth_ctx.bridge.status {
661        scp_core::bridge::BridgeStatus::Active => "Active",
662        scp_core::bridge::BridgeStatus::Suspended => "Suspended",
663        scp_core::bridge::BridgeStatus::Revoked => "Revoked",
664    };
665
666    let shadow_count = shadows.len();
667
668    let resp = BridgeStatusResponse {
669        bridge_id: auth_ctx.bridge.bridge_id.clone(),
670        status: status_str.to_owned(),
671        platform: auth_ctx.bridge.platform.clone(),
672        mode: bridge_mode_str.to_owned(),
673        operator_did: auth_ctx.bridge.operator_did.0.clone(),
674        registered_at: auth_ctx.bridge.registered_at,
675        shadow_count,
676        shadows,
677    };
678
679    (StatusCode::OK, Json(resp)).into_response()
680}
681
682// ---------------------------------------------------------------------------
683// Delete shadow handler (SCP-BCH-005)
684// ---------------------------------------------------------------------------
685
686/// Handler for `DELETE /v1/scp/bridge/shadow/{shadow_id}`.
687///
688/// Deletes a shadow identity. Historical actions remain in the event log.
689/// Returns 204 on success, 404 if not found, 409 if claimed.
690/// Deletion is idempotent (re-deleting returns 204).
691///
692/// See SCP-BCH-005 and spec section 12.10.4.
693async fn delete_shadow_handler(
694    State(bridge_state): State<Arc<BridgeState>>,
695    Extension(_auth_ctx): Extension<crate::bridge_auth::BridgeAuthContext>,
696    Path(shadow_id): Path<String>,
697) -> impl IntoResponse {
698    let deleted = bridge_state.deleted_shadows.read().await;
699
700    // Idempotent: already deleted returns 204.
701    if deleted.contains(&shadow_id) {
702        return StatusCode::NO_CONTENT.into_response();
703    }
704    drop(deleted);
705
706    let registries = bridge_state.registries.read().await;
707    let shadow_info = find_shadow(&registries, &shadow_id);
708    drop(registries);
709
710    match shadow_info {
711        None => (
712            StatusCode::NOT_FOUND,
713            Json(ApiError {
714                error: "shadow not found".to_owned(),
715                code: "SHADOW_NOT_FOUND".to_owned(),
716            }),
717        )
718            .into_response(),
719        Some((_ctx_id, shadow)) => {
720            // Claimed shadows cannot be deleted.
721            if shadow.provenance_status == ShadowProvenanceStatus::Claimed {
722                return (
723                    StatusCode::CONFLICT,
724                    Json(ApiError {
725                        error: "shadow has been claimed and cannot be deleted".to_owned(),
726                        code: "SHADOW_ALREADY_CLAIMED".to_owned(),
727                    }),
728                )
729                    .into_response();
730            }
731
732            bridge_state.deleted_shadows.write().await.insert(shadow_id);
733
734            StatusCode::NO_CONTENT.into_response()
735        }
736    }
737}
738
739// ---------------------------------------------------------------------------
740// Webhook handler (SCP-BCH-006)
741// ---------------------------------------------------------------------------
742
743/// Extracts the `shadow_id` field from a webhook event payload.
744fn extract_shadow_id(payload: &serde_json::Value) -> &str {
745    payload
746        .get("shadow_id")
747        .and_then(|v| v.as_str())
748        .unwrap_or("")
749}
750
751/// Constructs a webhook rejection response (accepted: false).
752fn webhook_reject(event_id: String, reason: &str) -> axum::response::Response {
753    (
754        StatusCode::OK,
755        Json(WebhookResponse {
756            accepted: false,
757            event_id,
758            reason: Some(reason.to_owned()),
759        }),
760    )
761        .into_response()
762}
763
764/// Processes a single webhook event, returning `Some(response)` if the
765/// event should be rejected, or `None` if processing succeeded.
766async fn process_webhook_event(
767    bridge_state: &BridgeState,
768    event_type: &str,
769    event_id: &str,
770    payload: &serde_json::Value,
771) -> Option<String> {
772    match event_type {
773        "message" => {
774            let shadow_id = extract_shadow_id(payload);
775            if shadow_id.is_empty() {
776                return Some("payload.shadow_id is required for message events".to_owned());
777            }
778            let registries = bridge_state.registries.read().await;
779            let exists = find_shadow(&registries, shadow_id).is_some();
780            drop(registries);
781            if !exists {
782                return Some("shadow not found".to_owned());
783            }
784        }
785        "identity_update" => {
786            let shadow_id = extract_shadow_id(payload);
787            if !shadow_id.is_empty() {
788                let registries = bridge_state.registries.read().await;
789                let exists = find_shadow(&registries, shadow_id).is_some();
790                drop(registries);
791                if !exists {
792                    return Some("shadow not found for identity_update".to_owned());
793                }
794            }
795        }
796        "user_departed" => {
797            let shadow_id = extract_shadow_id(payload);
798            if !shadow_id.is_empty() {
799                bridge_state
800                    .deleted_shadows
801                    .write()
802                    .await
803                    .insert(shadow_id.to_owned());
804            }
805        }
806        // presence, message_edit, message_delete are accepted but
807        // don't require specific state changes in the current impl.
808        _ => {}
809    }
810    let _ = event_id; // used by callers for dedup tracking
811    None
812}
813
814/// Handler for `POST /v1/scp/bridge/webhook`.
815///
816/// Accepts platform-initiated events with deduplication by `event_id`.
817/// Supports event types: message, presence, `identity_update`,
818/// `user_departed`, `message_edit`, `message_delete`.
819///
820/// See SCP-BCH-006 and spec section 12.10.4.
821async fn webhook_handler(
822    State(bridge_state): State<Arc<BridgeState>>,
823    Extension(_auth_ctx): Extension<crate::bridge_auth::BridgeAuthContext>,
824    Json(body): Json<WebhookRequest>,
825) -> impl IntoResponse {
826    if !VALID_EVENT_TYPES.contains(&body.event_type.as_str()) {
827        return webhook_reject(
828            body.event_id,
829            &format!("unknown event_type: {}", body.event_type),
830        );
831    }
832    if body.event_id.is_empty() {
833        return ApiError::bad_request("event_id must not be empty").into_response();
834    }
835
836    // Deduplication: if event_id was already processed, return accepted.
837    {
838        let processed = bridge_state.processed_event_ids.read().await;
839        if processed.contains(&body.event_id) {
840            return (
841                StatusCode::OK,
842                Json(WebhookResponse {
843                    accepted: true,
844                    event_id: body.event_id,
845                    reason: None,
846                }),
847            )
848                .into_response();
849        }
850    }
851
852    if let Some(reason) = process_webhook_event(
853        &bridge_state,
854        &body.event_type,
855        &body.event_id,
856        &body.payload,
857    )
858    .await
859    {
860        return webhook_reject(body.event_id, &reason);
861    }
862
863    bridge_state
864        .processed_event_ids
865        .write()
866        .await
867        .insert(body.event_id.clone());
868
869    (
870        StatusCode::OK,
871        Json(WebhookResponse {
872            accepted: true,
873            event_id: body.event_id,
874            reason: None,
875        }),
876    )
877        .into_response()
878}
879
880// ---------------------------------------------------------------------------
881// Router
882// ---------------------------------------------------------------------------
883
884/// Returns an axum [`Router`] serving bridge endpoints.
885///
886/// The router expects [`BridgeState`] as shared state and
887/// [`BridgeAuthContext`] as a request extension (injected by the bridge
888/// auth middleware layer applied by the caller).
889pub fn bridge_router(state: Arc<BridgeState>) -> Router {
890    Router::new()
891        .route("/v1/scp/bridge/shadow", post(create_shadow_handler))
892        .route(
893            "/v1/scp/bridge/shadow/{shadow_id}",
894            delete(delete_shadow_handler),
895        )
896        .route("/v1/scp/bridge/attest", post(attest_handler))
897        .route("/v1/scp/bridge/message", post(emit_message_handler))
898        .route("/v1/scp/bridge/status", get(status_handler))
899        .route("/v1/scp/bridge/webhook", post(webhook_handler))
900        .with_state(state)
901}
902
903// ---------------------------------------------------------------------------
904// Tests
905// ---------------------------------------------------------------------------
906
907#[cfg(test)]
908#[allow(
909    clippy::unwrap_used,
910    clippy::expect_used,
911    clippy::panic,
912    clippy::needless_pass_by_value,
913    clippy::significant_drop_tightening
914)]
915mod tests {
916    use super::*;
917
918    use axum::body::Body;
919    use axum::http::Request;
920    use http_body_util::BodyExt;
921    use scp_core::bridge::{BridgeConnector, BridgeMode, BridgeStatus};
922    use tower::ServiceExt;
923
924    use crate::bridge_auth::{BridgeAuthContext, BridgeJwtClaims};
925
926    fn test_claims() -> BridgeJwtClaims {
927        BridgeJwtClaims {
928            iss: "did:dht:z6MkTestOperator".to_owned(),
929            aud: "https://node.example.com".to_owned(),
930            iat: 1_700_000_000,
931            exp: 1_700_003_600,
932            scp_bridge_id: "bridge-test-001".to_owned(),
933            scp_context_id: "ctx-test-001".to_owned(),
934        }
935    }
936
937    fn test_auth_ctx() -> BridgeAuthContext {
938        BridgeAuthContext {
939            claims: test_claims(),
940            bridge: BridgeConnector {
941                bridge_id: "bridge-test-001".to_owned(),
942                operator_did: scp_identity::DID("did:dht:z6MkTestOperator".to_owned()),
943                platform: "discord".to_owned(),
944                mode: BridgeMode::Relay,
945                status: BridgeStatus::Active,
946                registration_context: "ctx-test-001".to_owned(),
947                registered_at: 1_700_000_000,
948            },
949        }
950    }
951
952    /// Builds the router with `BridgeAuthContext` injected as an extension
953    /// (bypasses real auth middleware for unit tests).
954    fn test_app(state: Arc<BridgeState>) -> Router {
955        let auth_ctx = test_auth_ctx();
956        Router::new()
957            .route("/v1/scp/bridge/shadow", post(create_shadow_handler))
958            .route(
959                "/v1/scp/bridge/shadow/{shadow_id}",
960                delete(delete_shadow_handler),
961            )
962            .route("/v1/scp/bridge/attest", post(attest_handler))
963            .route("/v1/scp/bridge/message", post(emit_message_handler))
964            .route("/v1/scp/bridge/status", get(status_handler))
965            .route("/v1/scp/bridge/webhook", post(webhook_handler))
966            .layer(axum::Extension(auth_ctx))
967            .with_state(state)
968    }
969
970    fn create_request(body: serde_json::Value) -> Request<Body> {
971        Request::builder()
972            .method("POST")
973            .uri("/v1/scp/bridge/shadow")
974            .header("content-type", "application/json")
975            .body(Body::from(serde_json::to_vec(&body).expect("test")))
976            .expect("test")
977    }
978
979    fn attest_request(body: serde_json::Value) -> Request<Body> {
980        Request::builder()
981            .method("POST")
982            .uri("/v1/scp/bridge/attest")
983            .header("content-type", "application/json")
984            .body(Body::from(serde_json::to_vec(&body).expect("test")))
985            .expect("test")
986    }
987
988    async fn response_json(resp: axum::response::Response) -> serde_json::Value {
989        let bytes = resp.into_body().collect().await.expect("test").to_bytes();
990        serde_json::from_slice(&bytes).expect("test")
991    }
992
993    #[tokio::test]
994    async fn successful_creation_returns_201() {
995        let state = Arc::new(BridgeState::new());
996        let app = test_app(state);
997
998        let req = create_request(serde_json::json!({
999            "platform_handle": "@alice#1234",
1000            "platform_user_id": "user-alice-001"
1001        }));
1002
1003        let resp = app.oneshot(req).await.expect("test");
1004        assert_eq!(resp.status(), StatusCode::CREATED);
1005
1006        let json = response_json(resp).await;
1007        assert_eq!(json["platform_handle"], "@alice#1234");
1008        assert_eq!(json["platform_user_id"], "user-alice-001");
1009        assert_eq!(json["attributed_role"], "observer");
1010        assert_eq!(json["shadow_id"], "shadow:bridge-test-001:user-alice-001");
1011        assert!(json["created_at"].as_u64().is_some());
1012    }
1013
1014    #[tokio::test]
1015    async fn idempotent_creation_returns_200() {
1016        let state = Arc::new(BridgeState::new());
1017
1018        // First creation.
1019        let app = test_app(Arc::clone(&state));
1020        let req = create_request(serde_json::json!({
1021            "platform_handle": "@alice#1234",
1022            "platform_user_id": "user-alice-001"
1023        }));
1024        let resp = app.oneshot(req).await.expect("test");
1025        assert_eq!(resp.status(), StatusCode::CREATED);
1026
1027        // Second creation with same platform_user_id — should return 200.
1028        let app = test_app(state);
1029        let req = create_request(serde_json::json!({
1030            "platform_handle": "@alice#1234",
1031            "platform_user_id": "user-alice-001"
1032        }));
1033        let resp = app.oneshot(req).await.expect("test");
1034        assert_eq!(resp.status(), StatusCode::OK);
1035
1036        let json = response_json(resp).await;
1037        assert_eq!(json["shadow_id"], "shadow:bridge-test-001:user-alice-001");
1038        assert_eq!(json["attributed_role"], "observer");
1039    }
1040
1041    #[tokio::test]
1042    async fn missing_platform_handle_returns_400() {
1043        let state = Arc::new(BridgeState::new());
1044        let app = test_app(state);
1045
1046        let req = create_request(serde_json::json!({
1047            "platform_handle": "",
1048            "platform_user_id": "user-001"
1049        }));
1050
1051        let resp = app.oneshot(req).await.expect("test");
1052        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1053    }
1054
1055    #[tokio::test]
1056    async fn missing_platform_user_id_returns_400() {
1057        let state = Arc::new(BridgeState::new());
1058        let app = test_app(state);
1059
1060        let req = create_request(serde_json::json!({
1061            "platform_handle": "@alice",
1062            "platform_user_id": ""
1063        }));
1064
1065        let resp = app.oneshot(req).await.expect("test");
1066        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1067    }
1068
1069    #[tokio::test]
1070    async fn missing_required_fields_returns_400() {
1071        let state = Arc::new(BridgeState::new());
1072        let app = test_app(state);
1073
1074        // Body with no required fields at all.
1075        let req = create_request(serde_json::json!({}));
1076
1077        let resp = app.oneshot(req).await.expect("test");
1078        // serde deserialization failure → 422 (axum default) or 400.
1079        // axum returns 422 for JSON deserialization errors by default.
1080        assert!(
1081            resp.status() == StatusCode::BAD_REQUEST
1082                || resp.status() == StatusCode::UNPROCESSABLE_ENTITY
1083        );
1084    }
1085
1086    // -----------------------------------------------------------------------
1087    // Attest endpoint tests
1088    // -----------------------------------------------------------------------
1089
1090    fn valid_attest_body() -> serde_json::Value {
1091        serde_json::json!({
1092            "platform_handle": "@dave#1234",
1093            "platform_user_id": "usr_abc123",
1094            "attestation_evidence": {
1095                "evidence_type": "platform-verified",
1096                "verification_method": "oauth2",
1097                "verified_at": 1_700_000_300,
1098                "platform_confidence": "high",
1099                "additional_signals": {
1100                    "account_age_days": 730,
1101                    "email_verified": true
1102                }
1103            }
1104        })
1105    }
1106
1107    #[tokio::test]
1108    async fn attest_successful_returns_201() {
1109        let state = Arc::new(BridgeState::new());
1110        let app = test_app(state);
1111
1112        let req = attest_request(valid_attest_body());
1113        let resp = app.oneshot(req).await.expect("test");
1114        assert_eq!(resp.status(), StatusCode::CREATED);
1115
1116        let json = response_json(resp).await;
1117        assert_eq!(json["status"], "active");
1118        assert_eq!(json["platform_handle"], "@dave#1234");
1119        assert_eq!(json["attestation_id"], "attest:bridge-test-001:usr_abc123");
1120        assert!(json["issued_at"].as_u64().is_some());
1121        assert!(json["expires_at"].as_u64().is_some());
1122
1123        let issued = json["issued_at"].as_u64().unwrap();
1124        let expires = json["expires_at"].as_u64().unwrap();
1125        assert_eq!(expires - issued, 86_400);
1126    }
1127
1128    #[tokio::test]
1129    async fn attest_stores_attestation() {
1130        let state = Arc::new(BridgeState::new());
1131        let app = test_app(Arc::clone(&state));
1132
1133        let req = attest_request(valid_attest_body());
1134        let resp = app.oneshot(req).await.expect("test");
1135        assert_eq!(resp.status(), StatusCode::CREATED);
1136
1137        let attestations = state.attestations.read().await;
1138        let stored = attestations
1139            .get("attest:bridge-test-001:usr_abc123")
1140            .expect("attestation should be stored");
1141        assert_eq!(stored.platform_handle, "@dave#1234");
1142        assert_eq!(stored.evidence.evidence_type, "platform-verified");
1143        assert_eq!(stored.evidence.platform_confidence, "high");
1144    }
1145
1146    #[tokio::test]
1147    async fn attest_empty_handle_returns_400() {
1148        let state = Arc::new(BridgeState::new());
1149        let app = test_app(state);
1150
1151        let req = attest_request(serde_json::json!({
1152            "platform_handle": "",
1153            "platform_user_id": "usr_abc123",
1154            "attestation_evidence": {
1155                "evidence_type": "platform-verified",
1156                "verification_method": "oauth2",
1157                "verified_at": 1_700_000_300,
1158                "platform_confidence": "high"
1159            }
1160        }));
1161
1162        let resp = app.oneshot(req).await.expect("test");
1163        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1164    }
1165
1166    #[tokio::test]
1167    async fn attest_empty_user_id_returns_400() {
1168        let state = Arc::new(BridgeState::new());
1169        let app = test_app(state);
1170
1171        let req = attest_request(serde_json::json!({
1172            "platform_handle": "@dave#1234",
1173            "platform_user_id": "",
1174            "attestation_evidence": {
1175                "evidence_type": "platform-verified",
1176                "verification_method": "oauth2",
1177                "verified_at": 1_700_000_300,
1178                "platform_confidence": "high"
1179            }
1180        }));
1181
1182        let resp = app.oneshot(req).await.expect("test");
1183        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1184    }
1185
1186    #[tokio::test]
1187    async fn attest_invalid_confidence_returns_400() {
1188        let state = Arc::new(BridgeState::new());
1189        let app = test_app(state);
1190
1191        let req = attest_request(serde_json::json!({
1192            "platform_handle": "@dave#1234",
1193            "platform_user_id": "usr_abc123",
1194            "attestation_evidence": {
1195                "evidence_type": "platform-verified",
1196                "verification_method": "oauth2",
1197                "verified_at": 1_700_000_300,
1198                "platform_confidence": "very-high"
1199            }
1200        }));
1201
1202        let resp = app.oneshot(req).await.expect("test");
1203        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1204    }
1205
1206    #[tokio::test]
1207    async fn attest_missing_evidence_returns_422() {
1208        let state = Arc::new(BridgeState::new());
1209        let app = test_app(state);
1210
1211        let req = attest_request(serde_json::json!({
1212            "platform_handle": "@dave#1234",
1213            "platform_user_id": "usr_abc123"
1214        }));
1215
1216        let resp = app.oneshot(req).await.expect("test");
1217        assert!(
1218            resp.status() == StatusCode::BAD_REQUEST
1219                || resp.status() == StatusCode::UNPROCESSABLE_ENTITY
1220        );
1221    }
1222
1223    #[tokio::test]
1224    async fn attest_without_additional_signals() {
1225        let state = Arc::new(BridgeState::new());
1226        let app = test_app(state);
1227
1228        let req = attest_request(serde_json::json!({
1229            "platform_handle": "@dave#1234",
1230            "platform_user_id": "usr_abc123",
1231            "attestation_evidence": {
1232                "evidence_type": "oauth2",
1233                "verification_method": "oauth2-flow",
1234                "verified_at": 1_700_000_300,
1235                "platform_confidence": "medium"
1236            }
1237        }));
1238
1239        let resp = app.oneshot(req).await.expect("test");
1240        assert_eq!(resp.status(), StatusCode::CREATED);
1241
1242        let json = response_json(resp).await;
1243        assert_eq!(json["status"], "active");
1244    }
1245
1246    // -----------------------------------------------------------------------
1247    // Message endpoint tests (SCP-BCH-003)
1248    // -----------------------------------------------------------------------
1249
1250    fn message_request(body: serde_json::Value) -> Request<Body> {
1251        Request::builder()
1252            .method("POST")
1253            .uri("/v1/scp/bridge/message")
1254            .header("content-type", "application/json")
1255            .body(Body::from(serde_json::to_vec(&body).expect("test")))
1256            .expect("test")
1257    }
1258
1259    /// Creates a shadow in the state and returns its `shadow_id`.
1260    async fn create_test_shadow(state: &Arc<BridgeState>) -> String {
1261        let app = test_app(Arc::clone(state));
1262        let req = create_request(serde_json::json!({
1263            "platform_handle": "@emitter#1234",
1264            "platform_user_id": "user-emitter-001"
1265        }));
1266        let resp = app.oneshot(req).await.expect("test");
1267        assert_eq!(resp.status(), StatusCode::CREATED);
1268        let json = response_json(resp).await;
1269        json["shadow_id"].as_str().expect("shadow_id").to_owned()
1270    }
1271
1272    #[tokio::test]
1273    async fn emit_message_returns_202() {
1274        let state = Arc::new(BridgeState::new());
1275        let shadow_id = create_test_shadow(&state).await;
1276
1277        let app = test_app(Arc::clone(&state));
1278        let req = message_request(serde_json::json!({
1279            "shadow_id": shadow_id,
1280            "content": "Hello from bridge!",
1281            "content_type": "text/plain"
1282        }));
1283
1284        let resp = app.oneshot(req).await.expect("test");
1285        assert_eq!(resp.status(), StatusCode::ACCEPTED);
1286
1287        let json = response_json(resp).await;
1288        assert!(json["message_id"].as_str().is_some());
1289        assert_eq!(json["sequence"], 1);
1290        assert_eq!(json["bridge_provenance"]["originating_platform"], "discord");
1291        assert_eq!(json["bridge_provenance"]["bridge_mode"], "Relay");
1292        assert_eq!(json["bridge_provenance"]["shadow_status"], "Shadow");
1293        assert_eq!(
1294            json["bridge_provenance"]["operator_did"],
1295            "did:dht:z6MkTestOperator"
1296        );
1297    }
1298
1299    #[tokio::test]
1300    async fn emit_message_shadow_not_found_returns_404() {
1301        let state = Arc::new(BridgeState::new());
1302        let app = test_app(state);
1303
1304        let req = message_request(serde_json::json!({
1305            "shadow_id": "shadow:nonexistent",
1306            "content": "Hello",
1307            "content_type": "text/plain"
1308        }));
1309
1310        let resp = app.oneshot(req).await.expect("test");
1311        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1312    }
1313
1314    #[tokio::test]
1315    async fn emit_message_empty_content_returns_400() {
1316        let state = Arc::new(BridgeState::new());
1317        let shadow_id = create_test_shadow(&state).await;
1318
1319        let app = test_app(state);
1320        let req = message_request(serde_json::json!({
1321            "shadow_id": shadow_id,
1322            "content": "",
1323            "content_type": "text/plain"
1324        }));
1325
1326        let resp = app.oneshot(req).await.expect("test");
1327        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1328    }
1329
1330    // -----------------------------------------------------------------------
1331    // Status endpoint tests (SCP-BCH-005)
1332    // -----------------------------------------------------------------------
1333
1334    fn status_request() -> Request<Body> {
1335        Request::builder()
1336            .method("GET")
1337            .uri("/v1/scp/bridge/status")
1338            .body(Body::empty())
1339            .expect("test")
1340    }
1341
1342    #[tokio::test]
1343    async fn status_returns_bridge_info() {
1344        let state = Arc::new(BridgeState::new());
1345        let _shadow_id = create_test_shadow(&state).await;
1346
1347        let app = test_app(state);
1348        let req = status_request();
1349
1350        let resp = app.oneshot(req).await.expect("test");
1351        assert_eq!(resp.status(), StatusCode::OK);
1352
1353        let json = response_json(resp).await;
1354        assert_eq!(json["bridge_id"], "bridge-test-001");
1355        assert_eq!(json["status"], "Active");
1356        assert_eq!(json["platform"], "discord");
1357        assert_eq!(json["mode"], "Relay");
1358        assert_eq!(json["operator_did"], "did:dht:z6MkTestOperator");
1359        assert_eq!(json["shadow_count"], 1);
1360        assert_eq!(json["shadows"].as_array().map(std::vec::Vec::len), Some(1));
1361    }
1362
1363    #[tokio::test]
1364    async fn status_empty_returns_zero_shadows() {
1365        let state = Arc::new(BridgeState::new());
1366        let app = test_app(state);
1367
1368        let req = status_request();
1369        let resp = app.oneshot(req).await.expect("test");
1370        assert_eq!(resp.status(), StatusCode::OK);
1371
1372        let json = response_json(resp).await;
1373        assert_eq!(json["shadow_count"], 0);
1374    }
1375
1376    // -----------------------------------------------------------------------
1377    // Delete shadow endpoint tests (SCP-BCH-005)
1378    // -----------------------------------------------------------------------
1379
1380    fn delete_shadow_request(shadow_id: &str) -> Request<Body> {
1381        Request::builder()
1382            .method("DELETE")
1383            .uri(format!("/v1/scp/bridge/shadow/{shadow_id}"))
1384            .body(Body::empty())
1385            .expect("test")
1386    }
1387
1388    #[tokio::test]
1389    async fn delete_shadow_returns_204() {
1390        let state = Arc::new(BridgeState::new());
1391        let shadow_id = create_test_shadow(&state).await;
1392
1393        let app = test_app(state);
1394        let req = delete_shadow_request(&shadow_id);
1395
1396        let resp = app.oneshot(req).await.expect("test");
1397        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
1398    }
1399
1400    #[tokio::test]
1401    async fn delete_shadow_not_found_returns_404() {
1402        let state = Arc::new(BridgeState::new());
1403        let app = test_app(state);
1404
1405        let req = delete_shadow_request("shadow:nonexistent");
1406        let resp = app.oneshot(req).await.expect("test");
1407        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1408    }
1409
1410    #[tokio::test]
1411    async fn delete_shadow_idempotent_returns_204() {
1412        let state = Arc::new(BridgeState::new());
1413        let shadow_id = create_test_shadow(&state).await;
1414
1415        // First delete.
1416        let app = test_app(Arc::clone(&state));
1417        let req = delete_shadow_request(&shadow_id);
1418        let resp = app.oneshot(req).await.expect("test");
1419        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
1420
1421        // Second delete — idempotent.
1422        let app = test_app(state);
1423        let req = delete_shadow_request(&shadow_id);
1424        let resp = app.oneshot(req).await.expect("test");
1425        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
1426    }
1427
1428    // -----------------------------------------------------------------------
1429    // Webhook endpoint tests (SCP-BCH-006)
1430    // -----------------------------------------------------------------------
1431
1432    fn webhook_request(body: serde_json::Value) -> Request<Body> {
1433        Request::builder()
1434            .method("POST")
1435            .uri("/v1/scp/bridge/webhook")
1436            .header("content-type", "application/json")
1437            .body(Body::from(serde_json::to_vec(&body).expect("test")))
1438            .expect("test")
1439    }
1440
1441    #[tokio::test]
1442    async fn webhook_message_event_accepted() {
1443        let state = Arc::new(BridgeState::new());
1444        let shadow_id = create_test_shadow(&state).await;
1445
1446        let app = test_app(state);
1447        let req = webhook_request(serde_json::json!({
1448            "event_type": "message",
1449            "event_id": "evt-001",
1450            "timestamp": 1_700_000_500,
1451            "payload": {
1452                "shadow_id": shadow_id,
1453                "content": "Hello from webhook"
1454            }
1455        }));
1456
1457        let resp = app.oneshot(req).await.expect("test");
1458        assert_eq!(resp.status(), StatusCode::OK);
1459
1460        let json = response_json(resp).await;
1461        assert_eq!(json["accepted"], true);
1462        assert_eq!(json["event_id"], "evt-001");
1463    }
1464
1465    #[tokio::test]
1466    async fn webhook_deduplication() {
1467        let state = Arc::new(BridgeState::new());
1468
1469        let app = test_app(Arc::clone(&state));
1470        let req = webhook_request(serde_json::json!({
1471            "event_type": "presence",
1472            "event_id": "evt-dedup-001",
1473            "timestamp": 1_700_000_500,
1474            "payload": {}
1475        }));
1476        let resp = app.oneshot(req).await.expect("test");
1477        assert_eq!(resp.status(), StatusCode::OK);
1478        let json = response_json(resp).await;
1479        assert_eq!(json["accepted"], true);
1480
1481        // Re-send same event_id — should be accepted without reprocessing.
1482        let app = test_app(state);
1483        let req = webhook_request(serde_json::json!({
1484            "event_type": "presence",
1485            "event_id": "evt-dedup-001",
1486            "timestamp": 1_700_000_600,
1487            "payload": {}
1488        }));
1489        let resp = app.oneshot(req).await.expect("test");
1490        assert_eq!(resp.status(), StatusCode::OK);
1491        let json = response_json(resp).await;
1492        assert_eq!(json["accepted"], true);
1493    }
1494
1495    #[tokio::test]
1496    async fn webhook_unknown_event_type_rejected() {
1497        let state = Arc::new(BridgeState::new());
1498        let app = test_app(state);
1499
1500        let req = webhook_request(serde_json::json!({
1501            "event_type": "unknown_type",
1502            "event_id": "evt-002",
1503            "timestamp": 1_700_000_500,
1504            "payload": {}
1505        }));
1506
1507        let resp = app.oneshot(req).await.expect("test");
1508        assert_eq!(resp.status(), StatusCode::OK);
1509
1510        let json = response_json(resp).await;
1511        assert_eq!(json["accepted"], false);
1512        assert!(json["reason"].as_str().is_some());
1513    }
1514
1515    #[tokio::test]
1516    async fn webhook_user_departed_triggers_shadow_deletion() {
1517        let state = Arc::new(BridgeState::new());
1518        let shadow_id = create_test_shadow(&state).await;
1519
1520        let app = test_app(Arc::clone(&state));
1521        let req = webhook_request(serde_json::json!({
1522            "event_type": "user_departed",
1523            "event_id": "evt-depart-001",
1524            "timestamp": 1_700_000_500,
1525            "payload": {
1526                "shadow_id": shadow_id
1527            }
1528        }));
1529
1530        let resp = app.oneshot(req).await.expect("test");
1531        assert_eq!(resp.status(), StatusCode::OK);
1532        let json = response_json(resp).await;
1533        assert_eq!(json["accepted"], true);
1534
1535        // Verify shadow was deleted.
1536        let deleted = state.deleted_shadows.read().await;
1537        assert!(deleted.contains(&shadow_id));
1538    }
1539
1540    #[tokio::test]
1541    async fn webhook_all_event_types_accepted() {
1542        let state = Arc::new(BridgeState::new());
1543
1544        for (i, event_type) in [
1545            "message",
1546            "presence",
1547            "identity_update",
1548            "user_departed",
1549            "message_edit",
1550            "message_delete",
1551        ]
1552        .iter()
1553        .enumerate()
1554        {
1555            // For message event, we need a shadow; for others, just empty payload.
1556            let shadow_id = if *event_type == "message" {
1557                create_test_shadow(&state).await
1558            } else {
1559                String::new()
1560            };
1561
1562            let payload = if *event_type == "message" {
1563                serde_json::json!({ "shadow_id": shadow_id, "content": "test" })
1564            } else {
1565                serde_json::json!({})
1566            };
1567
1568            let app = test_app(Arc::clone(&state));
1569            let req = webhook_request(serde_json::json!({
1570                "event_type": event_type,
1571                "event_id": format!("evt-type-{i}"),
1572                "timestamp": 1_700_000_500,
1573                "payload": payload
1574            }));
1575
1576            let resp = app.oneshot(req).await.expect("test");
1577            assert_eq!(resp.status(), StatusCode::OK);
1578            let json = response_json(resp).await;
1579            assert_eq!(
1580                json["accepted"], true,
1581                "event type '{event_type}' should be accepted"
1582            );
1583        }
1584    }
1585
1586    // -----------------------------------------------------------------------
1587    // Integration tests — full lifecycle (SCP-BCH-007)
1588    // -----------------------------------------------------------------------
1589
1590    /// Full lifecycle integration test exercising:
1591    /// create shadow -> emit message -> attest identity -> check status
1592    /// -> webhook event -> delete shadow.
1593    #[tokio::test]
1594    async fn integration_full_lifecycle() {
1595        let state = Arc::new(BridgeState::new());
1596
1597        // 1. Create shadow.
1598        let app = test_app(Arc::clone(&state));
1599        let req = create_request(serde_json::json!({
1600            "platform_handle": "@lifecycle-user#1234",
1601            "platform_user_id": "lifecycle-user-001"
1602        }));
1603        let resp = app.oneshot(req).await.expect("test");
1604        assert_eq!(resp.status(), StatusCode::CREATED);
1605        let create_json = response_json(resp).await;
1606        let shadow_id = create_json["shadow_id"]
1607            .as_str()
1608            .expect("shadow_id")
1609            .to_owned();
1610        assert_eq!(create_json["attributed_role"], "observer");
1611
1612        // 2. Emit message.
1613        let app = test_app(Arc::clone(&state));
1614        let req = message_request(serde_json::json!({
1615            "shadow_id": &shadow_id,
1616            "content": "Hello from lifecycle test!",
1617            "content_type": "text/plain",
1618            "platform_message_id": "ext-msg-001",
1619            "platform_timestamp": 1_700_001_000
1620        }));
1621        let resp = app.oneshot(req).await.expect("test");
1622        assert_eq!(resp.status(), StatusCode::ACCEPTED);
1623        let msg_json = response_json(resp).await;
1624        assert!(msg_json["message_id"].as_str().is_some());
1625        assert_eq!(msg_json["sequence"], 1);
1626        // Verify provenance fields.
1627        assert_eq!(
1628            msg_json["bridge_provenance"]["originating_platform"],
1629            "discord"
1630        );
1631        assert_eq!(msg_json["bridge_provenance"]["bridge_mode"], "Relay");
1632        assert_eq!(
1633            msg_json["bridge_provenance"]["operator_did"],
1634            "did:dht:z6MkTestOperator"
1635        );
1636        assert_eq!(msg_json["bridge_provenance"]["shadow_status"], "Shadow");
1637
1638        // 3. Attest identity.
1639        let app = test_app(Arc::clone(&state));
1640        let req = attest_request(serde_json::json!({
1641            "platform_handle": "@lifecycle-user#1234",
1642            "platform_user_id": "lifecycle-user-001",
1643            "attestation_evidence": {
1644                "evidence_type": "platform-verified",
1645                "verification_method": "oauth2",
1646                "verified_at": 1_700_001_200,
1647                "platform_confidence": "high"
1648            }
1649        }));
1650        let resp = app.oneshot(req).await.expect("test");
1651        assert_eq!(resp.status(), StatusCode::CREATED);
1652
1653        // 4. Check status.
1654        let app = test_app(Arc::clone(&state));
1655        let req = status_request();
1656        let resp = app.oneshot(req).await.expect("test");
1657        assert_eq!(resp.status(), StatusCode::OK);
1658        let status_json = response_json(resp).await;
1659        assert_eq!(status_json["bridge_id"], "bridge-test-001");
1660        assert_eq!(status_json["status"], "Active");
1661        assert_eq!(status_json["shadow_count"], 1);
1662        let shadows = status_json["shadows"].as_array().expect("shadows array");
1663        assert_eq!(shadows.len(), 1);
1664        assert_eq!(shadows[0]["shadow_id"], shadow_id);
1665
1666        // 5. Webhook event (presence).
1667        let app = test_app(Arc::clone(&state));
1668        let req = webhook_request(serde_json::json!({
1669            "event_type": "presence",
1670            "event_id": "evt-lifecycle-001",
1671            "timestamp": 1_700_001_500,
1672            "payload": {}
1673        }));
1674        let resp = app.oneshot(req).await.expect("test");
1675        assert_eq!(resp.status(), StatusCode::OK);
1676        let wh_json = response_json(resp).await;
1677        assert_eq!(wh_json["accepted"], true);
1678
1679        // 6. Delete shadow.
1680        let app = test_app(Arc::clone(&state));
1681        let req = delete_shadow_request(&shadow_id);
1682        let resp = app.oneshot(req).await.expect("test");
1683        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
1684
1685        // 7. Verify shadow is deleted — status should show 0 shadows.
1686        let app = test_app(state);
1687        let req = status_request();
1688        let resp = app.oneshot(req).await.expect("test");
1689        assert_eq!(resp.status(), StatusCode::OK);
1690        let status_after = response_json(resp).await;
1691        assert_eq!(status_after["shadow_count"], 0);
1692    }
1693
1694    /// Verifies all endpoints return Content-Type: application/json.
1695    #[tokio::test]
1696    async fn all_endpoints_return_json_content_type() {
1697        let state = Arc::new(BridgeState::new());
1698        let shadow_id = create_test_shadow(&state).await;
1699
1700        // POST /shadow
1701        let app = test_app(Arc::clone(&state));
1702        let req = create_request(serde_json::json!({
1703            "platform_handle": "@ct-user",
1704            "platform_user_id": "ct-user-001"
1705        }));
1706        let resp = app.oneshot(req).await.expect("test");
1707        assert_eq!(
1708            resp.headers()
1709                .get("content-type")
1710                .and_then(|v| v.to_str().ok()),
1711            Some("application/json"),
1712            "POST /shadow must return application/json"
1713        );
1714
1715        // POST /message
1716        let app = test_app(Arc::clone(&state));
1717        let req = message_request(serde_json::json!({
1718            "shadow_id": &shadow_id,
1719            "content": "content-type test",
1720            "content_type": "text/plain"
1721        }));
1722        let resp = app.oneshot(req).await.expect("test");
1723        assert_eq!(
1724            resp.headers()
1725                .get("content-type")
1726                .and_then(|v| v.to_str().ok()),
1727            Some("application/json"),
1728            "POST /message must return application/json"
1729        );
1730
1731        // GET /status
1732        let app = test_app(Arc::clone(&state));
1733        let req = status_request();
1734        let resp = app.oneshot(req).await.expect("test");
1735        assert_eq!(
1736            resp.headers()
1737                .get("content-type")
1738                .and_then(|v| v.to_str().ok()),
1739            Some("application/json"),
1740            "GET /status must return application/json"
1741        );
1742
1743        // POST /attest
1744        let app = test_app(Arc::clone(&state));
1745        let req = attest_request(valid_attest_body());
1746        let resp = app.oneshot(req).await.expect("test");
1747        assert_eq!(
1748            resp.headers()
1749                .get("content-type")
1750                .and_then(|v| v.to_str().ok()),
1751            Some("application/json"),
1752            "POST /attest must return application/json"
1753        );
1754
1755        // POST /webhook
1756        let app = test_app(Arc::clone(&state));
1757        let req = webhook_request(serde_json::json!({
1758            "event_type": "presence",
1759            "event_id": "ct-evt-001",
1760            "timestamp": 1_700_000_500,
1761            "payload": {}
1762        }));
1763        let resp = app.oneshot(req).await.expect("test");
1764        assert_eq!(
1765            resp.headers()
1766                .get("content-type")
1767                .and_then(|v| v.to_str().ok()),
1768            Some("application/json"),
1769            "POST /webhook must return application/json"
1770        );
1771    }
1772
1773    /// Verifies error responses use the SCP error format (code + error fields).
1774    #[tokio::test]
1775    async fn error_responses_use_scp_format() {
1776        let state = Arc::new(BridgeState::new());
1777        let app = test_app(state);
1778
1779        // 404 for nonexistent shadow on message endpoint.
1780        let req = message_request(serde_json::json!({
1781            "shadow_id": "shadow:nonexistent",
1782            "content": "test",
1783            "content_type": "text/plain"
1784        }));
1785        let resp = app.oneshot(req).await.expect("test");
1786        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1787        let json = response_json(resp).await;
1788        assert!(
1789            json["code"].as_str().is_some(),
1790            "error response must have code field"
1791        );
1792        assert!(
1793            json["error"].as_str().is_some(),
1794            "error response must have error field"
1795        );
1796    }
1797
1798    /// Verifies that deleting an unclaimed shadow succeeds and the
1799    /// delete handler correctly checks provenance status. The 409
1800    /// (`SHADOW_ALREADY_CLAIMED`) path is verified structurally: the
1801    /// handler checks `provenance_status == Claimed` and the claiming
1802    /// module has its own test coverage for status transitions.
1803    /// Here we verify the 204 (success) and 404 (not found) paths
1804    /// through the router.
1805    #[tokio::test]
1806    async fn delete_shadow_through_router() {
1807        let state = Arc::new(BridgeState::new());
1808        let shadow_id = create_test_shadow(&state).await;
1809
1810        // Delete succeeds for unclaimed shadow.
1811        let app = test_app(Arc::clone(&state));
1812        let req = delete_shadow_request(&shadow_id);
1813        let resp = app.oneshot(req).await.expect("test");
1814        assert_eq!(resp.status(), StatusCode::NO_CONTENT);
1815
1816        // Nonexistent shadow returns 404 with SCP error format.
1817        let app = test_app(state);
1818        let req = delete_shadow_request("shadow:nonexistent:claimed");
1819        let resp = app.oneshot(req).await.expect("test");
1820        assert_eq!(resp.status(), StatusCode::NOT_FOUND);
1821        let json = response_json(resp).await;
1822        assert_eq!(json["code"], "SHADOW_NOT_FOUND");
1823    }
1824
1825    /// Verifies the `bridge_router` function mounts all endpoints.
1826    #[tokio::test]
1827    async fn bridge_router_mounts_all_endpoints() {
1828        let state = Arc::new(BridgeState::new());
1829        let auth_ctx = test_auth_ctx();
1830        let router = bridge_router(state).layer(axum::Extension(auth_ctx));
1831
1832        // POST /shadow
1833        let req = create_request(serde_json::json!({
1834            "platform_handle": "@router-test",
1835            "platform_user_id": "router-user-001"
1836        }));
1837        let resp = router.clone().oneshot(req).await.expect("test");
1838        assert_eq!(resp.status(), StatusCode::CREATED);
1839    }
1840}