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}