Skip to main content

nako_addon_client/
lib.rs

1use std::time::Duration;
2
3use async_trait::async_trait;
4use nako_addon_protocol::{
5    ADDON_RUNTIME_ACCESS_CHECK_PATH, ADDON_RUNTIME_SIDE_EFFECTS_PATH, AddonAccessCheckRequest,
6    AddonAccessCheckResponse, AddonAuth, AddonEventRequest, AddonEventResponse,
7    AddonHealthCheckRequest, AddonHealthCheckResponse, AddonManifest, AddonManifestError,
8    AddonPermission, AddonResource, AddonResourceRequest, AddonResourceResponse, AddonScope,
9    AddonSideEffectResponse, AddonSideEffectTargetKind, AddonTaskRequest, AddonTaskResponse,
10    SubmitAddonArtworkWriteRequest, SubmitAddonMetadataWriteRequest, SubmitAddonSideEffectRequest,
11    ensure_event_subscription_scope_grant, ensure_scope_grant, ensure_task_scope_grant,
12    validate_event_response, validate_health_check_response, validate_manifest,
13    validate_resource_response, validate_task_response,
14};
15
16#[derive(Clone, Debug, Eq, PartialEq)]
17pub struct AddonHttpRequest {
18    pub url: String,
19    pub headers: Vec<(String, String)>,
20    pub body: String,
21    pub timeout_ms: u64,
22}
23
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct AddonHttpResponse {
26    pub status: u16,
27    pub body: String,
28}
29
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub enum AddonClientError {
32    Protocol(AddonManifestError),
33    InvalidRequest { message: String },
34    InvalidResponse { message: String },
35    UnsafeRequestBody,
36    HttpStatus { status: u16, retryable: bool },
37    Http { message: String },
38}
39
40impl std::fmt::Display for AddonClientError {
41    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            Self::Protocol(err) => write!(formatter, "{err}"),
44            Self::InvalidRequest { message } => {
45                write!(formatter, "addon client invalid request: {message}")
46            }
47            Self::InvalidResponse { message } => {
48                write!(formatter, "addon client invalid response: {message}")
49            }
50            Self::UnsafeRequestBody => {
51                write!(
52                    formatter,
53                    "addon client request body contained token material"
54                )
55            }
56            Self::HttpStatus { status, .. } => write!(formatter, "addon returned HTTP {status}"),
57            Self::Http { message } => write!(formatter, "addon HTTP call failed: {message}"),
58        }
59    }
60}
61
62impl std::error::Error for AddonClientError {}
63
64impl From<AddonManifestError> for AddonClientError {
65    fn from(value: AddonManifestError) -> Self {
66        Self::Protocol(value)
67    }
68}
69
70pub type AddonClientResult<T> = std::result::Result<T, AddonClientError>;
71
72#[derive(Clone, Debug, Eq, PartialEq)]
73pub struct AddonResourceCallOutcome {
74    pub response: AddonResourceResponse,
75    pub http_status: u16,
76    pub attempts: u32,
77}
78
79#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct AddonResourceCallFailure {
81    pub error: AddonClientError,
82    pub attempts: u32,
83}
84
85#[derive(Clone, Debug, Eq, PartialEq)]
86pub struct AddonTaskCallRequest {
87    pub task_id: String,
88    pub job_id: String,
89    pub request_id: String,
90    pub attempt: u32,
91    pub retry_of_job_id: Option<String>,
92    pub library_id: Option<String>,
93    pub source_id: Option<String>,
94    pub payload: serde_json::Value,
95}
96
97#[derive(Clone, Debug, Eq, PartialEq)]
98pub struct AddonTaskCallOutcome {
99    pub response: AddonTaskResponse,
100    pub http_status: u16,
101    pub attempts: u32,
102}
103
104#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct AddonTaskCallFailure {
106    pub error: AddonClientError,
107    pub attempts: u32,
108}
109
110#[derive(Clone, Debug, Eq, PartialEq)]
111pub struct AddonEventCallRequest {
112    pub subscription_id: String,
113    pub event_id: String,
114    pub event_kind: String,
115    pub subject_kind: String,
116    pub subject_id: String,
117    pub occurred_at: String,
118    pub attempt: u32,
119    pub payload: serde_json::Value,
120}
121
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct AddonEventCallOutcome {
124    pub response: AddonEventResponse,
125    pub http_status: u16,
126    pub attempts: u32,
127}
128
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct AddonEventCallFailure {
131    pub error: AddonClientError,
132    pub attempts: u32,
133}
134
135#[derive(Clone, Debug, Eq, PartialEq)]
136pub struct NakoRuntimeClientConfig {
137    pub base_url: String,
138    pub addon_token: String,
139    pub timeout_ms: u64,
140}
141
142#[derive(Clone, Debug)]
143pub struct NakoRuntimeClient<T = ReqwestAddonTransport> {
144    config: NakoRuntimeClientConfig,
145    transport: T,
146}
147
148#[async_trait]
149pub trait AddonTransport: Send + Sync {
150    async fn post(&self, request: AddonHttpRequest) -> AddonClientResult<AddonHttpResponse>;
151}
152
153#[derive(Clone, Debug)]
154pub struct ReqwestAddonTransport {
155    client: reqwest::Client,
156}
157
158impl Default for ReqwestAddonTransport {
159    fn default() -> Self {
160        Self {
161            client: reqwest::Client::new(),
162        }
163    }
164}
165
166impl ReqwestAddonTransport {
167    #[must_use]
168    pub fn new(client: reqwest::Client) -> Self {
169        Self { client }
170    }
171}
172
173#[async_trait]
174impl AddonTransport for ReqwestAddonTransport {
175    async fn post(&self, request: AddonHttpRequest) -> AddonClientResult<AddonHttpResponse> {
176        let mut builder = self
177            .client
178            .post(&request.url)
179            .timeout(Duration::from_millis(request.timeout_ms))
180            .body(request.body);
181
182        for (name, value) in request.headers {
183            builder = builder.header(name, value);
184        }
185
186        let response = builder.send().await.map_err(addon_http_error)?;
187        let status = response.status().as_u16();
188        let body = response.text().await.map_err(addon_http_error)?;
189
190        Ok(AddonHttpResponse { status, body })
191    }
192}
193
194pub async fn call_addon_resource<T>(
195    transport: &T,
196    manifest: &AddonManifest,
197    resource: AddonResource,
198    granted_scopes: &[AddonScope],
199    request_id: impl Into<String>,
200    payload: serde_json::Value,
201    bearer_token: Option<&str>,
202) -> AddonClientResult<AddonResourceResponse>
203where
204    T: AddonTransport,
205{
206    call_addon_resource_with_outcome(
207        transport,
208        manifest,
209        resource,
210        granted_scopes,
211        request_id,
212        payload,
213        bearer_token,
214    )
215    .await
216    .map(|outcome| outcome.response)
217    .map_err(|failure| failure.error)
218}
219
220pub async fn call_addon_resource_with_outcome<T>(
221    transport: &T,
222    manifest: &AddonManifest,
223    resource: AddonResource,
224    granted_scopes: &[AddonScope],
225    request_id: impl Into<String>,
226    payload: serde_json::Value,
227    bearer_token: Option<&str>,
228) -> Result<AddonResourceCallOutcome, AddonResourceCallFailure>
229where
230    T: AddonTransport,
231{
232    validate_manifest(manifest).map_err(resource_call_setup_failure)?;
233    ensure_scope_grant(manifest, resource, granted_scopes).map_err(resource_call_setup_failure)?;
234    let declaration = manifest
235        .resources
236        .iter()
237        .find(|candidate| candidate.kind == resource)
238        .ok_or(AddonManifestError::ResourceNotDeclared { resource })
239        .map_err(resource_call_setup_failure)?;
240    let request_id = request_id.into();
241    let timeout_ms = declaration
242        .timeout_ms
243        .or(manifest.default_timeout_ms)
244        .unwrap_or(10_000);
245    let max_attempts = declaration
246        .max_attempts
247        .or(manifest.default_max_attempts)
248        .unwrap_or(1);
249    let protocol_version = manifest.protocol_version.clone();
250    let envelope = AddonResourceRequest {
251        protocol_version: protocol_version.clone(),
252        addon_id: manifest.id.clone(),
253        resource,
254        request_id: request_id.clone(),
255        payload,
256    };
257    let body = serde_json::to_string(&envelope)
258        .map_err(|err| AddonManifestError::InvalidEnvelope {
259            message: format!("failed to serialize addon request: {err}"),
260        })
261        .map_err(resource_call_setup_failure)?;
262    let mut headers = vec![
263        ("content-type".to_owned(), "application/json".to_owned()),
264        ("x-nako-addon-protocol-version".to_owned(), protocol_version),
265        ("x-nako-addon-id".to_owned(), manifest.id.clone()),
266        (
267            "x-nako-addon-resource".to_owned(),
268            resource.as_str().to_owned(),
269        ),
270        ("x-nako-request-id".to_owned(), request_id.clone()),
271    ];
272    match manifest.auth {
273        AddonAuth::None => {}
274        AddonAuth::Bearer => {
275            let token = bearer_token
276                .ok_or(AddonManifestError::MissingAuthToken {
277                    auth: AddonAuth::Bearer,
278                })
279                .map_err(resource_call_setup_failure)?;
280            headers.push(("authorization".to_owned(), format!("Bearer {token}")));
281        }
282        AddonAuth::SharedSecret => {
283            let token = bearer_token
284                .ok_or(AddonManifestError::MissingAuthToken {
285                    auth: AddonAuth::SharedSecret,
286                })
287                .map_err(resource_call_setup_failure)?;
288            headers.push(("x-nako-addon-secret".to_owned(), token.to_owned()));
289        }
290    }
291
292    let mut last_error = None;
293    for attempt in 1..=max_attempts {
294        let mut attempt_headers = headers.clone();
295        attempt_headers.push(("x-nako-attempt".to_owned(), attempt.to_string()));
296        let response = transport
297            .post(AddonHttpRequest {
298                url: resource_url(&manifest.base_url, &declaration.path),
299                headers: attempt_headers,
300                body: body.clone(),
301                timeout_ms,
302            })
303            .await;
304
305        let response = match response {
306            Ok(response) => response,
307            Err(err) if attempt < max_attempts && err.is_retryable() => {
308                last_error = Some(AddonResourceCallFailure {
309                    error: err,
310                    attempts: attempt,
311                });
312                continue;
313            }
314            Err(err) => {
315                return Err(AddonResourceCallFailure {
316                    error: err,
317                    attempts: attempt,
318                });
319            }
320        };
321
322        if !(200..300).contains(&response.status) {
323            let failure = AddonResourceCallFailure {
324                error: AddonClientError::HttpStatus {
325                    status: response.status,
326                    retryable: is_retryable_http_status(response.status),
327                },
328                attempts: attempt,
329            };
330            if attempt < max_attempts && failure.error.is_retryable() {
331                last_error = Some(failure);
332                continue;
333            }
334            return Err(failure);
335        }
336
337        let envelope = serde_json::from_str::<AddonResourceResponse>(&response.body)
338            .map_err(|err| AddonManifestError::InvalidEnvelope {
339                message: format!("failed to parse addon response: {err}"),
340            })
341            .map_err(|error| AddonResourceCallFailure {
342                error: error.into(),
343                attempts: attempt,
344            })?;
345        validate_resource_response(&envelope, manifest, resource, &request_id).map_err(
346            |error| AddonResourceCallFailure {
347                error: error.into(),
348                attempts: attempt,
349            },
350        )?;
351
352        return Ok(AddonResourceCallOutcome {
353            response: envelope,
354            http_status: response.status,
355            attempts: attempt,
356        });
357    }
358
359    Err(last_error.unwrap_or_else(|| AddonResourceCallFailure {
360        error: AddonManifestError::InvalidMaxAttempts {
361            value: max_attempts,
362        }
363        .into(),
364        attempts: 0,
365    }))
366}
367
368pub async fn call_addon_task_with_outcome<T>(
369    transport: &T,
370    manifest: &AddonManifest,
371    granted_scopes: &[AddonScope],
372    request: AddonTaskCallRequest,
373    bearer_token: Option<&str>,
374) -> Result<AddonTaskCallOutcome, AddonTaskCallFailure>
375where
376    T: AddonTransport,
377{
378    validate_manifest(manifest).map_err(task_call_setup_failure)?;
379    ensure_task_scope_grant(manifest, &request.task_id, granted_scopes)
380        .map_err(task_call_setup_failure)?;
381    let declaration = manifest
382        .tasks
383        .iter()
384        .find(|candidate| candidate.id == request.task_id)
385        .ok_or_else(|| AddonManifestError::TaskNotDeclared {
386            task_id: request.task_id.clone(),
387        })
388        .map_err(task_call_setup_failure)?;
389    let timeout_ms = declaration
390        .timeout_ms
391        .or(manifest.default_timeout_ms)
392        .unwrap_or(10_000);
393    let protocol_version = manifest.protocol_version.clone();
394    let envelope = AddonTaskRequest {
395        protocol_version: protocol_version.clone(),
396        addon_id: manifest.id.clone(),
397        task_id: request.task_id.clone(),
398        job_id: request.job_id.clone(),
399        request_id: request.request_id.clone(),
400        attempt: request.attempt,
401        retry_of_job_id: request.retry_of_job_id.clone(),
402        library_id: request.library_id.clone(),
403        source_id: request.source_id.clone(),
404        payload: request.payload,
405    };
406    let body = serde_json::to_string(&envelope)
407        .map_err(|err| AddonManifestError::InvalidEnvelope {
408            message: format!("failed to serialize addon task request: {err}"),
409        })
410        .map_err(task_call_setup_failure)?;
411    let mut headers = vec![
412        ("content-type".to_owned(), "application/json".to_owned()),
413        ("x-nako-addon-protocol-version".to_owned(), protocol_version),
414        ("x-nako-addon-id".to_owned(), manifest.id.clone()),
415        (
416            "x-nako-addon-operation".to_owned(),
417            "task-dispatch".to_owned(),
418        ),
419        ("x-nako-addon-task".to_owned(), request.task_id.clone()),
420        ("x-nako-job-id".to_owned(), request.job_id.clone()),
421        ("x-nako-request-id".to_owned(), request.request_id.clone()),
422    ];
423    match manifest.auth {
424        AddonAuth::None => {}
425        AddonAuth::Bearer => {
426            let token = bearer_token
427                .ok_or(AddonManifestError::MissingAuthToken {
428                    auth: AddonAuth::Bearer,
429                })
430                .map_err(task_call_setup_failure)?;
431            headers.push(("authorization".to_owned(), format!("Bearer {token}")));
432        }
433        AddonAuth::SharedSecret => {
434            let token = bearer_token
435                .ok_or(AddonManifestError::MissingAuthToken {
436                    auth: AddonAuth::SharedSecret,
437                })
438                .map_err(task_call_setup_failure)?;
439            headers.push(("x-nako-addon-secret".to_owned(), token.to_owned()));
440        }
441    }
442
443    let dispatch_attempt = 1;
444    {
445        let mut attempt_headers = headers.clone();
446        attempt_headers.push(("x-nako-attempt".to_owned(), dispatch_attempt.to_string()));
447        let response = transport
448            .post(AddonHttpRequest {
449                url: resource_url(&manifest.base_url, &declaration.path),
450                headers: attempt_headers,
451                body: body.clone(),
452                timeout_ms,
453            })
454            .await;
455
456        let response = match response {
457            Ok(response) => response,
458            Err(err) => {
459                return Err(AddonTaskCallFailure {
460                    error: err,
461                    attempts: dispatch_attempt,
462                });
463            }
464        };
465
466        if !(200..300).contains(&response.status) {
467            return Err(AddonTaskCallFailure {
468                error: AddonClientError::HttpStatus {
469                    status: response.status,
470                    retryable: is_retryable_http_status(response.status),
471                },
472                attempts: dispatch_attempt,
473            });
474        }
475
476        let envelope = serde_json::from_str::<AddonTaskResponse>(&response.body)
477            .map_err(|err| AddonManifestError::InvalidEnvelope {
478                message: format!("failed to parse addon task response: {err}"),
479            })
480            .map_err(|error| AddonTaskCallFailure {
481                error: error.into(),
482                attempts: dispatch_attempt,
483            })?;
484        validate_task_response(
485            &envelope,
486            manifest,
487            &request.task_id,
488            &request.job_id,
489            &request.request_id,
490        )
491        .map_err(|error| AddonTaskCallFailure {
492            error: error.into(),
493            attempts: dispatch_attempt,
494        })?;
495
496        Ok(AddonTaskCallOutcome {
497            response: envelope,
498            http_status: response.status,
499            attempts: dispatch_attempt,
500        })
501    }
502}
503
504pub async fn call_addon_event_with_outcome<T>(
505    transport: &T,
506    manifest: &AddonManifest,
507    granted_scopes: &[AddonScope],
508    request: AddonEventCallRequest,
509    bearer_token: Option<&str>,
510) -> Result<AddonEventCallOutcome, AddonEventCallFailure>
511where
512    T: AddonTransport,
513{
514    validate_manifest(manifest).map_err(event_call_setup_failure)?;
515    ensure_event_subscription_scope_grant(manifest, &request.subscription_id, granted_scopes)
516        .map_err(event_call_setup_failure)?;
517    let declaration = manifest
518        .event_subscriptions
519        .iter()
520        .find(|candidate| candidate.id == request.subscription_id)
521        .ok_or_else(|| AddonManifestError::EventSubscriptionNotDeclared {
522            subscription_id: request.subscription_id.clone(),
523        })
524        .map_err(event_call_setup_failure)?;
525    if declaration.event_kind != request.event_kind {
526        return Err(event_call_setup_failure(
527            AddonManifestError::InvalidEnvelope {
528                message: format!(
529                    "event subscription {} declares {} but request used {}",
530                    declaration.id, declaration.event_kind, request.event_kind
531                ),
532            },
533        ));
534    }
535
536    let timeout_ms = manifest.default_timeout_ms.unwrap_or(10_000);
537    let protocol_version = manifest.protocol_version.clone();
538    let envelope = AddonEventRequest {
539        protocol_version: protocol_version.clone(),
540        addon_id: manifest.id.clone(),
541        subscription_id: request.subscription_id.clone(),
542        event_id: request.event_id.clone(),
543        event_kind: request.event_kind.clone(),
544        subject_kind: request.subject_kind.clone(),
545        subject_id: request.subject_id.clone(),
546        occurred_at: request.occurred_at.clone(),
547        attempt: request.attempt,
548        payload: request.payload,
549    };
550    let body = serde_json::to_string(&envelope)
551        .map_err(|err| AddonManifestError::InvalidEnvelope {
552            message: format!("failed to serialize addon event request: {err}"),
553        })
554        .map_err(event_call_setup_failure)?;
555    let mut headers = vec![
556        ("content-type".to_owned(), "application/json".to_owned()),
557        ("x-nako-addon-protocol-version".to_owned(), protocol_version),
558        ("x-nako-addon-id".to_owned(), manifest.id.clone()),
559        (
560            "x-nako-addon-operation".to_owned(),
561            "event-delivery".to_owned(),
562        ),
563        (
564            "x-nako-addon-event-subscription".to_owned(),
565            request.subscription_id.clone(),
566        ),
567        ("x-nako-event-id".to_owned(), request.event_id.clone()),
568        ("x-nako-event-kind".to_owned(), request.event_kind.clone()),
569        ("x-nako-attempt".to_owned(), request.attempt.to_string()),
570    ];
571    match manifest.auth {
572        AddonAuth::None => {}
573        AddonAuth::Bearer => {
574            let token = bearer_token
575                .ok_or(AddonManifestError::MissingAuthToken {
576                    auth: AddonAuth::Bearer,
577                })
578                .map_err(event_call_setup_failure)?;
579            headers.push(("authorization".to_owned(), format!("Bearer {token}")));
580        }
581        AddonAuth::SharedSecret => {
582            let token = bearer_token
583                .ok_or(AddonManifestError::MissingAuthToken {
584                    auth: AddonAuth::SharedSecret,
585                })
586                .map_err(event_call_setup_failure)?;
587            headers.push(("x-nako-addon-secret".to_owned(), token.to_owned()));
588        }
589    }
590
591    let dispatch_attempt = 1;
592    let response = transport
593        .post(AddonHttpRequest {
594            url: resource_url(&manifest.base_url, &declaration.path),
595            headers,
596            body,
597            timeout_ms,
598        })
599        .await
600        .map_err(|err| AddonEventCallFailure {
601            error: err,
602            attempts: dispatch_attempt,
603        })?;
604
605    if !(200..300).contains(&response.status) {
606        return Err(AddonEventCallFailure {
607            error: AddonClientError::HttpStatus {
608                status: response.status,
609                retryable: is_retryable_http_status(response.status),
610            },
611            attempts: dispatch_attempt,
612        });
613    }
614
615    let envelope = serde_json::from_str::<AddonEventResponse>(&response.body)
616        .map_err(|err| AddonManifestError::InvalidEnvelope {
617            message: format!("failed to parse addon event response: {err}"),
618        })
619        .map_err(|error| AddonEventCallFailure {
620            error: error.into(),
621            attempts: dispatch_attempt,
622        })?;
623    validate_event_response(
624        &envelope,
625        manifest,
626        &request.subscription_id,
627        &request.event_id,
628    )
629    .map_err(|error| AddonEventCallFailure {
630        error: error.into(),
631        attempts: dispatch_attempt,
632    })?;
633
634    Ok(AddonEventCallOutcome {
635        response: envelope,
636        http_status: response.status,
637        attempts: dispatch_attempt,
638    })
639}
640
641pub async fn check_addon_health<T>(
642    transport: &T,
643    manifest: &AddonManifest,
644    request_id: impl Into<String>,
645) -> AddonClientResult<AddonHealthCheckResponse>
646where
647    T: AddonTransport,
648{
649    validate_manifest(manifest)?;
650    let request_id = request_id.into();
651    let timeout_ms = manifest.default_timeout_ms.unwrap_or(10_000);
652    let protocol_version = manifest.protocol_version.clone();
653    let envelope = AddonHealthCheckRequest {
654        protocol_version: protocol_version.clone(),
655        manifest_id: manifest.id.clone(),
656        request_id: request_id.clone(),
657        expected_addon_version: manifest.version.clone(),
658        expected_resource_count: manifest.resources.len(),
659    };
660    let body =
661        serde_json::to_string(&envelope).map_err(|err| AddonManifestError::InvalidEnvelope {
662            message: format!("failed to serialize addon health request: {err}"),
663        })?;
664    let response = transport
665        .post(AddonHttpRequest {
666            url: resource_url(&manifest.base_url, "/health"),
667            headers: vec![
668                ("content-type".to_owned(), "application/json".to_owned()),
669                ("x-nako-addon-protocol-version".to_owned(), protocol_version),
670                ("x-nako-addon-id".to_owned(), manifest.id.clone()),
671                (
672                    "x-nako-addon-operation".to_owned(),
673                    "health-check".to_owned(),
674                ),
675                ("x-nako-request-id".to_owned(), request_id),
676            ],
677            body,
678            timeout_ms,
679        })
680        .await?;
681
682    if !(200..300).contains(&response.status) {
683        return Err(AddonClientError::HttpStatus {
684            status: response.status,
685            retryable: is_retryable_http_status(response.status),
686        });
687    }
688
689    let envelope =
690        serde_json::from_str::<AddonHealthCheckResponse>(&response.body).map_err(|err| {
691            AddonManifestError::InvalidEnvelope {
692                message: format!("failed to parse addon health response: {err}"),
693            }
694        })?;
695    validate_health_check_response(&envelope, manifest)?;
696
697    Ok(envelope)
698}
699
700impl NakoRuntimeClient<ReqwestAddonTransport> {
701    #[must_use]
702    pub fn new(config: NakoRuntimeClientConfig) -> Self {
703        Self::with_transport(config, ReqwestAddonTransport::default())
704    }
705}
706
707impl<T> NakoRuntimeClient<T>
708where
709    T: AddonTransport,
710{
711    #[must_use]
712    pub const fn with_transport(config: NakoRuntimeClientConfig, transport: T) -> Self {
713        Self { config, transport }
714    }
715
716    pub async fn access_check(
717        &self,
718        request: AddonAccessCheckRequest,
719    ) -> AddonClientResult<AddonAccessCheckResponse> {
720        self.post_runtime_json(ADDON_RUNTIME_ACCESS_CHECK_PATH, &request)
721            .await
722    }
723
724    pub async fn submit_side_effect(
725        &self,
726        request: SubmitAddonSideEffectRequest,
727    ) -> AddonClientResult<AddonSideEffectResponse> {
728        self.post_runtime_json(ADDON_RUNTIME_SIDE_EFFECTS_PATH, &request)
729            .await
730    }
731
732    pub async fn submit_metadata_write(
733        &self,
734        request: SubmitAddonMetadataWriteRequest,
735    ) -> AddonClientResult<AddonSideEffectResponse> {
736        let payload =
737            serde_json::to_value(&request.patch).map_err(invalid_runtime_request_envelope)?;
738        self.submit_side_effect(SubmitAddonSideEffectRequest {
739            permission: AddonPermission::MetadataWrite,
740            library_id: request.library_id,
741            target: request.target,
742            idempotency_key: request.idempotency_key,
743            provenance: request.provenance,
744            payload,
745        })
746        .await
747    }
748
749    pub async fn submit_artwork_write(
750        &self,
751        request: SubmitAddonArtworkWriteRequest,
752    ) -> AddonClientResult<AddonSideEffectResponse> {
753        if request.target.kind != AddonSideEffectTargetKind::MediaItem {
754            return Err(invalid_runtime_request(
755                "artwork_write target must be media_item",
756            ));
757        }
758        let payload =
759            serde_json::to_value(&request.artwork).map_err(invalid_runtime_request_envelope)?;
760        self.submit_side_effect(SubmitAddonSideEffectRequest {
761            permission: AddonPermission::ArtworkWrite,
762            library_id: request.library_id,
763            target: request.target,
764            idempotency_key: request.idempotency_key,
765            provenance: request.provenance,
766            payload,
767        })
768        .await
769    }
770
771    async fn post_runtime_json<B, R>(&self, path: &str, body: &B) -> AddonClientResult<R>
772    where
773        B: serde::Serialize,
774        R: for<'de> serde::Deserialize<'de>,
775    {
776        let body = serde_json::to_string(body).map_err(invalid_runtime_request_envelope)?;
777        if !self.config.addon_token.trim().is_empty() && body.contains(&self.config.addon_token) {
778            return Err(AddonClientError::UnsafeRequestBody);
779        }
780
781        let response = self
782            .transport
783            .post(AddonHttpRequest {
784                url: resource_url(&self.config.base_url, path),
785                headers: vec![
786                    ("accept".to_owned(), "application/json".to_owned()),
787                    ("content-type".to_owned(), "application/json".to_owned()),
788                    (
789                        "authorization".to_owned(),
790                        format!("Bearer {}", self.config.addon_token),
791                    ),
792                ],
793                body,
794                timeout_ms: self.config.timeout_ms,
795            })
796            .await?;
797
798        if !(200..300).contains(&response.status) {
799            return Err(AddonClientError::HttpStatus {
800                status: response.status,
801                retryable: is_retryable_http_status(response.status),
802            });
803        }
804
805        serde_json::from_str(&response.body).map_err(runtime_response_envelope_error)
806    }
807}
808
809fn resource_url(base_url: &str, path: &str) -> String {
810    format!("{}{}", base_url.trim_end_matches('/'), path)
811}
812
813fn invalid_runtime_request(message: impl Into<String>) -> AddonClientError {
814    AddonClientError::InvalidRequest {
815        message: message.into(),
816    }
817}
818
819fn invalid_runtime_request_envelope(error: serde_json::Error) -> AddonClientError {
820    invalid_runtime_request(format!("failed to serialize Nako runtime request: {error}"))
821}
822
823fn runtime_response_envelope_error(error: serde_json::Error) -> AddonClientError {
824    AddonClientError::InvalidResponse {
825        message: format!("failed to parse Nako runtime response: {error}"),
826    }
827}
828
829fn addon_http_error(error: reqwest::Error) -> AddonClientError {
830    AddonClientError::Http {
831        message: safe_error_text(&error.without_url().to_string()),
832    }
833}
834
835fn safe_error_text(value: &str) -> String {
836    value.replace(['\r', '\n'], " ").chars().take(240).collect()
837}
838
839impl AddonClientError {
840    #[must_use]
841    fn is_retryable(&self) -> bool {
842        match self {
843            Self::Http { .. } => true,
844            Self::HttpStatus { retryable, .. } => *retryable,
845            Self::Protocol(_)
846            | Self::InvalidRequest { .. }
847            | Self::InvalidResponse { .. }
848            | Self::UnsafeRequestBody => false,
849        }
850    }
851}
852
853fn is_retryable_http_status(status: u16) -> bool {
854    status == 408 || status == 429 || (500..600).contains(&status)
855}
856
857fn resource_call_setup_failure(error: impl Into<AddonClientError>) -> AddonResourceCallFailure {
858    AddonResourceCallFailure {
859        error: error.into(),
860        attempts: 0,
861    }
862}
863
864fn task_call_setup_failure(error: impl Into<AddonClientError>) -> AddonTaskCallFailure {
865    AddonTaskCallFailure {
866        error: error.into(),
867        attempts: 0,
868    }
869}
870
871fn event_call_setup_failure(error: impl Into<AddonClientError>) -> AddonEventCallFailure {
872    AddonEventCallFailure {
873        error: error.into(),
874        attempts: 0,
875    }
876}
877
878impl AddonClientError {
879    #[must_use]
880    pub const fn http_status(&self) -> Option<u16> {
881        match self {
882            Self::HttpStatus { status, .. } => Some(*status),
883            Self::Protocol(_)
884            | Self::InvalidRequest { .. }
885            | Self::InvalidResponse { .. }
886            | Self::UnsafeRequestBody
887            | Self::Http { .. } => None,
888        }
889    }
890
891    #[must_use]
892    pub const fn was_retryable_http_status(&self) -> bool {
893        match self {
894            Self::HttpStatus { retryable, .. } => *retryable,
895            Self::Protocol(_)
896            | Self::InvalidRequest { .. }
897            | Self::InvalidResponse { .. }
898            | Self::UnsafeRequestBody
899            | Self::Http { .. } => false,
900        }
901    }
902
903    #[must_use]
904    pub const fn safe_code(&self) -> &'static str {
905        match self {
906            Self::Protocol(_) | Self::InvalidRequest { .. } => "invalid_request",
907            Self::InvalidResponse { .. } => "invalid_response",
908            Self::UnsafeRequestBody => "unsafe_request_body",
909            Self::Http { .. } => "transport_error",
910            Self::HttpStatus { status, .. } => match *status {
911                400..=499 => "http_client_error",
912                500..=599 => "http_server_error",
913                _ => "http_status_error",
914            },
915        }
916    }
917}
918
919impl AddonClientError {
920    #[must_use]
921    pub fn kind(&self) -> &'static str {
922        match self {
923            Self::Protocol(_) => "protocol",
924            Self::InvalidRequest { .. } => "invalid_request",
925            Self::InvalidResponse { .. } => "invalid_response",
926            Self::UnsafeRequestBody => "unsafe_request_body",
927            Self::HttpStatus { .. } => "http_status",
928            Self::Http { .. } => "http",
929        }
930    }
931}
932
933#[cfg(test)]
934fn assert_error_shape(err: &AddonClientError) {
935    match err {
936        AddonClientError::HttpStatus { status, retryable } => {
937            assert_eq!(*retryable, is_retryable_http_status(*status));
938        }
939        AddonClientError::Protocol(_)
940        | AddonClientError::InvalidRequest { .. }
941        | AddonClientError::InvalidResponse { .. }
942        | AddonClientError::UnsafeRequestBody
943        | AddonClientError::Http { .. } => {}
944    }
945}
946
947#[cfg(test)]
948mod client_error_tests {
949    use super::*;
950
951    #[test]
952    fn http_status_error_records_retryability() {
953        assert_error_shape(&AddonClientError::HttpStatus {
954            status: 500,
955            retryable: true,
956        });
957        assert_error_shape(&AddonClientError::HttpStatus {
958            status: 400,
959            retryable: false,
960        });
961    }
962}
963
964#[cfg(test)]
965mod tests {
966    use std::{
967        collections::VecDeque,
968        sync::{Arc, Mutex},
969    };
970
971    use nako_addon_protocol::{
972        ADDON_PROTOCOL_VERSION, AddonArtifact, AddonEventSubscriptionDeclaration,
973        AddonResourceDeclaration, AddonTaskDeclaration,
974    };
975
976    use super::*;
977
978    #[tokio::test]
979    async fn calls_resource_with_bearer_auth_and_validates_response() {
980        let manifest = valid_manifest();
981        let transport = MockTransport::with_response(Ok(AddonHttpResponse {
982            status: 200,
983            body: response_json(&manifest, "request-1"),
984        }));
985
986        let response = call_addon_resource(
987            &transport,
988            &manifest,
989            AddonResource::Metadata,
990            &[
991                AddonScope::ItemMetadataRead,
992                AddonScope::ItemMetadataSuggest,
993            ],
994            "request-1",
995            serde_json::json!({"item_id":"item-1"}),
996            Some("token-1"),
997        )
998        .await
999        .unwrap();
1000
1001        assert_eq!(response.payload["title"], "The Matrix");
1002        let requests = transport.requests();
1003        assert_eq!(requests.len(), 1);
1004        assert_eq!(
1005            requests[0].url,
1006            "https://example.test/addon/metadata".to_owned()
1007        );
1008        assert_eq!(
1009            header_value(&requests[0], "authorization"),
1010            Some("Bearer token-1")
1011        );
1012        assert_eq!(header_value(&requests[0], "x-nako-attempt"), Some("1"));
1013        assert_eq!(requests[0].timeout_ms, 5_000);
1014        assert!(requests[0].body.contains("\"request_id\":\"request-1\""));
1015    }
1016
1017    #[tokio::test]
1018    async fn retries_retryable_errors_with_the_same_request_id() {
1019        let manifest = valid_manifest();
1020        let transport = MockTransport::default();
1021        transport.push_response(Err(AddonClientError::Http {
1022            message: "temporary network failure".to_owned(),
1023        }));
1024        transport.push_response(Ok(AddonHttpResponse {
1025            status: 200,
1026            body: response_json(&manifest, "request-2"),
1027        }));
1028
1029        let response = call_addon_resource(
1030            &transport,
1031            &manifest,
1032            AddonResource::Metadata,
1033            &[
1034                AddonScope::ItemMetadataRead,
1035                AddonScope::ItemMetadataSuggest,
1036            ],
1037            "request-2",
1038            serde_json::json!({"item_id":"item-1"}),
1039            Some("token-1"),
1040        )
1041        .await
1042        .unwrap();
1043
1044        assert_eq!(response.request_id, "request-2");
1045        let requests = transport.requests();
1046        assert_eq!(requests.len(), 2);
1047        assert_eq!(requests[0].body, requests[1].body);
1048        assert_eq!(header_value(&requests[0], "x-nako-attempt"), Some("1"));
1049        assert_eq!(header_value(&requests[1], "x-nako-attempt"), Some("2"));
1050    }
1051
1052    #[tokio::test]
1053    async fn does_not_retry_non_retryable_http_status() {
1054        let manifest = valid_manifest();
1055        let transport = MockTransport::default();
1056        transport.push_response(Ok(AddonHttpResponse {
1057            status: 400,
1058            body: "{}".to_owned(),
1059        }));
1060        transport.push_response(Ok(AddonHttpResponse {
1061            status: 200,
1062            body: response_json(&manifest, "request-3"),
1063        }));
1064
1065        let err = call_addon_resource(
1066            &transport,
1067            &manifest,
1068            AddonResource::Metadata,
1069            &[
1070                AddonScope::ItemMetadataRead,
1071                AddonScope::ItemMetadataSuggest,
1072            ],
1073            "request-3",
1074            serde_json::json!({"item_id":"item-1"}),
1075            Some("token-1"),
1076        )
1077        .await
1078        .unwrap_err();
1079
1080        assert_eq!(
1081            err,
1082            AddonClientError::HttpStatus {
1083                status: 400,
1084                retryable: false
1085            }
1086        );
1087        assert_eq!(transport.requests().len(), 1);
1088    }
1089
1090    #[tokio::test]
1091    async fn rejects_invalid_response_mapping() {
1092        let manifest = valid_manifest();
1093        let transport = MockTransport::with_response(Ok(AddonHttpResponse {
1094            status: 200,
1095            body: response_json(&manifest, "different-request"),
1096        }));
1097
1098        let err = call_addon_resource(
1099            &transport,
1100            &manifest,
1101            AddonResource::Metadata,
1102            &[
1103                AddonScope::ItemMetadataRead,
1104                AddonScope::ItemMetadataSuggest,
1105            ],
1106            "request-4",
1107            serde_json::json!({"item_id":"item-1"}),
1108            Some("token-1"),
1109        )
1110        .await
1111        .unwrap_err();
1112
1113        assert!(matches!(
1114            err,
1115            AddonClientError::Protocol(AddonManifestError::InvalidEnvelope { .. })
1116        ));
1117    }
1118
1119    #[tokio::test]
1120    async fn requires_auth_token_for_authenticated_addons() {
1121        let manifest = valid_manifest();
1122        let transport = MockTransport::default();
1123
1124        let err = call_addon_resource(
1125            &transport,
1126            &manifest,
1127            AddonResource::Metadata,
1128            &[
1129                AddonScope::ItemMetadataRead,
1130                AddonScope::ItemMetadataSuggest,
1131            ],
1132            "request-5",
1133            serde_json::json!({"item_id":"item-1"}),
1134            None,
1135        )
1136        .await
1137        .unwrap_err();
1138
1139        assert_eq!(
1140            err,
1141            AddonClientError::Protocol(AddonManifestError::MissingAuthToken {
1142                auth: AddonAuth::Bearer
1143            })
1144        );
1145        assert!(transport.requests().is_empty());
1146    }
1147
1148    #[tokio::test]
1149    async fn checks_health_without_auth_or_resource_payload() {
1150        let manifest = valid_manifest();
1151        let transport = MockTransport::with_response(Ok(AddonHttpResponse {
1152            status: 200,
1153            body: serde_json::to_string(&AddonHealthCheckResponse {
1154                protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
1155                manifest_id: manifest.id.clone(),
1156                status: nako_addon_protocol::AddonHealthStatus::Ok,
1157                checked_at: "2026-05-21T12:00:00.000Z".to_owned(),
1158                manifest: nako_addon_protocol::AddonHealthManifestFacts {
1159                    addon_version: manifest.version.clone(),
1160                    resource_count: manifest.resources.len(),
1161                },
1162                diagnostics: serde_json::json!({"safe_note": "ok"}),
1163            })
1164            .unwrap(),
1165        }));
1166
1167        let response = check_addon_health(&transport, &manifest, "health-1")
1168            .await
1169            .unwrap();
1170
1171        assert_eq!(response.manifest_id, manifest.id);
1172        let requests = transport.requests();
1173        assert_eq!(requests.len(), 1);
1174        assert_eq!(
1175            requests[0].url,
1176            "https://example.test/addon/health".to_owned()
1177        );
1178        assert_eq!(
1179            header_value(&requests[0], "x-nako-addon-operation"),
1180            Some("health-check")
1181        );
1182        assert_eq!(header_value(&requests[0], "authorization"), None);
1183        assert_eq!(header_value(&requests[0], "x-nako-addon-secret"), None);
1184        assert!(requests[0].body.contains("\"manifest_id\":\"example\""));
1185        assert!(!requests[0].body.contains("\"payload\""));
1186    }
1187
1188    #[tokio::test]
1189    async fn calls_declared_task_path_with_host_owned_run_envelope() {
1190        let manifest = valid_manifest_with_task();
1191        let transport = MockTransport::with_response(Ok(AddonHttpResponse {
1192            status: 200,
1193            body: serde_json::to_string(&AddonTaskResponse {
1194                protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
1195                addon_id: manifest.id.clone(),
1196                task_id: "bulk-task".to_owned(),
1197                job_id: "job-1".to_owned(),
1198                request_id: "task-request-1".to_owned(),
1199                output: serde_json::json!({"accepted": 2}),
1200            })
1201            .unwrap(),
1202        }));
1203
1204        let outcome = call_addon_task_with_outcome(
1205            &transport,
1206            &manifest,
1207            &[AddonScope::AutomationRun],
1208            AddonTaskCallRequest {
1209                task_id: "bulk-task".to_owned(),
1210                job_id: "job-1".to_owned(),
1211                request_id: "task-request-1".to_owned(),
1212                attempt: 2,
1213                retry_of_job_id: Some("job-0".to_owned()),
1214                library_id: Some("library-1".to_owned()),
1215                source_id: Some("source-1".to_owned()),
1216                payload: serde_json::json!({"mode": "missing-only"}),
1217            },
1218            Some("token-1"),
1219        )
1220        .await
1221        .unwrap();
1222
1223        assert_eq!(outcome.response.output["accepted"], 2);
1224        let requests = transport.requests();
1225        assert_eq!(requests.len(), 1);
1226        assert_eq!(
1227            requests[0].url,
1228            "https://example.test/addon/tasks/bulk".to_owned()
1229        );
1230        assert_eq!(
1231            header_value(&requests[0], "x-nako-addon-task"),
1232            Some("bulk-task")
1233        );
1234        assert_eq!(header_value(&requests[0], "x-nako-job-id"), Some("job-1"));
1235        assert_eq!(
1236            header_value(&requests[0], "x-nako-addon-operation"),
1237            Some("task-dispatch")
1238        );
1239        assert_eq!(
1240            header_value(&requests[0], "authorization"),
1241            Some("Bearer token-1")
1242        );
1243        assert_eq!(requests[0].timeout_ms, 7_000);
1244        assert!(requests[0].body.contains("\"task_id\":\"bulk-task\""));
1245        assert!(requests[0].body.contains("\"retry_of_job_id\":\"job-0\""));
1246        assert!(requests[0].body.contains("\"mode\":\"missing-only\""));
1247    }
1248
1249    #[tokio::test]
1250    async fn calls_declared_event_subscription_path_with_event_envelope() {
1251        let manifest = valid_manifest_with_event_subscription();
1252        let transport = MockTransport::with_response(Ok(AddonHttpResponse {
1253            status: 202,
1254            body: serde_json::to_string(&AddonEventResponse {
1255                protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
1256                addon_id: manifest.id.clone(),
1257                subscription_id: "library-scanned".to_owned(),
1258                event_id: "event-1".to_owned(),
1259                output: serde_json::json!({"queued": true}),
1260            })
1261            .unwrap(),
1262        }));
1263
1264        let outcome = call_addon_event_with_outcome(
1265            &transport,
1266            &manifest,
1267            &[AddonScope::WebhookEventRead],
1268            AddonEventCallRequest {
1269                subscription_id: "library-scanned".to_owned(),
1270                event_id: "event-1".to_owned(),
1271                event_kind: "library.scanned".to_owned(),
1272                subject_kind: "library".to_owned(),
1273                subject_id: "library-1".to_owned(),
1274                occurred_at: "2026-05-25T00:00:00.000Z".to_owned(),
1275                attempt: 2,
1276                payload: serde_json::json!({"library_id": "library-1"}),
1277            },
1278            None,
1279        )
1280        .await
1281        .unwrap();
1282
1283        assert_eq!(outcome.http_status, 202);
1284        assert_eq!(outcome.response.output["queued"], true);
1285        let requests = transport.requests();
1286        assert_eq!(requests.len(), 1);
1287        assert_eq!(
1288            requests[0].url,
1289            "https://example.test/addon/events/library-scanned".to_owned()
1290        );
1291        assert_eq!(
1292            header_value(&requests[0], "x-nako-addon-operation"),
1293            Some("event-delivery")
1294        );
1295        assert_eq!(
1296            header_value(&requests[0], "x-nako-addon-event-subscription"),
1297            Some("library-scanned")
1298        );
1299        assert_eq!(
1300            header_value(&requests[0], "x-nako-event-kind"),
1301            Some("library.scanned")
1302        );
1303        assert_eq!(header_value(&requests[0], "x-nako-attempt"), Some("2"));
1304        assert!(
1305            requests[0]
1306                .body
1307                .contains("\"subscription_id\":\"library-scanned\"")
1308        );
1309        assert!(requests[0].body.contains("\"event_id\":\"event-1\""));
1310        assert!(requests[0].body.contains("\"library_id\":\"library-1\""));
1311    }
1312
1313    #[tokio::test]
1314    async fn runtime_access_check_sends_bearer_token_only_in_header() {
1315        let transport = MockTransport::with_response(Ok(AddonHttpResponse {
1316            status: 200,
1317            body: serde_json::json!({
1318                "addon_id": "addon-1",
1319                "token_id": "token-1",
1320                "permission": "metadata_write",
1321                "library_id": "library-1",
1322                "allowed": true
1323            })
1324            .to_string(),
1325        }));
1326        let client = runtime_client(transport.clone());
1327
1328        let response = client
1329            .access_check(AddonAccessCheckRequest {
1330                permission: AddonPermission::MetadataWrite,
1331                library_id: Some("library-1".to_owned()),
1332            })
1333            .await
1334            .unwrap();
1335
1336        assert!(response.allowed);
1337        let requests = transport.requests();
1338        assert_eq!(requests.len(), 1);
1339        assert_eq!(
1340            requests[0].url,
1341            "https://nako.example/addon/v1/access-check"
1342        );
1343        assert_eq!(
1344            header_value(&requests[0], "authorization"),
1345            Some("Bearer addon-token-secret")
1346        );
1347        assert!(!requests[0].body.contains("addon-token-secret"));
1348        assert!(
1349            requests[0]
1350                .body
1351                .contains("\"permission\":\"metadata_write\"")
1352        );
1353    }
1354
1355    #[tokio::test]
1356    async fn runtime_side_effect_submission_parses_version_tolerant_summary() {
1357        let transport = MockTransport::with_response(Ok(AddonHttpResponse {
1358            status: 200,
1359            body: serde_json::json!({
1360                "side_effect": {
1361                    "id": "effect-1",
1362                    "addon_id": "addon-1",
1363                    "token_id": "token-1",
1364                    "permission": "metadata_write",
1365                    "library_id": "library-1",
1366                    "target": {"kind": "media_source", "id": "source-1"},
1367                    "idempotency_key": "metadata-demo-1",
1368                    "validation_status": "accepted",
1369                    "safe_error_code": null,
1370                    "apply_status": "applied",
1371                    "apply_error_code": null,
1372                    "applied_item_id": "item-1",
1373                    "applied_source": "addon:addon-1",
1374                    "apply_report": null,
1375                    "applied_at": "2026-05-24T09:00:00Z",
1376                    "created_at": "2026-05-24T09:00:00Z"
1377                },
1378                "idempotent_replay": false
1379            })
1380            .to_string(),
1381        }));
1382        let client = runtime_client(transport.clone());
1383
1384        let response = client
1385            .submit_side_effect(SubmitAddonSideEffectRequest {
1386                permission: AddonPermission::MetadataWrite,
1387                library_id: "library-1".to_owned(),
1388                target: nako_addon_protocol::AddonSideEffectTarget {
1389                    kind: AddonSideEffectTargetKind::MediaSource,
1390                    id: "source-1".to_owned(),
1391                },
1392                idempotency_key: "metadata-demo-1".to_owned(),
1393                provenance: serde_json::json!({"origin": "official-addon"}),
1394                payload: serde_json::json!({"title": "Demo"}),
1395            })
1396            .await
1397            .unwrap();
1398
1399        assert_eq!(response.side_effect.apply_status, "applied");
1400        assert_eq!(
1401            response.side_effect.applied_item_id.as_deref(),
1402            Some("item-1")
1403        );
1404        let requests = transport.requests();
1405        assert_eq!(
1406            requests[0].url,
1407            "https://nako.example/addon/v1/side-effects"
1408        );
1409        assert_eq!(
1410            header_value(&requests[0], "authorization"),
1411            Some("Bearer addon-token-secret")
1412        );
1413        assert!(!requests[0].body.contains("addon-token-secret"));
1414        assert!(
1415            requests[0]
1416                .body
1417                .contains("\"idempotency_key\":\"metadata-demo-1\"")
1418        );
1419    }
1420
1421    #[tokio::test]
1422    async fn runtime_metadata_write_serializes_patch_under_side_effect_payload() {
1423        let transport = MockTransport::with_response(Ok(AddonHttpResponse {
1424            status: 200,
1425            body: runtime_side_effect_response_json("metadata_write", "media_source"),
1426        }));
1427        let client = runtime_client(transport.clone());
1428
1429        client
1430            .submit_metadata_write(SubmitAddonMetadataWriteRequest {
1431                library_id: "library-1".to_owned(),
1432                target: nako_addon_protocol::AddonSideEffectTarget {
1433                    kind: AddonSideEffectTargetKind::MediaSource,
1434                    id: "source-1".to_owned(),
1435                },
1436                idempotency_key: "metadata-demo-2".to_owned(),
1437                provenance: serde_json::json!({"origin": "official-addon"}),
1438                patch: nako_addon_protocol::AddonMetadataPatch {
1439                    title: Some("The Matrix".to_owned()),
1440                    ..nako_addon_protocol::AddonMetadataPatch::default()
1441                },
1442            })
1443            .await
1444            .unwrap();
1445
1446        let requests = transport.requests();
1447        let body: serde_json::Value = serde_json::from_str(&requests[0].body).unwrap();
1448        assert_eq!(body["permission"], "metadata_write");
1449        assert_eq!(body["target"]["kind"], "media_source");
1450        assert_eq!(body["payload"]["title"], "The Matrix");
1451        assert_eq!(body["payload"]["overview"], serde_json::Value::Null);
1452    }
1453
1454    #[tokio::test]
1455    async fn runtime_artwork_write_rejects_non_media_item_targets_before_http() {
1456        let transport = MockTransport::default();
1457        let client = runtime_client(transport.clone());
1458
1459        let error = client
1460            .submit_artwork_write(SubmitAddonArtworkWriteRequest {
1461                library_id: "library-1".to_owned(),
1462                target: nako_addon_protocol::AddonSideEffectTarget {
1463                    kind: AddonSideEffectTargetKind::MediaSource,
1464                    id: "source-1".to_owned(),
1465                },
1466                idempotency_key: "artwork-demo-1".to_owned(),
1467                provenance: serde_json::json!({"origin": "official-addon"}),
1468                artwork: nako_addon_protocol::AddonArtworkWritePayload {
1469                    intent: nako_addon_protocol::AddonArtworkIntent::ProposeArtwork,
1470                    kind: nako_addon_protocol::AddonArtworkKind::Poster,
1471                    source: nako_addon_protocol::AddonArtworkSourcePayload {
1472                        kind: nako_addon_protocol::AddonArtworkSourceKind::RemoteUrl,
1473                        url: "https://example.test/poster.jpg".to_owned(),
1474                    },
1475                    language: None,
1476                    width: None,
1477                    height: None,
1478                },
1479            })
1480            .await
1481            .unwrap_err();
1482
1483        assert!(matches!(error, AddonClientError::InvalidRequest { .. }));
1484        assert_eq!(error.safe_code(), "invalid_request");
1485        assert!(transport.requests().is_empty());
1486    }
1487
1488    #[tokio::test]
1489    async fn runtime_request_rejects_body_token_material_before_http() {
1490        let transport = MockTransport::default();
1491        let client = runtime_client(transport.clone());
1492
1493        let error = client
1494            .submit_side_effect(SubmitAddonSideEffectRequest {
1495                permission: AddonPermission::MetadataWrite,
1496                library_id: "library-1".to_owned(),
1497                target: nako_addon_protocol::AddonSideEffectTarget {
1498                    kind: AddonSideEffectTargetKind::MediaSource,
1499                    id: "source-1".to_owned(),
1500                },
1501                idempotency_key: "metadata-demo-token".to_owned(),
1502                provenance: serde_json::json!({"origin": "official-addon"}),
1503                payload: serde_json::json!({"leak": "addon-token-secret"}),
1504            })
1505            .await
1506            .unwrap_err();
1507
1508        assert_eq!(error, AddonClientError::UnsafeRequestBody);
1509        assert_eq!(error.safe_code(), "unsafe_request_body");
1510        assert!(transport.requests().is_empty());
1511    }
1512
1513    #[tokio::test]
1514    async fn runtime_http_errors_do_not_expose_response_bodies() {
1515        let transport = MockTransport::with_response(Ok(AddonHttpResponse {
1516            status: 403,
1517            body: "forbidden: addon-token-secret".to_owned(),
1518        }));
1519        let client = runtime_client(transport);
1520
1521        let error = client
1522            .access_check(AddonAccessCheckRequest {
1523                permission: AddonPermission::MetadataWrite,
1524                library_id: None,
1525            })
1526            .await
1527            .unwrap_err();
1528
1529        assert_eq!(
1530            error,
1531            AddonClientError::HttpStatus {
1532                status: 403,
1533                retryable: false
1534            }
1535        );
1536        assert_eq!(error.safe_code(), "http_client_error");
1537        assert!(!error.to_string().contains("addon-token-secret"));
1538    }
1539
1540    #[tokio::test]
1541    async fn reqwest_transport_errors_do_not_expose_request_url_or_query_tokens() {
1542        let error = reqwest::Client::new()
1543            .get("http://127.0.0.1:1/addon/v1/access-check?token=addon-token-secret")
1544            .timeout(Duration::from_millis(50))
1545            .send()
1546            .await
1547            .unwrap_err();
1548
1549        let error = addon_http_error(error);
1550        let message = error.to_string();
1551
1552        assert_eq!(error.safe_code(), "transport_error");
1553        assert!(!message.contains("addon-token-secret"));
1554        assert!(!message.contains("/addon/v1/access-check"));
1555        assert!(!message.contains("127.0.0.1:1"));
1556    }
1557
1558    fn valid_manifest() -> AddonManifest {
1559        AddonManifest {
1560            id: "example".to_owned(),
1561            name: "Example".to_owned(),
1562            version: "0.1.0".to_owned(),
1563            protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
1564            base_url: "https://example.test/addon".to_owned(),
1565            description: None,
1566            resources: vec![AddonResourceDeclaration {
1567                kind: AddonResource::Metadata,
1568                path: "/metadata".to_owned(),
1569                input_schema: Some("nako.metadata.request.v1".to_owned()),
1570                output_schema: Some("nako.metadata.response.v1".to_owned()),
1571                required_scopes: vec![
1572                    AddonScope::ItemMetadataRead,
1573                    AddonScope::ItemMetadataSuggest,
1574                ],
1575                timeout_ms: Some(5_000),
1576                max_attempts: Some(2),
1577            }],
1578            entry_points: Vec::new(),
1579            hosted_pages: Vec::new(),
1580            configuration_schema: None,
1581            secret_reference_fields: Vec::new(),
1582            event_subscriptions: Vec::new(),
1583            tasks: Vec::new(),
1584            auth: AddonAuth::Bearer,
1585            default_timeout_ms: Some(10_000),
1586            default_max_attempts: Some(2),
1587            scopes: vec![
1588                AddonScope::ItemMetadataRead,
1589                AddonScope::ItemMetadataSuggest,
1590            ],
1591        }
1592    }
1593
1594    fn valid_manifest_with_task() -> AddonManifest {
1595        let mut manifest = valid_manifest();
1596        manifest.tasks = vec![
1597            AddonTaskDeclaration::new(
1598                "bulk-task",
1599                "Bulk Task",
1600                "/tasks/bulk",
1601                vec![AddonScope::AutomationRun],
1602            )
1603            .with_execution_bounds(Some(7_000), Some(3)),
1604        ];
1605        manifest.scopes.push(AddonScope::AutomationRun);
1606        manifest
1607    }
1608
1609    fn valid_manifest_with_event_subscription() -> AddonManifest {
1610        let mut manifest = valid_manifest();
1611        manifest.auth = AddonAuth::None;
1612        manifest.event_subscriptions = vec![AddonEventSubscriptionDeclaration::new(
1613            "library-scanned",
1614            "library.scanned",
1615            "/events/library-scanned",
1616            vec![AddonScope::WebhookEventRead],
1617            serde_json::Value::Null,
1618        )];
1619        manifest.scopes.push(AddonScope::WebhookEventRead);
1620        manifest
1621    }
1622
1623    fn response_json(manifest: &AddonManifest, request_id: &str) -> String {
1624        serde_json::to_string(&AddonResourceResponse {
1625            protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
1626            addon_id: manifest.id.clone(),
1627            resource: AddonResource::Metadata,
1628            request_id: request_id.to_owned(),
1629            payload: serde_json::json!({"title":"The Matrix"}),
1630            artifacts: vec![AddonArtifact {
1631                kind: "metadata_suggestion".to_owned(),
1632                payload: serde_json::json!({"title":"The Matrix"}),
1633            }],
1634        })
1635        .unwrap()
1636    }
1637
1638    fn runtime_client(transport: MockTransport) -> NakoRuntimeClient<MockTransport> {
1639        NakoRuntimeClient::with_transport(
1640            NakoRuntimeClientConfig {
1641                base_url: "https://nako.example".to_owned(),
1642                addon_token: "addon-token-secret".to_owned(),
1643                timeout_ms: 9_000,
1644            },
1645            transport,
1646        )
1647    }
1648
1649    fn runtime_side_effect_response_json(permission: &str, target_kind: &str) -> String {
1650        serde_json::json!({
1651            "side_effect": {
1652                "id": "effect-1",
1653                "permission": permission,
1654                "library_id": "library-1",
1655                "target": {"kind": target_kind, "id": "source-1"},
1656                "idempotency_key": "demo-1",
1657                "validation_status": "accepted",
1658                "safe_error_code": null,
1659                "apply_status": "applied",
1660                "apply_error_code": null,
1661                "applied_item_id": "item-1",
1662                "applied_source": "addon:addon-1",
1663                "apply_report": null
1664            },
1665            "idempotent_replay": false
1666        })
1667        .to_string()
1668    }
1669
1670    fn header_value<'a>(request: &'a AddonHttpRequest, name: &str) -> Option<&'a str> {
1671        request
1672            .headers
1673            .iter()
1674            .find(|(candidate, _)| candidate == name)
1675            .map(|(_, value)| value.as_str())
1676    }
1677
1678    #[derive(Clone, Default)]
1679    struct MockTransport {
1680        responses: Arc<Mutex<VecDeque<AddonClientResult<AddonHttpResponse>>>>,
1681        requests: Arc<Mutex<Vec<AddonHttpRequest>>>,
1682    }
1683
1684    impl MockTransport {
1685        fn with_response(response: AddonClientResult<AddonHttpResponse>) -> Self {
1686            let transport = Self::default();
1687            transport.push_response(response);
1688            transport
1689        }
1690
1691        fn push_response(&self, response: AddonClientResult<AddonHttpResponse>) {
1692            self.responses.lock().unwrap().push_back(response);
1693        }
1694
1695        fn requests(&self) -> Vec<AddonHttpRequest> {
1696            self.requests.lock().unwrap().clone()
1697        }
1698    }
1699
1700    #[async_trait::async_trait]
1701    impl AddonTransport for MockTransport {
1702        async fn post(&self, request: AddonHttpRequest) -> AddonClientResult<AddonHttpResponse> {
1703            self.requests.lock().unwrap().push(request);
1704            self.responses
1705                .lock()
1706                .unwrap()
1707                .pop_front()
1708                .unwrap_or_else(|| {
1709                    Err(AddonClientError::Http {
1710                        message: "mock transport response queue was empty".to_owned(),
1711                    })
1712                })
1713        }
1714    }
1715}