Skip to main content

nako_addon_protocol/
lib.rs

1use std::{collections::HashSet, fmt};
2
3use serde::{Deserialize, Serialize};
4
5pub const ADDON_PROTOCOL_VERSION: &str = "0.1.0-alpha.1";
6pub const SUPPORTED_ADDON_PROTOCOL_VERSIONS: &[&str] = &[ADDON_PROTOCOL_VERSION];
7pub const ADDON_RUNTIME_ACCESS_CHECK_PATH: &str = "/addon/v1/access-check";
8pub const ADDON_RUNTIME_SIDE_EFFECTS_PATH: &str = "/addon/v1/side-effects";
9pub const ADDON_RUNTIME_GENERATED_ARTIFACTS_PATH: &str = "/addon/v1/generated-artifacts";
10pub const ADDON_RUNTIME_ACQUISITION_INTAKE_CANDIDATES_PATH: &str =
11    "/addon/v1/acquisition/intake/candidates";
12pub const ADDON_RUNTIME_TASK_RUN_CLAIM_PATH: &str = "/addon/v1/task-runs/claim";
13pub const ADDON_RUNTIME_TASK_RUN_PROGRESS_PATH: &str = "/addon/v1/task-runs/progress";
14pub const ADDON_RUNTIME_TASK_RUN_COMPLETE_PATH: &str = "/addon/v1/task-runs/complete";
15pub const ADDON_RUNTIME_TASK_RUN_FAIL_PATH: &str = "/addon/v1/task-runs/fail";
16pub const ADDON_RUNTIME_TASK_RUN_CANCEL_PATH: &str = "/addon/v1/task-runs/cancel";
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
19pub struct AddonRuntimeRoute {
20    pub path: &'static str,
21    pub method: AddonRuntimeHttpMethod,
22    pub kind: AddonRuntimeRouteKind,
23}
24
25#[derive(Clone, Copy, Debug, Eq, PartialEq)]
26pub enum AddonRuntimeHttpMethod {
27    Post,
28}
29
30impl AddonRuntimeHttpMethod {
31    #[must_use]
32    pub const fn as_str(self) -> &'static str {
33        match self {
34            Self::Post => "POST",
35        }
36    }
37}
38
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum AddonRuntimeRouteKind {
41    AccessCheck,
42    SideEffect,
43    GeneratedArtifact,
44    AcquisitionIntakeCandidate,
45    TaskRunClaim,
46    TaskRunProgress,
47    TaskRunComplete,
48    TaskRunFail,
49    TaskRunCancel,
50}
51
52pub const ADDON_RUNTIME_ROUTES: &[AddonRuntimeRoute] = &[
53    AddonRuntimeRoute {
54        path: ADDON_RUNTIME_ACCESS_CHECK_PATH,
55        method: AddonRuntimeHttpMethod::Post,
56        kind: AddonRuntimeRouteKind::AccessCheck,
57    },
58    AddonRuntimeRoute {
59        path: ADDON_RUNTIME_SIDE_EFFECTS_PATH,
60        method: AddonRuntimeHttpMethod::Post,
61        kind: AddonRuntimeRouteKind::SideEffect,
62    },
63    AddonRuntimeRoute {
64        path: ADDON_RUNTIME_GENERATED_ARTIFACTS_PATH,
65        method: AddonRuntimeHttpMethod::Post,
66        kind: AddonRuntimeRouteKind::GeneratedArtifact,
67    },
68    AddonRuntimeRoute {
69        path: ADDON_RUNTIME_ACQUISITION_INTAKE_CANDIDATES_PATH,
70        method: AddonRuntimeHttpMethod::Post,
71        kind: AddonRuntimeRouteKind::AcquisitionIntakeCandidate,
72    },
73    AddonRuntimeRoute {
74        path: ADDON_RUNTIME_TASK_RUN_CLAIM_PATH,
75        method: AddonRuntimeHttpMethod::Post,
76        kind: AddonRuntimeRouteKind::TaskRunClaim,
77    },
78    AddonRuntimeRoute {
79        path: ADDON_RUNTIME_TASK_RUN_PROGRESS_PATH,
80        method: AddonRuntimeHttpMethod::Post,
81        kind: AddonRuntimeRouteKind::TaskRunProgress,
82    },
83    AddonRuntimeRoute {
84        path: ADDON_RUNTIME_TASK_RUN_COMPLETE_PATH,
85        method: AddonRuntimeHttpMethod::Post,
86        kind: AddonRuntimeRouteKind::TaskRunComplete,
87    },
88    AddonRuntimeRoute {
89        path: ADDON_RUNTIME_TASK_RUN_FAIL_PATH,
90        method: AddonRuntimeHttpMethod::Post,
91        kind: AddonRuntimeRouteKind::TaskRunFail,
92    },
93    AddonRuntimeRoute {
94        path: ADDON_RUNTIME_TASK_RUN_CANCEL_PATH,
95        method: AddonRuntimeHttpMethod::Post,
96        kind: AddonRuntimeRouteKind::TaskRunCancel,
97    },
98];
99
100#[must_use]
101pub fn addon_runtime_paths() -> impl ExactSizeIterator<Item = &'static str> {
102    ADDON_RUNTIME_ROUTES.iter().map(|route| route.path)
103}
104
105#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
106pub struct AddonManifest {
107    pub id: String,
108    pub name: String,
109    pub version: String,
110    pub protocol_version: String,
111    pub base_url: String,
112    pub description: Option<String>,
113    #[serde(default)]
114    pub resources: Vec<AddonResourceDeclaration>,
115    #[serde(default, skip_serializing_if = "Vec::is_empty")]
116    pub entry_points: Vec<AddonEntryPointDeclaration>,
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub hosted_pages: Vec<AddonHostedPageDeclaration>,
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub configuration_schema: Option<AddonConfigurationSchema>,
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub secret_reference_fields: Vec<AddonSecretReferenceFieldDeclaration>,
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub event_subscriptions: Vec<AddonEventSubscriptionDeclaration>,
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub tasks: Vec<AddonTaskDeclaration>,
127    pub auth: AddonAuth,
128    #[serde(default)]
129    pub default_timeout_ms: Option<u64>,
130    #[serde(default)]
131    pub default_max_attempts: Option<u32>,
132    #[serde(default)]
133    pub scopes: Vec<AddonScope>,
134}
135
136#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
137pub struct AddonInstallDescriptor {
138    pub manifest: AddonManifest,
139    pub runtime: AddonRuntimeRequirement,
140    #[serde(default, skip_serializing_if = "Vec::is_empty")]
141    pub secret_reference_bindings: Vec<AddonSecretReferenceBinding>,
142    #[serde(default, skip_serializing_if = "Vec::is_empty")]
143    pub install_notes: Vec<String>,
144}
145
146#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
147pub struct AddonRuntimeRequirement {
148    pub kind: AddonRuntimeKind,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub image: Option<String>,
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub binary: Option<String>,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub command: Option<String>,
155}
156
157#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
158#[serde(rename_all = "snake_case")]
159pub enum AddonRuntimeKind {
160    HttpSidecar,
161}
162
163#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
164pub struct AddonSecretReferenceBinding {
165    pub field_id: String,
166    pub secret_ref: String,
167}
168
169#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
170pub struct AddonInstallGuide {
171    pub manifest_id: String,
172    pub addon_name: String,
173    pub protocol_version: String,
174    pub runtime_kind: AddonRuntimeKind,
175    pub runtime_reference: AddonRuntimeReference,
176    pub base_url_scheme: String,
177    pub base_url_configured: bool,
178    pub declared_resources: Vec<AddonResource>,
179    pub declared_scopes: Vec<AddonScope>,
180    pub required_secret_fields: Vec<AddonInstallSecretField>,
181    pub provided_secret_refs: Vec<String>,
182    pub missing_required_secret_fields: Vec<String>,
183    pub has_configuration_schema: bool,
184    pub entry_point_count: u32,
185    pub hosted_page_count: u32,
186    pub task_count: u32,
187    pub event_subscription_count: u32,
188    pub install_steps: Vec<AddonInstallStep>,
189}
190
191#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
192pub struct AddonRuntimeReference {
193    pub kind: AddonRuntimeReferenceKind,
194    pub value: String,
195}
196
197#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
198#[serde(rename_all = "snake_case")]
199pub enum AddonRuntimeReferenceKind {
200    Image,
201    Binary,
202    Command,
203}
204
205#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
206pub struct AddonInstallSecretField {
207    pub id: String,
208    pub label: String,
209    pub required: bool,
210    pub provided: bool,
211}
212
213#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
214pub struct AddonInstallStep {
215    pub kind: AddonInstallStepKind,
216    pub summary: String,
217}
218
219#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
220#[serde(rename_all = "snake_case")]
221pub enum AddonInstallStepKind {
222    RunSidecar,
223    ConfigureSecretReference,
224    RegisterManifest,
225    GrantScopes,
226}
227
228#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
229pub struct AddonResourceDeclaration {
230    pub kind: AddonResource,
231    pub path: String,
232    #[serde(default)]
233    pub input_schema: Option<String>,
234    #[serde(default)]
235    pub output_schema: Option<String>,
236    #[serde(default)]
237    pub required_scopes: Vec<AddonScope>,
238    #[serde(default)]
239    pub timeout_ms: Option<u64>,
240    #[serde(default)]
241    pub max_attempts: Option<u32>,
242}
243
244#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
245#[serde(rename_all = "snake_case")]
246pub enum AddonResource {
247    Catalog,
248    Metadata,
249    Image,
250    Stream,
251    Subtitle,
252    Recommendation,
253    Automation,
254    Webhook,
255    RendererAdapter,
256}
257
258impl AddonResource {
259    #[must_use]
260    pub const fn as_str(self) -> &'static str {
261        match self {
262            Self::Catalog => "catalog",
263            Self::Metadata => "metadata",
264            Self::Image => "image",
265            Self::Stream => "stream",
266            Self::Subtitle => "subtitle",
267            Self::Recommendation => "recommendation",
268            Self::Automation => "automation",
269            Self::Webhook => "webhook",
270            Self::RendererAdapter => "renderer_adapter",
271        }
272    }
273}
274
275#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
276pub struct AddonEntryPointDeclaration {
277    pub id: String,
278    pub kind: AddonEntryPointKind,
279    pub label: String,
280    pub path: String,
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub hosted_page_id: Option<String>,
283    #[serde(default)]
284    pub required_scopes: Vec<AddonScope>,
285}
286
287impl AddonEntryPointDeclaration {
288    #[must_use]
289    pub fn hosted_page(
290        id: impl Into<String>,
291        kind: AddonEntryPointKind,
292        label: impl Into<String>,
293        path: impl Into<String>,
294        hosted_page_id: impl Into<String>,
295        required_scopes: Vec<AddonScope>,
296    ) -> Self {
297        Self {
298            id: id.into(),
299            kind,
300            label: label.into(),
301            path: path.into(),
302            hosted_page_id: Some(hosted_page_id.into()),
303            required_scopes,
304        }
305    }
306
307    #[must_use]
308    pub fn action(
309        id: impl Into<String>,
310        kind: AddonEntryPointKind,
311        label: impl Into<String>,
312        path: impl Into<String>,
313        required_scopes: Vec<AddonScope>,
314    ) -> Self {
315        Self {
316            id: id.into(),
317            kind,
318            label: label.into(),
319            path: path.into(),
320            hosted_page_id: None,
321            required_scopes,
322        }
323    }
324}
325
326#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
327#[serde(rename_all = "snake_case")]
328pub enum AddonEntryPointKind {
329    ItemAction,
330    LibraryAction,
331    AdminAction,
332    Settings,
333    Diagnostics,
334    TaskLauncher,
335}
336
337impl AddonEntryPointKind {
338    #[must_use]
339    pub const fn as_str(self) -> &'static str {
340        match self {
341            Self::ItemAction => "item_action",
342            Self::LibraryAction => "library_action",
343            Self::AdminAction => "admin_action",
344            Self::Settings => "settings",
345            Self::Diagnostics => "diagnostics",
346            Self::TaskLauncher => "task_launcher",
347        }
348    }
349}
350
351#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
352pub struct AddonHostedPageDeclaration {
353    pub id: String,
354    pub title: String,
355    pub path: String,
356    #[serde(default)]
357    pub required_scopes: Vec<AddonScope>,
358}
359
360impl AddonHostedPageDeclaration {
361    #[must_use]
362    pub fn new(
363        id: impl Into<String>,
364        title: impl Into<String>,
365        path: impl Into<String>,
366        required_scopes: Vec<AddonScope>,
367    ) -> Self {
368        Self {
369            id: id.into(),
370            title: title.into(),
371            path: path.into(),
372            required_scopes,
373        }
374    }
375}
376
377#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
378pub struct AddonConfigurationSchema {
379    pub schema_id: String,
380    pub schema: serde_json::Value,
381}
382
383impl AddonConfigurationSchema {
384    #[must_use]
385    pub fn new(schema_id: impl Into<String>, schema: serde_json::Value) -> Self {
386        Self {
387            schema_id: schema_id.into(),
388            schema,
389        }
390    }
391}
392
393#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
394pub struct AddonSecretReferenceFieldDeclaration {
395    pub id: String,
396    pub label: String,
397    #[serde(default)]
398    pub description: Option<String>,
399    #[serde(default)]
400    pub required: bool,
401}
402
403impl AddonSecretReferenceFieldDeclaration {
404    #[must_use]
405    pub fn new(
406        id: impl Into<String>,
407        label: impl Into<String>,
408        description: Option<String>,
409        required: bool,
410    ) -> Self {
411        Self {
412            id: id.into(),
413            label: label.into(),
414            description,
415            required,
416        }
417    }
418}
419
420#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
421pub struct AddonEventSubscriptionDeclaration {
422    pub id: String,
423    pub event_kind: String,
424    pub path: String,
425    #[serde(default)]
426    pub required_scopes: Vec<AddonScope>,
427    #[serde(default)]
428    pub filters: serde_json::Value,
429}
430
431impl AddonEventSubscriptionDeclaration {
432    #[must_use]
433    pub fn new(
434        id: impl Into<String>,
435        event_kind: impl Into<String>,
436        path: impl Into<String>,
437        required_scopes: Vec<AddonScope>,
438        filters: serde_json::Value,
439    ) -> Self {
440        Self {
441            id: id.into(),
442            event_kind: event_kind.into(),
443            path: path.into(),
444            required_scopes,
445            filters,
446        }
447    }
448}
449
450#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
451pub struct AddonTaskDeclaration {
452    pub id: String,
453    pub name: String,
454    pub path: String,
455    #[serde(default)]
456    pub description: Option<String>,
457    #[serde(default)]
458    pub required_scopes: Vec<AddonScope>,
459    #[serde(default)]
460    pub timeout_ms: Option<u64>,
461    #[serde(default)]
462    pub max_attempts: Option<u32>,
463}
464
465impl AddonTaskDeclaration {
466    #[must_use]
467    pub fn new(
468        id: impl Into<String>,
469        name: impl Into<String>,
470        path: impl Into<String>,
471        required_scopes: Vec<AddonScope>,
472    ) -> Self {
473        Self {
474            id: id.into(),
475            name: name.into(),
476            path: path.into(),
477            description: None,
478            required_scopes,
479            timeout_ms: None,
480            max_attempts: None,
481        }
482    }
483
484    #[must_use]
485    pub const fn with_execution_bounds(
486        mut self,
487        timeout_ms: Option<u64>,
488        max_attempts: Option<u32>,
489    ) -> Self {
490        self.timeout_ms = timeout_ms;
491        self.max_attempts = max_attempts;
492        self
493    }
494
495    #[must_use]
496    pub fn with_description(mut self, description: impl Into<String>) -> Self {
497        self.description = Some(description.into());
498        self
499    }
500}
501
502#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
503#[serde(rename_all = "snake_case")]
504pub enum AddonScope {
505    CatalogRead,
506    ItemMetadataRead,
507    ItemMetadataSuggest,
508    ImageRead,
509    SubtitleRead,
510    StreamUrlRead,
511    RecommendationWrite,
512    AutomationRun,
513    WebhookEventRead,
514    RendererAdapterRead,
515    RendererAdapterControl,
516}
517
518impl AddonScope {
519    #[must_use]
520    pub const fn as_str(self) -> &'static str {
521        match self {
522            Self::CatalogRead => "catalog_read",
523            Self::ItemMetadataRead => "item_metadata_read",
524            Self::ItemMetadataSuggest => "item_metadata_suggest",
525            Self::ImageRead => "image_read",
526            Self::SubtitleRead => "subtitle_read",
527            Self::StreamUrlRead => "stream_url_read",
528            Self::RecommendationWrite => "recommendation_write",
529            Self::AutomationRun => "automation_run",
530            Self::WebhookEventRead => "webhook_event_read",
531            Self::RendererAdapterRead => "renderer_adapter_read",
532            Self::RendererAdapterControl => "renderer_adapter_control",
533        }
534    }
535}
536
537#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
538#[serde(rename_all = "snake_case")]
539pub enum AddonAuth {
540    #[default]
541    None,
542    Bearer,
543    SharedSecret,
544}
545
546#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
547pub struct AddonResourceRequest {
548    pub protocol_version: String,
549    pub addon_id: String,
550    pub resource: AddonResource,
551    pub request_id: String,
552    pub payload: serde_json::Value,
553}
554
555#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
556pub struct AddonResourceResponse {
557    pub protocol_version: String,
558    pub addon_id: String,
559    pub resource: AddonResource,
560    pub request_id: String,
561    pub payload: serde_json::Value,
562    #[serde(default)]
563    pub artifacts: Vec<AddonArtifact>,
564}
565
566#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
567pub struct AddonTaskRequest {
568    pub protocol_version: String,
569    pub addon_id: String,
570    pub task_id: String,
571    pub job_id: String,
572    pub request_id: String,
573    pub attempt: u32,
574    #[serde(default)]
575    pub retry_of_job_id: Option<String>,
576    #[serde(default)]
577    pub library_id: Option<String>,
578    #[serde(default)]
579    pub source_id: Option<String>,
580    pub payload: serde_json::Value,
581}
582
583#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
584pub struct AddonTaskResponse {
585    pub protocol_version: String,
586    pub addon_id: String,
587    pub task_id: String,
588    pub job_id: String,
589    pub request_id: String,
590    #[serde(default)]
591    pub output: serde_json::Value,
592}
593
594#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
595#[serde(rename_all = "snake_case")]
596pub enum AddonRendererAdapterProtocol {
597    Chromecast,
598    DlnaRenderer,
599    Airplay,
600}
601
602impl AddonRendererAdapterProtocol {
603    #[must_use]
604    pub const fn as_str(self) -> &'static str {
605        match self {
606            Self::Chromecast => "chromecast",
607            Self::DlnaRenderer => "dlna_renderer",
608            Self::Airplay => "airplay",
609        }
610    }
611}
612
613#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
614#[serde(tag = "action", rename_all = "snake_case")]
615pub enum AddonRendererAdapterRequest {
616    InspectReadiness {
617        protocol: AddonRendererAdapterProtocol,
618    },
619    DiscoverTargets {
620        protocol: AddonRendererAdapterProtocol,
621        #[serde(default, skip_serializing_if = "Option::is_none")]
622        timeout_ms: Option<u64>,
623    },
624    DispatchCommand {
625        protocol: AddonRendererAdapterProtocol,
626        envelope: AddonRendererAdapterCommandEnvelope,
627    },
628}
629
630#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
631#[serde(tag = "kind", rename_all = "snake_case")]
632pub enum AddonRendererAdapterResponse {
633    Readiness {
634        readiness: AddonRendererAdapterReadiness,
635    },
636    Targets {
637        targets: Vec<AddonRendererAdapterTarget>,
638    },
639    CommandResult {
640        result: AddonRendererAdapterCommandResult,
641    },
642}
643
644#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
645pub struct AddonRendererAdapterReadiness {
646    pub protocol: AddonRendererAdapterProtocol,
647    pub status: AddonRendererAdapterReadinessStatus,
648    pub reason_code: String,
649    #[serde(default, skip_serializing_if = "Option::is_none")]
650    pub safe_message: Option<String>,
651}
652
653#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
654#[serde(rename_all = "snake_case")]
655pub enum AddonRendererAdapterReadinessStatus {
656    Ready,
657    Degraded,
658    ConfigurationRequired,
659    Unavailable,
660}
661
662impl AddonRendererAdapterReadinessStatus {
663    #[must_use]
664    pub const fn as_str(self) -> &'static str {
665        match self {
666            Self::Ready => "ready",
667            Self::Degraded => "degraded",
668            Self::ConfigurationRequired => "configuration_required",
669            Self::Unavailable => "unavailable",
670        }
671    }
672}
673
674#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
675pub struct AddonRendererAdapterTarget {
676    pub stable_device_id: String,
677    pub target_kind: AddonRendererAdapterProtocol,
678    pub display_name: String,
679    pub network_scope: AddonRendererAdapterNetworkScope,
680    pub media_capabilities: AddonRendererAdapterMediaCapabilities,
681    pub control_capabilities: AddonRendererAdapterControlCapabilities,
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub discovered_at_ms: Option<u64>,
684}
685
686#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
687#[serde(rename_all = "snake_case")]
688pub enum AddonRendererAdapterNetworkScope {
689    Local,
690    Remote,
691    Unknown,
692}
693
694impl AddonRendererAdapterNetworkScope {
695    #[must_use]
696    pub const fn as_str(self) -> &'static str {
697        match self {
698            Self::Local => "local",
699            Self::Remote => "remote",
700            Self::Unknown => "unknown",
701        }
702    }
703}
704
705#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
706pub struct AddonRendererAdapterMediaCapabilities {
707    pub direct_play: bool,
708    #[serde(default, skip_serializing_if = "Vec::is_empty")]
709    pub containers: Vec<String>,
710    #[serde(default, skip_serializing_if = "Vec::is_empty")]
711    pub video_codecs: Vec<String>,
712    #[serde(default, skip_serializing_if = "Vec::is_empty")]
713    pub audio_codecs: Vec<String>,
714}
715
716#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
717pub struct AddonRendererAdapterControlCapabilities {
718    pub play: bool,
719    pub pause: bool,
720    pub resume: bool,
721    pub seek: bool,
722    pub stop: bool,
723    pub set_volume: bool,
724}
725
726impl AddonRendererAdapterControlCapabilities {
727    #[must_use]
728    pub const fn basic_playback() -> Self {
729        Self {
730            play: true,
731            pause: true,
732            resume: true,
733            seek: true,
734            stop: true,
735            set_volume: false,
736        }
737    }
738}
739
740#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
741pub struct AddonRendererAdapterCommandEnvelope {
742    pub adapter_id: String,
743    pub stable_device_id: String,
744    pub target_kind: AddonRendererAdapterProtocol,
745    pub renderer_session_id: String,
746    pub playback_session_id: String,
747    pub source_id: String,
748    pub command: AddonRendererAdapterCommand,
749    #[serde(default, skip_serializing_if = "Option::is_none")]
750    pub position_ms: Option<u64>,
751    #[serde(default, skip_serializing_if = "Option::is_none")]
752    pub volume_percent: Option<u8>,
753    pub transport: AddonRendererAdapterTransport,
754}
755
756impl fmt::Debug for AddonRendererAdapterCommandEnvelope {
757    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
758        formatter
759            .debug_struct("AddonRendererAdapterCommandEnvelope")
760            .field("adapter_id", &self.adapter_id)
761            .field("stable_device_id", &self.stable_device_id)
762            .field("target_kind", &self.target_kind)
763            .field("renderer_session_id", &self.renderer_session_id)
764            .field("playback_session_id", &self.playback_session_id)
765            .field("source_id", &self.source_id)
766            .field("command", &self.command)
767            .field("position_ms", &self.position_ms)
768            .field("volume_percent", &self.volume_percent)
769            .field("transport", &self.transport)
770            .finish()
771    }
772}
773
774#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
775#[serde(rename_all = "snake_case")]
776pub enum AddonRendererAdapterCommand {
777    Play,
778    Pause,
779    Resume,
780    Seek,
781    Stop,
782    SetVolume,
783}
784
785impl AddonRendererAdapterCommand {
786    #[must_use]
787    pub const fn as_str(self) -> &'static str {
788        match self {
789            Self::Play => "play",
790            Self::Pause => "pause",
791            Self::Resume => "resume",
792            Self::Seek => "seek",
793            Self::Stop => "stop",
794            Self::SetVolume => "set_volume",
795        }
796    }
797}
798
799#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
800pub struct AddonRendererAdapterTransport {
801    pub mode: AddonRendererAdapterTransportMode,
802    pub expires_at: String,
803    pub urls: Vec<AddonRendererAdapterTransportUrl>,
804}
805
806impl fmt::Debug for AddonRendererAdapterTransport {
807    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
808        formatter
809            .debug_struct("AddonRendererAdapterTransport")
810            .field("mode", &self.mode)
811            .field("expires_at", &self.expires_at)
812            .field("url_count", &self.urls.len())
813            .finish()
814    }
815}
816
817#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
818#[serde(rename_all = "snake_case")]
819pub enum AddonRendererAdapterTransportMode {
820    Direct,
821    Remux,
822    Hls,
823}
824
825impl AddonRendererAdapterTransportMode {
826    #[must_use]
827    pub const fn as_str(self) -> &'static str {
828        match self {
829            Self::Direct => "direct",
830            Self::Remux => "remux",
831            Self::Hls => "hls",
832        }
833    }
834}
835
836#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
837pub struct AddonRendererAdapterTransportUrl {
838    pub kind: AddonRendererAdapterTransportUrlKind,
839    pub url: String,
840    pub content_type: String,
841    pub supports_range_requests: bool,
842}
843
844impl fmt::Debug for AddonRendererAdapterTransportUrl {
845    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
846        formatter
847            .debug_struct("AddonRendererAdapterTransportUrl")
848            .field("kind", &self.kind)
849            .field("url", &"<redacted>")
850            .field("content_type", &self.content_type)
851            .field("supports_range_requests", &self.supports_range_requests)
852            .finish()
853    }
854}
855
856#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
857#[serde(rename_all = "snake_case")]
858pub enum AddonRendererAdapterTransportUrlKind {
859    Stream,
860    Playlist,
861    SegmentBase,
862}
863
864impl AddonRendererAdapterTransportUrlKind {
865    #[must_use]
866    pub const fn as_str(self) -> &'static str {
867        match self {
868            Self::Stream => "stream",
869            Self::Playlist => "playlist",
870            Self::SegmentBase => "segment_base",
871        }
872    }
873}
874
875#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
876pub struct AddonRendererAdapterCommandResult {
877    pub stable_device_id: String,
878    pub command: AddonRendererAdapterCommand,
879    pub state: AddonRendererAdapterCommandState,
880    #[serde(default, skip_serializing_if = "Option::is_none")]
881    pub safe_reason_code: Option<String>,
882}
883
884#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
885#[serde(rename_all = "snake_case")]
886pub enum AddonRendererAdapterCommandState {
887    Accepted,
888    Rejected,
889    Failed,
890}
891
892impl AddonRendererAdapterCommandState {
893    #[must_use]
894    pub const fn as_str(self) -> &'static str {
895        match self {
896            Self::Accepted => "accepted",
897            Self::Rejected => "rejected",
898            Self::Failed => "failed",
899        }
900    }
901}
902
903#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
904pub struct AddonEventRequest {
905    pub protocol_version: String,
906    pub addon_id: String,
907    pub subscription_id: String,
908    pub event_id: String,
909    pub event_kind: String,
910    pub subject_kind: String,
911    pub subject_id: String,
912    pub occurred_at: String,
913    pub attempt: u32,
914    pub payload: serde_json::Value,
915}
916
917#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
918pub struct AddonEventResponse {
919    pub protocol_version: String,
920    pub addon_id: String,
921    pub subscription_id: String,
922    pub event_id: String,
923    #[serde(default)]
924    pub output: serde_json::Value,
925}
926
927#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
928pub struct AddonHealthCheckRequest {
929    pub protocol_version: String,
930    pub manifest_id: String,
931    pub request_id: String,
932    pub expected_addon_version: String,
933    pub expected_resource_count: usize,
934}
935
936#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
937pub struct AddonHealthCheckResponse {
938    pub protocol_version: String,
939    pub manifest_id: String,
940    pub status: AddonHealthStatus,
941    pub checked_at: String,
942    pub manifest: AddonHealthManifestFacts,
943    #[serde(default)]
944    pub diagnostics: serde_json::Value,
945}
946
947#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
948#[serde(rename_all = "snake_case")]
949pub enum AddonHealthStatus {
950    Ok,
951    Degraded,
952    Unhealthy,
953}
954
955#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
956pub struct AddonHealthManifestFacts {
957    pub addon_version: String,
958    pub resource_count: usize,
959}
960
961#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
962pub struct AddonArtifact {
963    pub kind: String,
964    pub payload: serde_json::Value,
965}
966
967#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
968#[serde(deny_unknown_fields)]
969pub struct AddonMetadataPatch {
970    #[serde(default)]
971    pub title: Option<String>,
972    #[serde(default)]
973    pub original_title: Option<String>,
974    #[serde(default)]
975    pub sort_title: Option<String>,
976    #[serde(default)]
977    pub overview: Option<String>,
978    #[serde(default)]
979    pub release_date: Option<String>,
980    #[serde(default)]
981    pub runtime_minutes: Option<u32>,
982    #[serde(default)]
983    pub tagline: Option<String>,
984    #[serde(default)]
985    pub genres: Option<Vec<String>>,
986    #[serde(default)]
987    pub tags: Option<Vec<String>>,
988    #[serde(default)]
989    pub ratings: Option<Vec<AddonMetadataContentRating>>,
990    #[serde(default)]
991    pub images: Option<Vec<AddonMetadataImage>>,
992    #[serde(default)]
993    pub credits: Option<Vec<AddonMetadataCredit>>,
994    #[serde(default)]
995    pub collections: Option<Vec<AddonMetadataCollection>>,
996    #[serde(default)]
997    pub studios: Option<Vec<AddonMetadataStudio>>,
998    #[serde(default)]
999    pub external_ids: Option<Vec<AddonMetadataExternalId>>,
1000}
1001
1002#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1003#[serde(deny_unknown_fields)]
1004pub struct AddonMetadataContentRating {
1005    pub source: String,
1006    pub value: String,
1007}
1008
1009#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1010#[serde(deny_unknown_fields)]
1011pub struct AddonMetadataImage {
1012    pub kind: String,
1013    pub uri: String,
1014    pub provider: String,
1015    #[serde(default)]
1016    pub width: Option<u32>,
1017    #[serde(default)]
1018    pub height: Option<u32>,
1019    #[serde(default)]
1020    pub language: Option<String>,
1021}
1022
1023#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1024#[serde(deny_unknown_fields)]
1025pub struct AddonMetadataCredit {
1026    pub name: String,
1027    pub role: String,
1028    #[serde(default)]
1029    pub character: Option<String>,
1030    #[serde(default)]
1031    pub order: Option<u32>,
1032    #[serde(default)]
1033    pub external_ids: Vec<AddonMetadataExternalId>,
1034}
1035
1036#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1037#[serde(deny_unknown_fields)]
1038pub struct AddonMetadataCollection {
1039    pub name: String,
1040    #[serde(default)]
1041    pub overview: Option<String>,
1042    #[serde(default)]
1043    pub sort_order: Option<u32>,
1044    #[serde(default)]
1045    pub external_ids: Vec<AddonMetadataExternalId>,
1046}
1047
1048#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1049#[serde(deny_unknown_fields)]
1050pub struct AddonMetadataStudio {
1051    pub name: String,
1052    #[serde(default)]
1053    pub external_ids: Vec<AddonMetadataExternalId>,
1054}
1055
1056#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
1057#[serde(deny_unknown_fields)]
1058pub struct AddonMetadataExternalId {
1059    pub provider: String,
1060    pub value: String,
1061}
1062
1063#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1064#[serde(rename_all = "snake_case")]
1065pub enum AddonArtworkIntent {
1066    ProposeArtwork,
1067}
1068
1069#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1070#[serde(rename_all = "snake_case")]
1071pub enum AddonArtworkKind {
1072    Poster,
1073    Backdrop,
1074    Logo,
1075    Banner,
1076    Thumbnail,
1077}
1078
1079impl AddonArtworkKind {
1080    #[must_use]
1081    pub const fn as_str(self) -> &'static str {
1082        match self {
1083            Self::Poster => "poster",
1084            Self::Backdrop => "backdrop",
1085            Self::Logo => "logo",
1086            Self::Banner => "banner",
1087            Self::Thumbnail => "thumbnail",
1088        }
1089    }
1090}
1091
1092#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1093#[serde(rename_all = "snake_case")]
1094pub enum AddonArtworkSourceKind {
1095    RemoteUrl,
1096}
1097
1098#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1099#[serde(deny_unknown_fields)]
1100pub struct AddonArtworkSourcePayload {
1101    pub kind: AddonArtworkSourceKind,
1102    pub url: String,
1103}
1104
1105#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1106#[serde(deny_unknown_fields)]
1107pub struct AddonArtworkWritePayload {
1108    pub intent: AddonArtworkIntent,
1109    pub kind: AddonArtworkKind,
1110    pub source: AddonArtworkSourcePayload,
1111    #[serde(default)]
1112    pub language: Option<String>,
1113    #[serde(default)]
1114    pub width: Option<u32>,
1115    #[serde(default)]
1116    pub height: Option<u32>,
1117}
1118
1119#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
1120#[serde(rename_all = "snake_case")]
1121pub enum AddonPermission {
1122    MetadataWrite,
1123    ArtworkWrite,
1124    SubtitleWrite,
1125    LibraryFileWrite,
1126}
1127
1128impl AddonPermission {
1129    #[must_use]
1130    pub const fn as_str(self) -> &'static str {
1131        match self {
1132            Self::MetadataWrite => "metadata_write",
1133            Self::ArtworkWrite => "artwork_write",
1134            Self::SubtitleWrite => "subtitle_write",
1135            Self::LibraryFileWrite => "library_file_write",
1136        }
1137    }
1138}
1139
1140#[derive(Clone, Copy, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)]
1141#[serde(rename_all = "snake_case")]
1142pub enum AddonSideEffectTargetKind {
1143    MediaItem,
1144    MediaSource,
1145}
1146
1147impl AddonSideEffectTargetKind {
1148    #[must_use]
1149    pub const fn as_str(self) -> &'static str {
1150        match self {
1151            Self::MediaItem => "media_item",
1152            Self::MediaSource => "media_source",
1153        }
1154    }
1155}
1156
1157#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1158pub struct AddonSideEffectTarget {
1159    pub kind: AddonSideEffectTargetKind,
1160    pub id: String,
1161}
1162
1163#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1164pub struct AddonAccessCheckRequest {
1165    pub permission: AddonPermission,
1166    #[serde(default, skip_serializing_if = "Option::is_none")]
1167    pub library_id: Option<String>,
1168}
1169
1170#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1171pub struct AddonAccessCheckResponse {
1172    pub addon_id: String,
1173    pub token_id: String,
1174    pub permission: AddonPermission,
1175    #[serde(default)]
1176    pub library_id: Option<String>,
1177    pub allowed: bool,
1178}
1179
1180#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1181pub struct SubmitAddonSideEffectRequest {
1182    pub permission: AddonPermission,
1183    pub library_id: String,
1184    pub target: AddonSideEffectTarget,
1185    pub idempotency_key: String,
1186    pub provenance: serde_json::Value,
1187    pub payload: serde_json::Value,
1188}
1189
1190#[derive(Clone, Debug)]
1191pub struct SubmitAddonMetadataWriteRequest {
1192    pub library_id: String,
1193    pub target: AddonSideEffectTarget,
1194    pub idempotency_key: String,
1195    pub provenance: serde_json::Value,
1196    pub patch: AddonMetadataPatch,
1197}
1198
1199#[derive(Clone, Debug)]
1200pub struct SubmitAddonArtworkWriteRequest {
1201    pub library_id: String,
1202    pub target: AddonSideEffectTarget,
1203    pub idempotency_key: String,
1204    pub provenance: serde_json::Value,
1205    pub artwork: AddonArtworkWritePayload,
1206}
1207
1208#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1209pub struct AddonSideEffectResponse {
1210    pub side_effect: AddonSideEffectSummary,
1211    pub idempotent_replay: bool,
1212}
1213
1214#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1215pub struct AddonSideEffectSummary {
1216    pub id: String,
1217    #[serde(default, skip_serializing_if = "Option::is_none")]
1218    pub addon_id: Option<String>,
1219    #[serde(default, skip_serializing_if = "Option::is_none")]
1220    pub token_id: Option<String>,
1221    pub permission: AddonPermission,
1222    pub library_id: String,
1223    pub target: AddonSideEffectTarget,
1224    pub idempotency_key: String,
1225    pub validation_status: String,
1226    #[serde(default)]
1227    pub safe_error_code: Option<String>,
1228    pub apply_status: String,
1229    #[serde(default)]
1230    pub apply_error_code: Option<String>,
1231    #[serde(default)]
1232    pub applied_item_id: Option<String>,
1233    #[serde(default)]
1234    pub applied_source: Option<String>,
1235    #[serde(default)]
1236    pub apply_report: Option<serde_json::Value>,
1237    #[serde(default, skip_serializing_if = "Option::is_none")]
1238    pub applied_at: Option<String>,
1239    #[serde(default, skip_serializing_if = "Option::is_none")]
1240    pub created_at: Option<String>,
1241}
1242
1243#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1244#[serde(rename_all = "snake_case")]
1245pub enum AddonLibraryFileRole {
1246    Nfo,
1247}
1248
1249impl AddonLibraryFileRole {
1250    #[must_use]
1251    pub const fn as_str(self) -> &'static str {
1252        match self {
1253            Self::Nfo => "nfo",
1254        }
1255    }
1256}
1257
1258#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
1259#[serde(rename_all = "snake_case")]
1260pub enum AddonLibraryFileWritePolicy {
1261    CreateMissing,
1262    ReplaceExistingPreserving,
1263}
1264
1265impl AddonLibraryFileWritePolicy {
1266    #[must_use]
1267    pub const fn as_str(self) -> &'static str {
1268        match self {
1269            Self::CreateMissing => "create_missing",
1270            Self::ReplaceExistingPreserving => "replace_existing_preserving",
1271        }
1272    }
1273}
1274
1275#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1276#[serde(deny_unknown_fields)]
1277pub struct AddonLibraryFileWritePayload {
1278    pub file_role: AddonLibraryFileRole,
1279    pub policy: AddonLibraryFileWritePolicy,
1280}
1281
1282#[derive(Clone, Debug, Eq, PartialEq)]
1283pub enum AddonManifestError {
1284    UnsupportedProtocolVersion {
1285        actual: String,
1286    },
1287    EmptyField {
1288        field: &'static str,
1289    },
1290    InvalidBaseUrl,
1291    InvalidResourcePath {
1292        path: String,
1293    },
1294    InvalidDeclarationPath {
1295        declaration: &'static str,
1296        path: String,
1297    },
1298    DuplicateResource {
1299        resource: AddonResource,
1300    },
1301    DuplicateDeclaration {
1302        declaration: &'static str,
1303        id: String,
1304    },
1305    UnknownHostedPageReference {
1306        entry_point_id: String,
1307        hosted_page_id: String,
1308    },
1309    EmptyResources,
1310    MissingDeclaredScope {
1311        resource: AddonResource,
1312        scope: AddonScope,
1313    },
1314    MissingDeclaredScopeForDeclaration {
1315        declaration: &'static str,
1316        scope: AddonScope,
1317    },
1318    InvalidConfigurationSchema {
1319        message: String,
1320    },
1321    InvalidTimeout {
1322        value: u64,
1323    },
1324    InvalidMaxAttempts {
1325        value: u32,
1326    },
1327    MissingAuthToken {
1328        auth: AddonAuth,
1329    },
1330    MissingRuntimeReference,
1331    InvalidRuntimeReference,
1332    UnknownSecretReferenceField {
1333        field_id: String,
1334    },
1335    DuplicateSecretReferenceBinding {
1336        field_id: String,
1337    },
1338    SecretReferenceContainsValue {
1339        field_id: String,
1340    },
1341    ResourceNotDeclared {
1342        resource: AddonResource,
1343    },
1344    EventSubscriptionNotDeclared {
1345        subscription_id: String,
1346    },
1347    TaskNotDeclared {
1348        task_id: String,
1349    },
1350    InvalidEnvelope {
1351        message: String,
1352    },
1353}
1354
1355impl fmt::Display for AddonManifestError {
1356    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1357        match self {
1358            Self::UnsupportedProtocolVersion { actual } => {
1359                write!(formatter, "unsupported addon protocol version: {actual}")
1360            }
1361            Self::EmptyField { field } => {
1362                write!(formatter, "addon manifest field is empty: {field}")
1363            }
1364            Self::InvalidBaseUrl => write!(formatter, "addon base_url must use http or https"),
1365            Self::InvalidResourcePath { path } => {
1366                write!(formatter, "addon resource path must be absolute: {path}")
1367            }
1368            Self::InvalidDeclarationPath { declaration, path } => {
1369                write!(
1370                    formatter,
1371                    "addon {declaration} path must be absolute: {path}"
1372                )
1373            }
1374            Self::DuplicateResource { resource } => {
1375                write!(formatter, "duplicate addon resource: {}", resource.as_str())
1376            }
1377            Self::DuplicateDeclaration { declaration, id } => {
1378                write!(formatter, "duplicate addon {declaration} declaration: {id}")
1379            }
1380            Self::UnknownHostedPageReference {
1381                entry_point_id,
1382                hosted_page_id,
1383            } => write!(
1384                formatter,
1385                "addon entry_point {entry_point_id} references unknown hosted_page {hosted_page_id}"
1386            ),
1387            Self::EmptyResources => write!(formatter, "addon manifest must declare resources"),
1388            Self::MissingDeclaredScope { resource, scope } => write!(
1389                formatter,
1390                "addon resource {} requires undeclared scope {}",
1391                resource.as_str(),
1392                scope.as_str()
1393            ),
1394            Self::MissingDeclaredScopeForDeclaration { declaration, scope } => write!(
1395                formatter,
1396                "addon {declaration} declaration requires undeclared scope {}",
1397                scope.as_str()
1398            ),
1399            Self::InvalidConfigurationSchema { message } => {
1400                write!(formatter, "invalid addon configuration schema: {message}")
1401            }
1402            Self::InvalidTimeout { value } => {
1403                write!(
1404                    formatter,
1405                    "addon timeout_ms is outside allowed range: {value}"
1406                )
1407            }
1408            Self::InvalidMaxAttempts { value } => {
1409                write!(
1410                    formatter,
1411                    "addon max_attempts is outside allowed range: {value}"
1412                )
1413            }
1414            Self::MissingAuthToken { auth } => {
1415                write!(
1416                    formatter,
1417                    "addon auth token is required for {auth:?} authentication"
1418                )
1419            }
1420            Self::MissingRuntimeReference => {
1421                write!(
1422                    formatter,
1423                    "addon install descriptor must declare an image, binary, or command"
1424                )
1425            }
1426            Self::InvalidRuntimeReference => {
1427                write!(
1428                    formatter,
1429                    "addon runtime reference must not contain credentials, URLs, or local paths"
1430                )
1431            }
1432            Self::UnknownSecretReferenceField { field_id } => {
1433                write!(
1434                    formatter,
1435                    "addon install descriptor references unknown secret field: {field_id}"
1436                )
1437            }
1438            Self::DuplicateSecretReferenceBinding { field_id } => {
1439                write!(
1440                    formatter,
1441                    "duplicate addon secret reference binding: {field_id}"
1442                )
1443            }
1444            Self::SecretReferenceContainsValue { field_id } => {
1445                write!(
1446                    formatter,
1447                    "addon secret reference binding {field_id} must contain a reference, not a secret value"
1448                )
1449            }
1450            Self::ResourceNotDeclared { resource } => {
1451                write!(
1452                    formatter,
1453                    "addon resource is not declared: {}",
1454                    resource.as_str()
1455                )
1456            }
1457            Self::EventSubscriptionNotDeclared { subscription_id } => {
1458                write!(
1459                    formatter,
1460                    "addon event subscription is not declared: {subscription_id}"
1461                )
1462            }
1463            Self::TaskNotDeclared { task_id } => {
1464                write!(formatter, "addon task is not declared: {task_id}")
1465            }
1466            Self::InvalidEnvelope { message } => {
1467                write!(formatter, "invalid addon envelope: {message}")
1468            }
1469        }
1470    }
1471}
1472
1473impl std::error::Error for AddonManifestError {}
1474
1475pub type AddonProtocolResult<T> = std::result::Result<T, AddonManifestError>;
1476
1477#[must_use]
1478pub fn is_supported_addon_protocol_version(protocol_version: &str) -> bool {
1479    SUPPORTED_ADDON_PROTOCOL_VERSIONS.contains(&protocol_version)
1480}
1481
1482pub fn validate_manifest(manifest: &AddonManifest) -> AddonProtocolResult<()> {
1483    validate_non_empty(&manifest.id, "id")?;
1484    validate_non_empty(&manifest.name, "name")?;
1485    validate_non_empty(&manifest.version, "version")?;
1486    if !is_supported_addon_protocol_version(&manifest.protocol_version) {
1487        return Err(AddonManifestError::UnsupportedProtocolVersion {
1488            actual: manifest.protocol_version.clone(),
1489        });
1490    }
1491    if !has_http_base_url(&manifest.base_url) {
1492        return Err(AddonManifestError::InvalidBaseUrl);
1493    }
1494    if manifest.resources.is_empty() {
1495        return Err(AddonManifestError::EmptyResources);
1496    }
1497    if let Some(timeout) = manifest.default_timeout_ms {
1498        validate_timeout(timeout)?;
1499    }
1500    if let Some(max_attempts) = manifest.default_max_attempts {
1501        validate_max_attempts(max_attempts)?;
1502    }
1503
1504    let declared_scopes = manifest.scopes.iter().copied().collect::<HashSet<_>>();
1505    let mut declared_resources = HashSet::new();
1506    for resource in &manifest.resources {
1507        if !declared_resources.insert(resource.kind) {
1508            return Err(AddonManifestError::DuplicateResource {
1509                resource: resource.kind,
1510            });
1511        }
1512        if !resource.path.starts_with('/') {
1513            return Err(AddonManifestError::InvalidResourcePath {
1514                path: resource.path.clone(),
1515            });
1516        }
1517        if let Some(timeout) = resource.timeout_ms {
1518            validate_timeout(timeout)?;
1519        }
1520        if let Some(max_attempts) = resource.max_attempts {
1521            validate_max_attempts(max_attempts)?;
1522        }
1523        for scope in &resource.required_scopes {
1524            if !declared_scopes.contains(scope) {
1525                return Err(AddonManifestError::MissingDeclaredScope {
1526                    resource: resource.kind,
1527                    scope: *scope,
1528                });
1529            }
1530        }
1531    }
1532    validate_manifest_declarations(manifest, &declared_scopes)?;
1533
1534    Ok(())
1535}
1536
1537pub fn ensure_scope_grant(
1538    manifest: &AddonManifest,
1539    resource: AddonResource,
1540    granted_scopes: &[AddonScope],
1541) -> AddonProtocolResult<()> {
1542    validate_manifest(manifest)?;
1543    let granted = granted_scopes.iter().copied().collect::<HashSet<_>>();
1544    let declaration = manifest
1545        .resources
1546        .iter()
1547        .find(|candidate| candidate.kind == resource)
1548        .ok_or(AddonManifestError::ResourceNotDeclared { resource })?;
1549
1550    for scope in &declaration.required_scopes {
1551        if !granted.contains(scope) {
1552            return Err(AddonManifestError::MissingDeclaredScope {
1553                resource,
1554                scope: *scope,
1555            });
1556        }
1557    }
1558
1559    Ok(())
1560}
1561
1562pub fn ensure_task_scope_grant(
1563    manifest: &AddonManifest,
1564    task_id: &str,
1565    granted_scopes: &[AddonScope],
1566) -> AddonProtocolResult<()> {
1567    validate_manifest(manifest)?;
1568    let granted = granted_scopes.iter().copied().collect::<HashSet<_>>();
1569    let declaration = manifest
1570        .tasks
1571        .iter()
1572        .find(|candidate| candidate.id == task_id)
1573        .ok_or_else(|| AddonManifestError::TaskNotDeclared {
1574            task_id: task_id.to_owned(),
1575        })?;
1576
1577    for scope in &declaration.required_scopes {
1578        if !granted.contains(scope) {
1579            return Err(AddonManifestError::MissingDeclaredScopeForDeclaration {
1580                declaration: "task",
1581                scope: *scope,
1582            });
1583        }
1584    }
1585
1586    Ok(())
1587}
1588
1589pub fn ensure_event_subscription_scope_grant(
1590    manifest: &AddonManifest,
1591    subscription_id: &str,
1592    granted_scopes: &[AddonScope],
1593) -> AddonProtocolResult<()> {
1594    validate_manifest(manifest)?;
1595    let granted = granted_scopes.iter().copied().collect::<HashSet<_>>();
1596    let declaration = manifest
1597        .event_subscriptions
1598        .iter()
1599        .find(|candidate| candidate.id == subscription_id)
1600        .ok_or_else(|| AddonManifestError::EventSubscriptionNotDeclared {
1601            subscription_id: subscription_id.to_owned(),
1602        })?;
1603
1604    for scope in &declaration.required_scopes {
1605        if !granted.contains(scope) {
1606            return Err(AddonManifestError::MissingDeclaredScopeForDeclaration {
1607                declaration: "event_subscription",
1608                scope: *scope,
1609            });
1610        }
1611    }
1612
1613    Ok(())
1614}
1615
1616pub fn validate_install_descriptor(descriptor: &AddonInstallDescriptor) -> AddonProtocolResult<()> {
1617    validate_manifest(&descriptor.manifest)?;
1618    validate_runtime_requirement(&descriptor.runtime)?;
1619
1620    let declared_secret_fields = descriptor
1621        .manifest
1622        .secret_reference_fields
1623        .iter()
1624        .map(|field| field.id.as_str())
1625        .collect::<HashSet<_>>();
1626    let mut bound_secret_fields = HashSet::new();
1627    for binding in &descriptor.secret_reference_bindings {
1628        validate_non_empty(&binding.field_id, "secret_reference_bindings.field_id")?;
1629        validate_non_empty(&binding.secret_ref, "secret_reference_bindings.secret_ref")?;
1630        if !declared_secret_fields.contains(binding.field_id.as_str()) {
1631            return Err(AddonManifestError::UnknownSecretReferenceField {
1632                field_id: binding.field_id.clone(),
1633            });
1634        }
1635        if !bound_secret_fields.insert(binding.field_id.clone()) {
1636            return Err(AddonManifestError::DuplicateSecretReferenceBinding {
1637                field_id: binding.field_id.clone(),
1638            });
1639        }
1640        if secret_reference_looks_like_value(&binding.secret_ref) {
1641            return Err(AddonManifestError::SecretReferenceContainsValue {
1642                field_id: binding.field_id.clone(),
1643            });
1644        }
1645    }
1646
1647    Ok(())
1648}
1649
1650#[must_use]
1651pub fn addon_install_guide(descriptor: &AddonInstallDescriptor) -> AddonInstallGuide {
1652    let provided_secret_refs = descriptor
1653        .secret_reference_bindings
1654        .iter()
1655        .map(|binding| binding.secret_ref.clone())
1656        .collect::<Vec<_>>();
1657    let provided_secret_fields = descriptor
1658        .secret_reference_bindings
1659        .iter()
1660        .map(|binding| binding.field_id.as_str())
1661        .collect::<HashSet<_>>();
1662    let required_secret_fields = descriptor
1663        .manifest
1664        .secret_reference_fields
1665        .iter()
1666        .map(|field| AddonInstallSecretField {
1667            id: field.id.clone(),
1668            label: field.label.clone(),
1669            required: field.required,
1670            provided: provided_secret_fields.contains(field.id.as_str()),
1671        })
1672        .collect::<Vec<_>>();
1673    let missing_required_secret_fields = required_secret_fields
1674        .iter()
1675        .filter(|field| field.required && !field.provided)
1676        .map(|field| field.id.clone())
1677        .collect::<Vec<_>>();
1678
1679    AddonInstallGuide {
1680        manifest_id: descriptor.manifest.id.clone(),
1681        addon_name: descriptor.manifest.name.clone(),
1682        protocol_version: descriptor.manifest.protocol_version.clone(),
1683        runtime_kind: descriptor.runtime.kind,
1684        runtime_reference: runtime_reference(&descriptor.runtime),
1685        base_url_scheme: base_url_scheme(&descriptor.manifest.base_url).unwrap_or_default(),
1686        base_url_configured: has_http_base_url(&descriptor.manifest.base_url),
1687        declared_resources: descriptor
1688            .manifest
1689            .resources
1690            .iter()
1691            .map(|resource| resource.kind)
1692            .collect(),
1693        declared_scopes: descriptor.manifest.scopes.clone(),
1694        required_secret_fields,
1695        provided_secret_refs,
1696        missing_required_secret_fields,
1697        has_configuration_schema: descriptor.manifest.configuration_schema.is_some(),
1698        entry_point_count: usize_to_u32(descriptor.manifest.entry_points.len()),
1699        hosted_page_count: usize_to_u32(descriptor.manifest.hosted_pages.len()),
1700        task_count: usize_to_u32(descriptor.manifest.tasks.len()),
1701        event_subscription_count: usize_to_u32(descriptor.manifest.event_subscriptions.len()),
1702        install_steps: install_steps(descriptor),
1703    }
1704}
1705
1706pub fn validate_resource_response(
1707    response: &AddonResourceResponse,
1708    manifest: &AddonManifest,
1709    resource: AddonResource,
1710    request_id: &str,
1711) -> AddonProtocolResult<()> {
1712    if !is_supported_addon_protocol_version(&response.protocol_version) {
1713        return Err(AddonManifestError::UnsupportedProtocolVersion {
1714            actual: response.protocol_version.clone(),
1715        });
1716    }
1717    if response.protocol_version != manifest.protocol_version {
1718        return Err(AddonManifestError::InvalidEnvelope {
1719            message: format!(
1720                "response protocol_version {} did not match manifest protocol_version {}",
1721                response.protocol_version, manifest.protocol_version
1722            ),
1723        });
1724    }
1725    if response.addon_id != manifest.id {
1726        return Err(AddonManifestError::InvalidEnvelope {
1727            message: format!(
1728                "response addon_id {} did not match {}",
1729                response.addon_id, manifest.id
1730            ),
1731        });
1732    }
1733    if response.resource != resource {
1734        return Err(AddonManifestError::InvalidEnvelope {
1735            message: format!(
1736                "response resource {} did not match {}",
1737                response.resource.as_str(),
1738                resource.as_str()
1739            ),
1740        });
1741    }
1742    if response.request_id != request_id {
1743        return Err(AddonManifestError::InvalidEnvelope {
1744            message: format!(
1745                "response request_id {} did not match {request_id}",
1746                response.request_id
1747            ),
1748        });
1749    }
1750
1751    Ok(())
1752}
1753
1754pub fn validate_task_response(
1755    response: &AddonTaskResponse,
1756    manifest: &AddonManifest,
1757    task_id: &str,
1758    job_id: &str,
1759    request_id: &str,
1760) -> AddonProtocolResult<()> {
1761    if !is_supported_addon_protocol_version(&response.protocol_version) {
1762        return Err(AddonManifestError::UnsupportedProtocolVersion {
1763            actual: response.protocol_version.clone(),
1764        });
1765    }
1766    if response.protocol_version != manifest.protocol_version {
1767        return Err(AddonManifestError::InvalidEnvelope {
1768            message: format!(
1769                "task response protocol_version {} did not match manifest protocol_version {}",
1770                response.protocol_version, manifest.protocol_version
1771            ),
1772        });
1773    }
1774    if response.addon_id != manifest.id {
1775        return Err(AddonManifestError::InvalidEnvelope {
1776            message: format!(
1777                "task response addon_id {} did not match {}",
1778                response.addon_id, manifest.id
1779            ),
1780        });
1781    }
1782    if response.task_id != task_id {
1783        return Err(AddonManifestError::InvalidEnvelope {
1784            message: format!(
1785                "task response task_id {} did not match {task_id}",
1786                response.task_id
1787            ),
1788        });
1789    }
1790    if response.job_id != job_id {
1791        return Err(AddonManifestError::InvalidEnvelope {
1792            message: format!(
1793                "task response job_id {} did not match {job_id}",
1794                response.job_id
1795            ),
1796        });
1797    }
1798    if response.request_id != request_id {
1799        return Err(AddonManifestError::InvalidEnvelope {
1800            message: format!(
1801                "task response request_id {} did not match {request_id}",
1802                response.request_id
1803            ),
1804        });
1805    }
1806
1807    Ok(())
1808}
1809
1810pub fn validate_event_response(
1811    response: &AddonEventResponse,
1812    manifest: &AddonManifest,
1813    subscription_id: &str,
1814    event_id: &str,
1815) -> AddonProtocolResult<()> {
1816    if !is_supported_addon_protocol_version(&response.protocol_version) {
1817        return Err(AddonManifestError::UnsupportedProtocolVersion {
1818            actual: response.protocol_version.clone(),
1819        });
1820    }
1821    if response.protocol_version != manifest.protocol_version {
1822        return Err(AddonManifestError::InvalidEnvelope {
1823            message: format!(
1824                "event response protocol_version {} did not match manifest protocol_version {}",
1825                response.protocol_version, manifest.protocol_version
1826            ),
1827        });
1828    }
1829    if response.addon_id != manifest.id {
1830        return Err(AddonManifestError::InvalidEnvelope {
1831            message: format!(
1832                "event response addon_id {} did not match {}",
1833                response.addon_id, manifest.id
1834            ),
1835        });
1836    }
1837    if response.subscription_id != subscription_id {
1838        return Err(AddonManifestError::InvalidEnvelope {
1839            message: format!(
1840                "event response subscription_id {} did not match {subscription_id}",
1841                response.subscription_id
1842            ),
1843        });
1844    }
1845    if response.event_id != event_id {
1846        return Err(AddonManifestError::InvalidEnvelope {
1847            message: format!(
1848                "event response event_id {} did not match {event_id}",
1849                response.event_id
1850            ),
1851        });
1852    }
1853
1854    Ok(())
1855}
1856
1857pub fn validate_health_check_response(
1858    response: &AddonHealthCheckResponse,
1859    manifest: &AddonManifest,
1860) -> AddonProtocolResult<()> {
1861    if !is_supported_addon_protocol_version(&response.protocol_version) {
1862        return Err(AddonManifestError::UnsupportedProtocolVersion {
1863            actual: response.protocol_version.clone(),
1864        });
1865    }
1866    if response.protocol_version != manifest.protocol_version {
1867        return Err(AddonManifestError::InvalidEnvelope {
1868            message: format!(
1869                "health protocol_version {} did not match manifest protocol_version {}",
1870                response.protocol_version, manifest.protocol_version
1871            ),
1872        });
1873    }
1874    if response.manifest_id != manifest.id {
1875        return Err(AddonManifestError::InvalidEnvelope {
1876            message: format!(
1877                "health manifest_id {} did not match {}",
1878                response.manifest_id, manifest.id
1879            ),
1880        });
1881    }
1882    if response.checked_at.trim().is_empty() {
1883        return Err(AddonManifestError::InvalidEnvelope {
1884            message: "health checked_at must not be empty".to_owned(),
1885        });
1886    }
1887    if response.manifest.addon_version != manifest.version {
1888        return Err(AddonManifestError::InvalidEnvelope {
1889            message: format!(
1890                "health addon_version {} did not match {}",
1891                response.manifest.addon_version, manifest.version
1892            ),
1893        });
1894    }
1895    if response.manifest.resource_count != manifest.resources.len() {
1896        return Err(AddonManifestError::InvalidEnvelope {
1897            message: format!(
1898                "health resource_count {} did not match {}",
1899                response.manifest.resource_count,
1900                manifest.resources.len()
1901            ),
1902        });
1903    }
1904
1905    Ok(())
1906}
1907
1908fn validate_non_empty(value: &str, field: &'static str) -> AddonProtocolResult<()> {
1909    if value.trim().is_empty() {
1910        Err(AddonManifestError::EmptyField { field })
1911    } else {
1912        Ok(())
1913    }
1914}
1915
1916fn validate_manifest_declarations(
1917    manifest: &AddonManifest,
1918    declared_scopes: &HashSet<AddonScope>,
1919) -> AddonProtocolResult<()> {
1920    let mut entry_point_ids = HashSet::new();
1921    for entry_point in &manifest.entry_points {
1922        validate_non_empty(&entry_point.id, "entry_points.id")?;
1923        validate_non_empty(&entry_point.label, "entry_points.label")?;
1924        validate_absolute_declaration_path("entry_point", &entry_point.path)?;
1925        validate_unique_declaration_id("entry_point", &entry_point.id, &mut entry_point_ids)?;
1926        validate_declared_scopes("entry_point", &entry_point.required_scopes, declared_scopes)?;
1927    }
1928
1929    let mut hosted_page_ids = HashSet::new();
1930    for hosted_page in &manifest.hosted_pages {
1931        validate_non_empty(&hosted_page.id, "hosted_pages.id")?;
1932        validate_non_empty(&hosted_page.title, "hosted_pages.title")?;
1933        validate_absolute_declaration_path("hosted_page", &hosted_page.path)?;
1934        validate_unique_declaration_id("hosted_page", &hosted_page.id, &mut hosted_page_ids)?;
1935        validate_declared_scopes("hosted_page", &hosted_page.required_scopes, declared_scopes)?;
1936    }
1937
1938    for entry_point in &manifest.entry_points {
1939        if let Some(hosted_page_id) = &entry_point.hosted_page_id
1940            && !hosted_page_ids.contains(hosted_page_id)
1941        {
1942            return Err(AddonManifestError::UnknownHostedPageReference {
1943                entry_point_id: entry_point.id.clone(),
1944                hosted_page_id: hosted_page_id.clone(),
1945            });
1946        }
1947    }
1948
1949    if let Some(configuration_schema) = &manifest.configuration_schema {
1950        validate_non_empty(
1951            &configuration_schema.schema_id,
1952            "configuration_schema.schema_id",
1953        )?;
1954        if !configuration_schema.schema.is_object() {
1955            return Err(AddonManifestError::InvalidConfigurationSchema {
1956                message: "schema must be a JSON object".to_owned(),
1957            });
1958        }
1959    }
1960
1961    let mut secret_reference_ids = HashSet::new();
1962    for secret_reference in &manifest.secret_reference_fields {
1963        validate_non_empty(&secret_reference.id, "secret_reference_fields.id")?;
1964        validate_non_empty(&secret_reference.label, "secret_reference_fields.label")?;
1965        validate_unique_declaration_id(
1966            "secret_reference_field",
1967            &secret_reference.id,
1968            &mut secret_reference_ids,
1969        )?;
1970    }
1971
1972    let mut event_subscription_ids = HashSet::new();
1973    for event_subscription in &manifest.event_subscriptions {
1974        validate_non_empty(&event_subscription.id, "event_subscriptions.id")?;
1975        validate_non_empty(
1976            &event_subscription.event_kind,
1977            "event_subscriptions.event_kind",
1978        )?;
1979        validate_absolute_declaration_path("event_subscription", &event_subscription.path)?;
1980        validate_unique_declaration_id(
1981            "event_subscription",
1982            &event_subscription.id,
1983            &mut event_subscription_ids,
1984        )?;
1985        validate_declared_scopes(
1986            "event_subscription",
1987            &event_subscription.required_scopes,
1988            declared_scopes,
1989        )?;
1990    }
1991
1992    let mut task_ids = HashSet::new();
1993    for task in &manifest.tasks {
1994        validate_non_empty(&task.id, "tasks.id")?;
1995        validate_non_empty(&task.name, "tasks.name")?;
1996        validate_absolute_declaration_path("task", &task.path)?;
1997        validate_unique_declaration_id("task", &task.id, &mut task_ids)?;
1998        validate_declared_scopes("task", &task.required_scopes, declared_scopes)?;
1999        if let Some(timeout) = task.timeout_ms {
2000            validate_timeout(timeout)?;
2001        }
2002        if let Some(max_attempts) = task.max_attempts {
2003            validate_max_attempts(max_attempts)?;
2004        }
2005    }
2006
2007    Ok(())
2008}
2009
2010fn validate_absolute_declaration_path(
2011    declaration: &'static str,
2012    path: &str,
2013) -> AddonProtocolResult<()> {
2014    if path.starts_with('/') {
2015        Ok(())
2016    } else {
2017        Err(AddonManifestError::InvalidDeclarationPath {
2018            declaration,
2019            path: path.to_owned(),
2020        })
2021    }
2022}
2023
2024fn validate_unique_declaration_id(
2025    declaration: &'static str,
2026    id: &str,
2027    seen: &mut HashSet<String>,
2028) -> AddonProtocolResult<()> {
2029    if seen.insert(id.to_owned()) {
2030        Ok(())
2031    } else {
2032        Err(AddonManifestError::DuplicateDeclaration {
2033            declaration,
2034            id: id.to_owned(),
2035        })
2036    }
2037}
2038
2039fn validate_declared_scopes(
2040    declaration: &'static str,
2041    required_scopes: &[AddonScope],
2042    declared_scopes: &HashSet<AddonScope>,
2043) -> AddonProtocolResult<()> {
2044    for scope in required_scopes {
2045        if !declared_scopes.contains(scope) {
2046            return Err(AddonManifestError::MissingDeclaredScopeForDeclaration {
2047                declaration,
2048                scope: *scope,
2049            });
2050        }
2051    }
2052
2053    Ok(())
2054}
2055
2056fn validate_timeout(value: u64) -> AddonProtocolResult<()> {
2057    if (100..=120_000).contains(&value) {
2058        Ok(())
2059    } else {
2060        Err(AddonManifestError::InvalidTimeout { value })
2061    }
2062}
2063
2064fn validate_max_attempts(value: u32) -> AddonProtocolResult<()> {
2065    if (1..=10).contains(&value) {
2066        Ok(())
2067    } else {
2068        Err(AddonManifestError::InvalidMaxAttempts { value })
2069    }
2070}
2071
2072fn validate_runtime_requirement(runtime: &AddonRuntimeRequirement) -> AddonProtocolResult<()> {
2073    let runtime_reference_count = [&runtime.image, &runtime.binary, &runtime.command]
2074        .into_iter()
2075        .flatten()
2076        .count();
2077    if runtime_reference_count == 0 {
2078        return Err(AddonManifestError::MissingRuntimeReference);
2079    }
2080    if runtime_reference_count > 1 {
2081        return Err(AddonManifestError::InvalidRuntimeReference);
2082    }
2083
2084    for value in [&runtime.image, &runtime.binary, &runtime.command]
2085        .into_iter()
2086        .flatten()
2087    {
2088        validate_non_empty(value, "runtime")?;
2089        if runtime_reference_is_sensitive(value) {
2090            return Err(AddonManifestError::InvalidRuntimeReference);
2091        }
2092    }
2093
2094    Ok(())
2095}
2096
2097fn runtime_reference_is_sensitive(value: &str) -> bool {
2098    let lower = value.to_ascii_lowercase();
2099    value.contains('\\')
2100        || looks_like_local_path(value)
2101        || lower.starts_with("file:")
2102        || lower.contains("://")
2103        || lower.contains("token=")
2104        || lower.contains("secret=")
2105        || lower.contains("password=")
2106        || lower.contains("authorization:")
2107        || lower.contains("bearer ")
2108        || lower.contains("--env ")
2109        || lower.contains("-e ")
2110}
2111
2112fn looks_like_local_path(value: &str) -> bool {
2113    let value = value.trim();
2114    value.starts_with('/')
2115        || value.starts_with("./")
2116        || value.starts_with("../")
2117        || value.starts_with("~/")
2118        || value.as_bytes().get(1).is_some_and(|byte| *byte == b':')
2119}
2120
2121fn secret_reference_looks_like_value(value: &str) -> bool {
2122    let value = value.trim();
2123    if let Some(name) = value.strip_prefix("env:") {
2124        return !valid_environment_reference_name(name);
2125    }
2126    if let Some(name) = value.strip_prefix("secret:") {
2127        return !valid_named_secret_reference(name);
2128    }
2129    if let Some(name) = value.strip_prefix("nako:") {
2130        return !valid_named_secret_reference(name);
2131    }
2132
2133    let lower = value.to_ascii_lowercase();
2134    lower.contains("://")
2135        || lower.contains('=')
2136        || lower.starts_with("bearer ")
2137        || lower.contains("secret")
2138        || lower.contains("token")
2139        || lower.contains("password")
2140}
2141
2142fn valid_environment_reference_name(value: &str) -> bool {
2143    let mut chars = value.chars();
2144    let Some(first) = chars.next() else {
2145        return false;
2146    };
2147    (first == '_' || first.is_ascii_alphabetic())
2148        && chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
2149}
2150
2151fn valid_named_secret_reference(value: &str) -> bool {
2152    !value.is_empty()
2153        && value.chars().all(|character| {
2154            character == '_'
2155                || character == '-'
2156                || character == '.'
2157                || character == '/'
2158                || character.is_ascii_alphanumeric()
2159        })
2160}
2161
2162fn base_url_scheme(value: &str) -> Option<String> {
2163    value.split_once("://").map(|(scheme, _)| scheme.to_owned())
2164}
2165
2166fn runtime_reference(runtime: &AddonRuntimeRequirement) -> AddonRuntimeReference {
2167    if let Some(value) = runtime.image.as_ref() {
2168        return AddonRuntimeReference {
2169            kind: AddonRuntimeReferenceKind::Image,
2170            value: value.clone(),
2171        };
2172    }
2173    if let Some(value) = runtime.binary.as_ref() {
2174        return AddonRuntimeReference {
2175            kind: AddonRuntimeReferenceKind::Binary,
2176            value: value.clone(),
2177        };
2178    }
2179    let value = runtime.command.clone().unwrap_or_default();
2180    AddonRuntimeReference {
2181        kind: AddonRuntimeReferenceKind::Command,
2182        value,
2183    }
2184}
2185
2186fn usize_to_u32(value: usize) -> u32 {
2187    u32::try_from(value).unwrap_or(u32::MAX)
2188}
2189
2190fn install_steps(descriptor: &AddonInstallDescriptor) -> Vec<AddonInstallStep> {
2191    let mut steps = vec![AddonInstallStep {
2192        kind: AddonInstallStepKind::RunSidecar,
2193        summary: "Run the Addon Sidecar outside Nako using the declared runtime reference."
2194            .to_owned(),
2195    }];
2196    if descriptor
2197        .manifest
2198        .secret_reference_fields
2199        .iter()
2200        .any(|field| field.required)
2201    {
2202        steps.push(AddonInstallStep {
2203            kind: AddonInstallStepKind::ConfigureSecretReference,
2204            summary: "Configure required Secret References in Nako; do not paste secret values into the install guide."
2205                .to_owned(),
2206        });
2207    }
2208    steps.push(AddonInstallStep {
2209        kind: AddonInstallStepKind::RegisterManifest,
2210        summary:
2211            "Register the manifest through the Admin Addon API after the sidecar is reachable."
2212                .to_owned(),
2213    });
2214    if !descriptor.manifest.scopes.is_empty() {
2215        steps.push(AddonInstallStep {
2216            kind: AddonInstallStepKind::GrantScopes,
2217            summary:
2218                "Grant only the requested Addon Scopes and library access needed by this sidecar."
2219                    .to_owned(),
2220        });
2221    }
2222
2223    steps
2224}
2225
2226fn has_http_base_url(value: &str) -> bool {
2227    let Some((scheme, rest)) = value.split_once("://") else {
2228        return false;
2229    };
2230    if !matches!(scheme, "http" | "https") {
2231        return false;
2232    }
2233    let Some(authority) = rest.split(['/', '?', '#']).next() else {
2234        return false;
2235    };
2236    !authority.trim().is_empty() && !authority.contains(char::is_whitespace)
2237}
2238
2239#[cfg(test)]
2240mod tests {
2241    use super::*;
2242
2243    #[test]
2244    fn validates_manifest_resource_and_scope_contract() {
2245        let manifest = valid_manifest();
2246
2247        validate_manifest(&manifest).unwrap();
2248        ensure_scope_grant(
2249            &manifest,
2250            AddonResource::Metadata,
2251            &[
2252                AddonScope::ItemMetadataRead,
2253                AddonScope::ItemMetadataSuggest,
2254            ],
2255        )
2256        .unwrap();
2257    }
2258
2259    #[test]
2260    fn exposes_explicit_supported_protocol_versions() {
2261        assert_eq!(SUPPORTED_ADDON_PROTOCOL_VERSIONS, &[ADDON_PROTOCOL_VERSION]);
2262        assert!(is_supported_addon_protocol_version(ADDON_PROTOCOL_VERSION));
2263        assert!(!is_supported_addon_protocol_version("0.1.0-alpha.0"));
2264    }
2265
2266    #[test]
2267    fn exposes_explicit_addon_runtime_route_inventory() {
2268        let paths = addon_runtime_paths().collect::<Vec<_>>();
2269
2270        assert_eq!(
2271            paths,
2272            vec![
2273                ADDON_RUNTIME_ACCESS_CHECK_PATH,
2274                ADDON_RUNTIME_SIDE_EFFECTS_PATH,
2275                ADDON_RUNTIME_GENERATED_ARTIFACTS_PATH,
2276                ADDON_RUNTIME_ACQUISITION_INTAKE_CANDIDATES_PATH,
2277                ADDON_RUNTIME_TASK_RUN_CLAIM_PATH,
2278                ADDON_RUNTIME_TASK_RUN_PROGRESS_PATH,
2279                ADDON_RUNTIME_TASK_RUN_COMPLETE_PATH,
2280                ADDON_RUNTIME_TASK_RUN_FAIL_PATH,
2281                ADDON_RUNTIME_TASK_RUN_CANCEL_PATH,
2282            ]
2283        );
2284        assert!(paths.iter().all(|path| path.starts_with("/addon/v1/")));
2285        assert_eq!(
2286            ADDON_RUNTIME_ROUTES.len(),
2287            paths.iter().collect::<HashSet<_>>().len(),
2288            "Addon runtime route paths must stay unique"
2289        );
2290        assert!(
2291            ADDON_RUNTIME_ROUTES
2292                .iter()
2293                .all(|route| route.method == AddonRuntimeHttpMethod::Post)
2294        );
2295    }
2296
2297    #[test]
2298    fn rejects_invalid_manifest_shape() {
2299        let mut manifest = valid_manifest();
2300        manifest.protocol_version = "2020-01-01".to_owned();
2301        assert!(matches!(
2302            validate_manifest(&manifest),
2303            Err(AddonManifestError::UnsupportedProtocolVersion { .. })
2304        ));
2305
2306        let mut manifest = valid_manifest();
2307        manifest.resources[0].path = "metadata".to_owned();
2308        assert!(matches!(
2309            validate_manifest(&manifest),
2310            Err(AddonManifestError::InvalidResourcePath { .. })
2311        ));
2312
2313        let mut manifest = valid_manifest();
2314        manifest.resources.push(manifest.resources[0].clone());
2315        assert!(matches!(
2316            validate_manifest(&manifest),
2317            Err(AddonManifestError::DuplicateResource { .. })
2318        ));
2319
2320        let mut manifest = valid_manifest();
2321        manifest.base_url = "file:///tmp/addon".to_owned();
2322        assert!(matches!(
2323            validate_manifest(&manifest),
2324            Err(AddonManifestError::InvalidBaseUrl)
2325        ));
2326
2327        let mut manifest = valid_manifest();
2328        manifest.base_url = "https:///missing-authority".to_owned();
2329        assert!(matches!(
2330            validate_manifest(&manifest),
2331            Err(AddonManifestError::InvalidBaseUrl)
2332        ));
2333    }
2334
2335    #[test]
2336    fn validates_manifest_declaration_contracts() {
2337        let mut manifest = valid_manifest();
2338        manifest.hosted_pages = vec![AddonHostedPageDeclaration {
2339            id: "diagnostics".to_owned(),
2340            title: "Addon Diagnostics".to_owned(),
2341            path: "/ui/diagnostics".to_owned(),
2342            required_scopes: vec![AddonScope::ItemMetadataRead],
2343        }];
2344        manifest.entry_points = vec![AddonEntryPointDeclaration::hosted_page(
2345            "metadata-action",
2346            AddonEntryPointKind::ItemAction,
2347            "Suggest Metadata",
2348            "/ui/metadata-action",
2349            "diagnostics",
2350            vec![AddonScope::ItemMetadataSuggest],
2351        )];
2352        manifest.configuration_schema = Some(AddonConfigurationSchema {
2353            schema_id: "nako.reference.metadata.config.v1".to_owned(),
2354            schema: serde_json::json!({
2355                "type": "object",
2356                "properties": {
2357                    "language": { "type": "string" },
2358                    "api_key": { "type": "string", "x-nako-secret-reference": true }
2359                },
2360                "additionalProperties": false
2361            }),
2362        });
2363        manifest.secret_reference_fields = vec![AddonSecretReferenceFieldDeclaration {
2364            id: "api_key".to_owned(),
2365            label: "API Key".to_owned(),
2366            description: Some("Resolved by Nako when the addon is called".to_owned()),
2367            required: true,
2368        }];
2369        manifest.event_subscriptions = vec![AddonEventSubscriptionDeclaration {
2370            id: "library-scan-finished".to_owned(),
2371            event_kind: "library_scan.succeeded".to_owned(),
2372            path: "/events/library-scan-finished".to_owned(),
2373            required_scopes: vec![AddonScope::WebhookEventRead],
2374            filters: serde_json::json!({ "library_preset": "movies" }),
2375        }];
2376        manifest.tasks = vec![AddonTaskDeclaration {
2377            id: "bulk-metadata-scrape".to_owned(),
2378            name: "Bulk metadata scrape".to_owned(),
2379            path: "/tasks/bulk-metadata-scrape".to_owned(),
2380            description: Some("Runs metadata suggestions for selected items".to_owned()),
2381            required_scopes: vec![AddonScope::AutomationRun],
2382            timeout_ms: Some(30_000),
2383            max_attempts: Some(2),
2384        }];
2385        manifest
2386            .scopes
2387            .extend([AddonScope::WebhookEventRead, AddonScope::AutomationRun]);
2388
2389        validate_manifest(&manifest).unwrap();
2390    }
2391
2392    #[test]
2393    fn rejects_invalid_manifest_declarations() {
2394        let mut manifest = valid_manifest();
2395        manifest.entry_points = vec![AddonEntryPointDeclaration {
2396            id: "metadata-action".to_owned(),
2397            kind: AddonEntryPointKind::ItemAction,
2398            label: "Suggest Metadata".to_owned(),
2399            path: "ui/metadata-action".to_owned(),
2400            hosted_page_id: None,
2401            required_scopes: vec![AddonScope::ItemMetadataSuggest],
2402        }];
2403        assert!(matches!(
2404            validate_manifest(&manifest),
2405            Err(AddonManifestError::InvalidDeclarationPath {
2406                declaration: "entry_point",
2407                ..
2408            })
2409        ));
2410
2411        let mut manifest = valid_manifest();
2412        manifest.hosted_pages = vec![
2413            AddonHostedPageDeclaration {
2414                id: "diagnostics".to_owned(),
2415                title: "Diagnostics".to_owned(),
2416                path: "/ui/diagnostics".to_owned(),
2417                required_scopes: Vec::new(),
2418            },
2419            AddonHostedPageDeclaration {
2420                id: "diagnostics".to_owned(),
2421                title: "Diagnostics Duplicate".to_owned(),
2422                path: "/ui/diagnostics-2".to_owned(),
2423                required_scopes: Vec::new(),
2424            },
2425        ];
2426        assert!(matches!(
2427            validate_manifest(&manifest),
2428            Err(AddonManifestError::DuplicateDeclaration {
2429                declaration: "hosted_page",
2430                ..
2431            })
2432        ));
2433
2434        let mut manifest = valid_manifest();
2435        manifest.entry_points = vec![AddonEntryPointDeclaration {
2436            id: "metadata-action".to_owned(),
2437            kind: AddonEntryPointKind::ItemAction,
2438            label: "Suggest Metadata".to_owned(),
2439            path: "/ui/metadata-action".to_owned(),
2440            hosted_page_id: Some("missing-page".to_owned()),
2441            required_scopes: vec![AddonScope::ItemMetadataSuggest],
2442        }];
2443        assert!(matches!(
2444            validate_manifest(&manifest),
2445            Err(AddonManifestError::UnknownHostedPageReference { .. })
2446        ));
2447
2448        let mut manifest = valid_manifest();
2449        manifest.tasks = vec![AddonTaskDeclaration {
2450            id: "bulk-metadata-scrape".to_owned(),
2451            name: "Bulk metadata scrape".to_owned(),
2452            path: "/tasks/bulk-metadata-scrape".to_owned(),
2453            description: None,
2454            required_scopes: vec![AddonScope::AutomationRun],
2455            timeout_ms: Some(30_000),
2456            max_attempts: Some(2),
2457        }];
2458        assert!(matches!(
2459            validate_manifest(&manifest),
2460            Err(AddonManifestError::MissingDeclaredScopeForDeclaration {
2461                declaration: "task",
2462                scope: AddonScope::AutomationRun,
2463            })
2464        ));
2465
2466        let mut manifest = valid_manifest();
2467        manifest.configuration_schema = Some(AddonConfigurationSchema {
2468            schema_id: "nako.reference.metadata.config.v1".to_owned(),
2469            schema: serde_json::json!("not-an-object"),
2470        });
2471        assert!(matches!(
2472            validate_manifest(&manifest),
2473            Err(AddonManifestError::InvalidConfigurationSchema { .. })
2474        ));
2475    }
2476
2477    #[test]
2478    fn denies_missing_scope_grants() {
2479        let manifest = valid_manifest();
2480
2481        assert!(matches!(
2482            ensure_scope_grant(
2483                &manifest,
2484                AddonResource::Metadata,
2485                &[AddonScope::ItemMetadataRead]
2486            ),
2487            Err(AddonManifestError::MissingDeclaredScope { .. })
2488        ));
2489    }
2490
2491    #[test]
2492    fn resource_envelopes_round_trip() {
2493        let request = AddonResourceRequest {
2494            protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
2495            addon_id: "example".to_owned(),
2496            resource: AddonResource::Metadata,
2497            request_id: "request-1".to_owned(),
2498            payload: serde_json::json!({"item_id":"018f0000-0000-7000-8000-000000000001"}),
2499        };
2500        let json = serde_json::to_string(&request).unwrap();
2501
2502        assert_eq!(
2503            serde_json::from_str::<AddonResourceRequest>(&json).unwrap(),
2504            request
2505        );
2506    }
2507
2508    #[test]
2509    fn renderer_adapter_payload_contracts_round_trip_and_redact_debug() {
2510        let target = AddonRendererAdapterTarget {
2511            stable_device_id: "living-room-tv".to_owned(),
2512            target_kind: AddonRendererAdapterProtocol::Chromecast,
2513            display_name: "Living Room TV".to_owned(),
2514            network_scope: AddonRendererAdapterNetworkScope::Local,
2515            media_capabilities: AddonRendererAdapterMediaCapabilities {
2516                direct_play: true,
2517                containers: vec!["mp4".to_owned()],
2518                video_codecs: vec!["h264".to_owned()],
2519                audio_codecs: vec!["aac".to_owned()],
2520            },
2521            control_capabilities: AddonRendererAdapterControlCapabilities::basic_playback(),
2522            discovered_at_ms: Some(1_779_814_400_000),
2523        };
2524        let targets = AddonRendererAdapterResponse::Targets {
2525            targets: vec![target],
2526        };
2527        assert_eq!(
2528            serde_json::to_value(&targets).unwrap(),
2529            serde_json::json!({
2530                "kind": "targets",
2531                "targets": [{
2532                    "stable_device_id": "living-room-tv",
2533                    "target_kind": "chromecast",
2534                    "display_name": "Living Room TV",
2535                    "network_scope": "local",
2536                    "media_capabilities": {
2537                        "direct_play": true,
2538                        "containers": ["mp4"],
2539                        "video_codecs": ["h264"],
2540                        "audio_codecs": ["aac"]
2541                    },
2542                    "control_capabilities": {
2543                        "play": true,
2544                        "pause": true,
2545                        "resume": true,
2546                        "seek": true,
2547                        "stop": true,
2548                        "set_volume": false
2549                    },
2550                    "discovered_at_ms": 1779814400000u64
2551                }]
2552            })
2553        );
2554
2555        let envelope = AddonRendererAdapterCommandEnvelope {
2556            adapter_id: "nako.official.chromecast-renderer".to_owned(),
2557            stable_device_id: "living-room-tv".to_owned(),
2558            target_kind: AddonRendererAdapterProtocol::Chromecast,
2559            renderer_session_id: "018f0000-0000-7000-8000-000000000010".to_owned(),
2560            playback_session_id: "018f0000-0000-7000-8000-000000000011".to_owned(),
2561            source_id: "018f0000-0000-7000-8000-000000000012".to_owned(),
2562            command: AddonRendererAdapterCommand::Play,
2563            position_ms: Some(9_000),
2564            volume_percent: None,
2565            transport: AddonRendererAdapterTransport {
2566                mode: AddonRendererAdapterTransportMode::Direct,
2567                expires_at: "2026-05-27T12:00:00.000Z".to_owned(),
2568                urls: vec![AddonRendererAdapterTransportUrl {
2569                    kind: AddonRendererAdapterTransportUrlKind::Stream,
2570                    url: "https://nako.example/playback/stream?renderer_ticket=nako_rtt_secret"
2571                        .to_owned(),
2572                    content_type: "video/mp4".to_owned(),
2573                    supports_range_requests: true,
2574                }],
2575            },
2576        };
2577        let request = AddonRendererAdapterRequest::DispatchCommand {
2578            protocol: AddonRendererAdapterProtocol::Chromecast,
2579            envelope,
2580        };
2581        let json = serde_json::to_string(&request).unwrap();
2582
2583        assert_eq!(
2584            serde_json::from_str::<AddonRendererAdapterRequest>(&json).unwrap(),
2585            request
2586        );
2587        assert!(json.contains("renderer_ticket=nako_rtt_secret"));
2588
2589        let debug = format!("{request:?}").to_ascii_lowercase();
2590        for forbidden in ["renderer_ticket", "nako_rtt_secret", "bearer", "local://"] {
2591            assert!(
2592                !debug.contains(forbidden),
2593                "renderer adapter debug leaked forbidden term: {forbidden}"
2594            );
2595        }
2596    }
2597
2598    #[test]
2599    fn health_check_envelopes_validate_manifest_facts() {
2600        let manifest = valid_manifest();
2601        let request = AddonHealthCheckRequest {
2602            protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
2603            manifest_id: manifest.id.clone(),
2604            request_id: "health-1".to_owned(),
2605            expected_addon_version: manifest.version.clone(),
2606            expected_resource_count: manifest.resources.len(),
2607        };
2608        let response = AddonHealthCheckResponse {
2609            protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
2610            manifest_id: manifest.id.clone(),
2611            status: AddonHealthStatus::Ok,
2612            checked_at: "2026-05-21T12:00:00.000Z".to_owned(),
2613            manifest: AddonHealthManifestFacts {
2614                addon_version: manifest.version.clone(),
2615                resource_count: manifest.resources.len(),
2616            },
2617            diagnostics: serde_json::json!({"safe_note": "ok"}),
2618        };
2619
2620        assert_eq!(
2621            serde_json::from_str::<AddonHealthCheckRequest>(
2622                &serde_json::to_string(&request).unwrap()
2623            )
2624            .unwrap(),
2625            request
2626        );
2627        validate_health_check_response(&response, &manifest).unwrap();
2628
2629        let mut invalid = response;
2630        invalid.manifest.resource_count += 1;
2631        assert!(matches!(
2632            validate_health_check_response(&invalid, &manifest),
2633            Err(AddonManifestError::InvalidEnvelope { .. })
2634        ));
2635    }
2636
2637    #[test]
2638    fn protected_write_payload_contracts_keep_wire_shape() {
2639        let metadata = AddonMetadataPatch {
2640            title: Some("Demo".to_owned()),
2641            genres: Some(vec!["Drama".to_owned()]),
2642            ..AddonMetadataPatch::default()
2643        };
2644        assert_eq!(
2645            serde_json::to_value(&metadata).unwrap(),
2646            serde_json::json!({
2647                "title": "Demo",
2648                "original_title": null,
2649                "sort_title": null,
2650                "overview": null,
2651                "release_date": null,
2652                "runtime_minutes": null,
2653                "tagline": null,
2654                "genres": ["Drama"],
2655                "tags": null,
2656                "ratings": null,
2657                "images": null,
2658                "credits": null,
2659                "collections": null,
2660                "studios": null,
2661                "external_ids": null
2662            })
2663        );
2664
2665        let artwork = AddonArtworkWritePayload {
2666            intent: AddonArtworkIntent::ProposeArtwork,
2667            kind: AddonArtworkKind::Poster,
2668            source: AddonArtworkSourcePayload {
2669                kind: AddonArtworkSourceKind::RemoteUrl,
2670                url: "https://addon.example/poster.jpg".to_owned(),
2671            },
2672            language: Some("en".to_owned()),
2673            width: Some(1000),
2674            height: Some(1500),
2675        };
2676        assert_eq!(
2677            serde_json::to_value(&artwork).unwrap(),
2678            serde_json::json!({
2679                "intent": "propose_artwork",
2680                "kind": "poster",
2681                "source": {
2682                    "kind": "remote_url",
2683                    "url": "https://addon.example/poster.jpg"
2684                },
2685                "language": "en",
2686                "width": 1000,
2687                "height": 1500
2688            })
2689        );
2690
2691        let file_write = AddonLibraryFileWritePayload {
2692            file_role: AddonLibraryFileRole::Nfo,
2693            policy: AddonLibraryFileWritePolicy::ReplaceExistingPreserving,
2694        };
2695        assert_eq!(
2696            serde_json::to_value(&file_write).unwrap(),
2697            serde_json::json!({
2698                "file_role": "nfo",
2699                "policy": "replace_existing_preserving"
2700            })
2701        );
2702    }
2703
2704    #[test]
2705    fn addon_install_descriptor_generates_redacted_install_guide() {
2706        let mut manifest = valid_manifest();
2707        manifest.secret_reference_fields = vec![AddonSecretReferenceFieldDeclaration {
2708            id: "metadata_api_key".to_owned(),
2709            label: "Metadata API key".to_owned(),
2710            description: Some("Resolved by Nako at runtime".to_owned()),
2711            required: true,
2712        }];
2713        manifest.tasks = vec![AddonTaskDeclaration {
2714            id: "bulk-metadata-scrape".to_owned(),
2715            name: "Bulk metadata scrape".to_owned(),
2716            path: "/tasks/bulk-metadata-scrape".to_owned(),
2717            description: None,
2718            required_scopes: vec![AddonScope::AutomationRun],
2719            timeout_ms: Some(30_000),
2720            max_attempts: Some(2),
2721        }];
2722        manifest.event_subscriptions = vec![AddonEventSubscriptionDeclaration {
2723            id: "library-scan-finished".to_owned(),
2724            event_kind: "library_scan.succeeded".to_owned(),
2725            path: "/events/library-scan-finished".to_owned(),
2726            required_scopes: vec![AddonScope::WebhookEventRead],
2727            filters: serde_json::json!({ "library_preset": "movies" }),
2728        }];
2729        manifest
2730            .scopes
2731            .extend([AddonScope::AutomationRun, AddonScope::WebhookEventRead]);
2732        let descriptor = AddonInstallDescriptor {
2733            manifest,
2734            runtime: AddonRuntimeRequirement {
2735                kind: AddonRuntimeKind::HttpSidecar,
2736                image: Some("ghcr.io/nako/example-metadata-addon:0.1.0".to_owned()),
2737                binary: None,
2738                command: None,
2739            },
2740            secret_reference_bindings: vec![AddonSecretReferenceBinding {
2741                field_id: "metadata_api_key".to_owned(),
2742                secret_ref: "env:NAKO_METADATA_ADDON_API_KEY".to_owned(),
2743            }],
2744            install_notes: vec!["Use a reverse proxy if exposing the sidecar remotely.".to_owned()],
2745        };
2746
2747        validate_install_descriptor(&descriptor).unwrap();
2748        let guide = addon_install_guide(&descriptor);
2749        let body = serde_json::to_string(&guide).unwrap();
2750
2751        assert_eq!(guide.manifest_id, "example");
2752        assert_eq!(guide.runtime_kind, AddonRuntimeKind::HttpSidecar);
2753        assert_eq!(
2754            guide.runtime_reference,
2755            AddonRuntimeReference {
2756                kind: AddonRuntimeReferenceKind::Image,
2757                value: "ghcr.io/nako/example-metadata-addon:0.1.0".to_owned(),
2758            }
2759        );
2760        assert_eq!(guide.base_url_scheme, "https");
2761        assert_eq!(guide.task_count, 1);
2762        assert_eq!(guide.event_subscription_count, 1);
2763        assert_eq!(guide.required_secret_fields[0].id, "metadata_api_key");
2764        assert!(guide.required_secret_fields[0].provided);
2765        assert!(guide.missing_required_secret_fields.is_empty());
2766        assert!(
2767            guide
2768                .install_steps
2769                .iter()
2770                .any(|step| step.kind == AddonInstallStepKind::ConfigureSecretReference)
2771        );
2772        assert!(!body.contains("NAKO_METADATA_ADDON_API_KEY="));
2773        assert!(!body.contains("secret-value"));
2774        assert!(!body.contains("Bearer "));
2775        assert!(!body.contains("C:\\"));
2776        assert!(!body.contains("file:///"));
2777    }
2778
2779    #[test]
2780    fn addon_install_descriptor_rejects_secret_values_and_local_runtime_paths() {
2781        let mut descriptor = AddonInstallDescriptor {
2782            manifest: valid_manifest(),
2783            runtime: AddonRuntimeRequirement {
2784                kind: AddonRuntimeKind::HttpSidecar,
2785                image: None,
2786                binary: Some("C:\\addons\\metadata.exe".to_owned()),
2787                command: None,
2788            },
2789            secret_reference_bindings: Vec::new(),
2790            install_notes: Vec::new(),
2791        };
2792        assert!(matches!(
2793            validate_install_descriptor(&descriptor),
2794            Err(AddonManifestError::InvalidRuntimeReference)
2795        ));
2796
2797        descriptor.runtime.binary = Some("C:/addons/metadata.exe".to_owned());
2798        assert!(matches!(
2799            validate_install_descriptor(&descriptor),
2800            Err(AddonManifestError::InvalidRuntimeReference)
2801        ));
2802
2803        descriptor.runtime.binary = Some("nako-metadata-addon".to_owned());
2804        descriptor.runtime.command = Some("nako-metadata-addon --port 8080".to_owned());
2805        assert!(matches!(
2806            validate_install_descriptor(&descriptor),
2807            Err(AddonManifestError::InvalidRuntimeReference)
2808        ));
2809
2810        descriptor.runtime.binary = Some("nako-metadata-addon".to_owned());
2811        descriptor.runtime.command = None;
2812        descriptor
2813            .manifest
2814            .secret_reference_fields
2815            .push(AddonSecretReferenceFieldDeclaration {
2816                id: "metadata_api_key".to_owned(),
2817                label: "Metadata API key".to_owned(),
2818                description: None,
2819                required: true,
2820            });
2821        descriptor.secret_reference_bindings = vec![AddonSecretReferenceBinding {
2822            field_id: "metadata_api_key".to_owned(),
2823            secret_ref: "secret-value-token".to_owned(),
2824        }];
2825        assert!(matches!(
2826            validate_install_descriptor(&descriptor),
2827            Err(AddonManifestError::SecretReferenceContainsValue { .. })
2828        ));
2829
2830        descriptor.secret_reference_bindings = vec![AddonSecretReferenceBinding {
2831            field_id: "metadata_api_key".to_owned(),
2832            secret_ref: "env:NAKO_METADATA_ADDON_TOKEN".to_owned(),
2833        }];
2834        validate_install_descriptor(&descriptor).unwrap();
2835    }
2836
2837    fn valid_manifest() -> AddonManifest {
2838        AddonManifest {
2839            id: "example".to_owned(),
2840            name: "Example".to_owned(),
2841            version: "0.1.0".to_owned(),
2842            protocol_version: ADDON_PROTOCOL_VERSION.to_owned(),
2843            base_url: "https://example.test/addon".to_owned(),
2844            description: None,
2845            resources: vec![AddonResourceDeclaration {
2846                kind: AddonResource::Metadata,
2847                path: "/metadata".to_owned(),
2848                input_schema: Some("nako.metadata.request.v1".to_owned()),
2849                output_schema: Some("nako.metadata.response.v1".to_owned()),
2850                required_scopes: vec![
2851                    AddonScope::ItemMetadataRead,
2852                    AddonScope::ItemMetadataSuggest,
2853                ],
2854                timeout_ms: Some(5_000),
2855                max_attempts: Some(2),
2856            }],
2857            entry_points: Vec::new(),
2858            hosted_pages: Vec::new(),
2859            configuration_schema: None,
2860            secret_reference_fields: Vec::new(),
2861            event_subscriptions: Vec::new(),
2862            tasks: Vec::new(),
2863            auth: AddonAuth::Bearer,
2864            default_timeout_ms: Some(10_000),
2865            default_max_attempts: Some(2),
2866            scopes: vec![
2867                AddonScope::ItemMetadataRead,
2868                AddonScope::ItemMetadataSuggest,
2869            ],
2870        }
2871    }
2872}