Skip to main content

everruns_core/
app.rs

1// App domain types
2//
3// Design Decision: Dual-ID pattern (see specs/id-schema.md)
4// - public_id: AppId (external, API-facing, client-supplied or auto-generated)
5// - internal_id: Uuid (internal PK, used for FK references, never exposed in API)
6//
7// An App binds a Harness + Agent to a distribution channel (Slack, AG-UI, etc.)
8// with a publish/unpublish lifecycle.
9//
10// Design Decision: Channel ingress is app-scoped because the App defines the
11// agent, harness, identity, and channel-specific configuration.
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15use uuid::Uuid;
16
17use crate::principal::PrincipalSummary;
18use crate::typed_id::{
19    AgentId, AgentIdentityId, AgentVersionId, AppChannelId, AppId, HarnessId, PrincipalId,
20};
21
22#[cfg(feature = "openapi")]
23use utoipa::ToSchema;
24
25/// App lifecycle status.
26/// - `draft`: App is configured but not accepting requests
27/// - `published`: App is live, accepting incoming requests
28/// - `archived`: App is hidden from listings and cannot be modified or assigned
29/// - `deleted`: App is a tombstone kept only for historical references
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31#[cfg_attr(feature = "openapi", derive(ToSchema))]
32#[cfg_attr(feature = "openapi", schema(example = "published"))]
33#[serde(rename_all = "lowercase")]
34pub enum AppStatus {
35    Draft,
36    Published,
37    Archived,
38    Deleted,
39}
40
41/// How an App resolves the Agent version it runs.
42#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
43#[cfg_attr(feature = "openapi", derive(ToSchema))]
44#[cfg_attr(feature = "openapi", schema(example = "pinned"))]
45#[serde(rename_all = "lowercase")]
46pub enum AgentVersionPolicy {
47    /// Resolve the agent's default_version_id at session creation/invocation time.
48    #[default]
49    Default,
50    /// Resolve the newest agent_versions row for the app's agent.
51    Latest,
52    /// Use the app's pinned agent_version_id.
53    Pinned,
54}
55
56impl std::fmt::Display for AgentVersionPolicy {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        match self {
59            AgentVersionPolicy::Default => write!(f, "default"),
60            AgentVersionPolicy::Latest => write!(f, "latest"),
61            AgentVersionPolicy::Pinned => write!(f, "pinned"),
62        }
63    }
64}
65
66impl From<&str> for AgentVersionPolicy {
67    fn from(s: &str) -> Self {
68        match s {
69            "latest" => AgentVersionPolicy::Latest,
70            "pinned" => AgentVersionPolicy::Pinned,
71            _ => AgentVersionPolicy::Default,
72        }
73    }
74}
75
76impl std::fmt::Display for AppStatus {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            AppStatus::Draft => write!(f, "draft"),
80            AppStatus::Published => write!(f, "published"),
81            AppStatus::Archived => write!(f, "archived"),
82            AppStatus::Deleted => write!(f, "deleted"),
83        }
84    }
85}
86
87impl From<&str> for AppStatus {
88    fn from(s: &str) -> Self {
89        match s {
90            "published" => AppStatus::Published,
91            "archived" => AppStatus::Archived,
92            "deleted" => AppStatus::Deleted,
93            _ => AppStatus::Draft,
94        }
95    }
96}
97
98/// Supported channel types for app distribution.
99#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
100#[cfg_attr(feature = "openapi", derive(ToSchema))]
101#[cfg_attr(feature = "openapi", schema(example = "webhook"))]
102#[serde(rename_all = "lowercase")]
103pub enum ChannelType {
104    Slack,
105    #[serde(rename = "ag_ui")]
106    AgUi,
107    Schedule,
108    Webhook,
109    /// Agent2Agent (A2A) protocol channel — JSON-RPC + API key.
110    A2a,
111    /// Free Communication Protocol channel — text-first HTTP ingress with an
112    /// optional handshake. See `specs/fcp-channel.md` and the upstream FCP
113    /// specification.
114    Fcp,
115    /// App-scoped, execution-only API key over native session routes.
116    /// See `specs/app-api-keys.md`.
117    #[serde(rename = "api_endpoint")]
118    ApiEndpoint,
119    /// Public Chat channel — an isolated, public-facing chat web app bound to a
120    /// single App's agent. Anonymous by default, with optional Google sign-in
121    /// and Cloudflare Turnstile bot mitigation. Reuses AG-UI streaming and the
122    /// shared App endpoint auth verifier. See `specs/public-chat.md`.
123    #[serde(rename = "public_chat")]
124    PublicChat,
125}
126
127impl std::fmt::Display for ChannelType {
128    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        match self {
130            ChannelType::Slack => write!(f, "slack"),
131            ChannelType::AgUi => write!(f, "ag_ui"),
132            ChannelType::Schedule => write!(f, "schedule"),
133            ChannelType::Webhook => write!(f, "webhook"),
134            ChannelType::A2a => write!(f, "a2a"),
135            ChannelType::Fcp => write!(f, "fcp"),
136            ChannelType::ApiEndpoint => write!(f, "api_endpoint"),
137            ChannelType::PublicChat => write!(f, "public_chat"),
138        }
139    }
140}
141
142impl ChannelType {
143    pub fn from_str_opt(s: &str) -> Option<Self> {
144        match s {
145            "slack" => Some(ChannelType::Slack),
146            "ag_ui" => Some(ChannelType::AgUi),
147            "schedule" => Some(ChannelType::Schedule),
148            "webhook" => Some(ChannelType::Webhook),
149            "a2a" => Some(ChannelType::A2a),
150            "fcp" => Some(ChannelType::Fcp),
151            "api_endpoint" => Some(ChannelType::ApiEndpoint),
152            "public_chat" => Some(ChannelType::PublicChat),
153            _ => None,
154        }
155    }
156}
157
158/// App configuration for deploying agents to channels.
159/// An app binds a harness and optional agent to distribution channels with a
160/// publish lifecycle.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162#[cfg_attr(feature = "openapi", derive(ToSchema))]
163pub struct App {
164    /// External identifier (app_<32-hex>). Shown as "id" in API.
165    #[serde(rename = "id")]
166    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "app_01933b5a000070008000000000000001"))]
167    pub public_id: AppId,
168    /// Internal UUID primary key. Used for FK references. Never exposed in API.
169    #[serde(skip, default = "Uuid::nil")]
170    pub internal_id: Uuid,
171    /// Organization ID. Internal only, not exposed in API.
172    #[serde(skip, default)]
173    pub org_id: i64,
174    /// Display name of the app.
175    pub name: String,
176    /// Human-readable description of what the app does.
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub description: Option<String>,
179    /// ID of the harness to use (format: harness_{32-hex}).
180    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "harness_01933b5a00007000800000000000001"))]
181    pub harness_id: HarnessId,
182    /// Optional ID of the agent to use (format: agent_{32-hex}).
183    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agent_01933b5a00007000800000000000001"))]
184    pub agent_id: Option<AgentId>,
185    /// Version resolution policy for the optional agent.
186    #[serde(default)]
187    pub agent_version_policy: AgentVersionPolicy,
188    /// Pinned agent version. Required when policy is `pinned`.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agentver_01933b5a00007000800000000000001"))]
191    pub agent_version_id: Option<AgentVersionId>,
192    /// Optional virtual identity that represents the app in unattended/channel execution.
193    #[serde(skip_serializing_if = "Option::is_none")]
194    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "identity_01933b5a00007000800000000000001"))]
195    pub agent_identity_id: Option<AgentIdentityId>,
196    /// Owning principal for this app.
197    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "principal_01933b5a000070008000000000000001"))]
198    pub owner_principal_id: PrincipalId,
199    /// Denormalized effective human owner of the owning principal lineage.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub resolved_owner_user_id: Option<Uuid>,
202    /// Owning principal summary.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub owner: Option<PrincipalSummary>,
205    /// Effective human owner summary.
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub effective_owner: Option<PrincipalSummary>,
208    /// Distribution channels attached to this app.
209    #[serde(default)]
210    pub channels: Vec<AppChannel>,
211    /// Current lifecycle status.
212    pub status: AppStatus,
213    /// Timestamp when the app was last published.
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub published_at: Option<DateTime<Utc>>,
216    /// Timestamp when the app was created.
217    pub created_at: DateTime<Utc>,
218    /// Timestamp when the app was last updated.
219    pub updated_at: DateTime<Utc>,
220    /// Timestamp when the app was archived.
221    #[serde(skip_serializing_if = "Option::is_none")]
222    pub archived_at: Option<DateTime<Utc>>,
223    /// Timestamp when the app was deleted.
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub deleted_at: Option<DateTime<Utc>>,
226}
227
228/// A single distribution channel attached to an App.
229/// Each channel has its own type, config, and lifecycle status.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231#[cfg_attr(feature = "openapi", derive(ToSchema))]
232pub struct AppChannel {
233    /// External identifier (appchan_<32-hex>). Shown as "id" in API.
234    #[serde(rename = "id")]
235    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "appchan_01933b5a000070008000000000000001"))]
236    pub public_id: AppChannelId,
237    /// Internal UUID primary key. Never exposed in API.
238    #[serde(skip, default = "Uuid::nil")]
239    pub internal_id: Uuid,
240    /// Channel type (e.g. slack).
241    pub channel_type: ChannelType,
242    /// Channel-specific configuration (validated per channel type).
243    #[serde(default)]
244    pub channel_config: serde_json::Value,
245    /// Whether this channel is enabled.
246    #[serde(default = "default_true")]
247    pub enabled: bool,
248    /// Timestamp when this channel was created.
249    pub created_at: DateTime<Utc>,
250    /// Timestamp when this channel was last updated.
251    pub updated_at: DateTime<Utc>,
252}
253
254fn default_true() -> bool {
255    true
256}
257
258impl AppChannel {
259    /// Parse channel_config as SlackChannelConfig. Returns None if not a Slack channel
260    /// or if the config is invalid.
261    pub fn slack_config(&self) -> Option<SlackChannelConfig> {
262        if self.channel_type != ChannelType::Slack {
263            return None;
264        }
265        serde_json::from_value(self.channel_config.clone()).ok()
266    }
267
268    /// Parse channel_config as AgUiChannelConfig. Returns None if not an AG-UI
269    /// channel or if the config is invalid.
270    pub fn ag_ui_config(&self) -> Option<AgUiChannelConfig> {
271        if self.channel_type != ChannelType::AgUi {
272            return None;
273        }
274        serde_json::from_value(self.channel_config.clone()).ok()
275    }
276
277    /// Parse channel_config as ScheduleChannelConfig. Returns None if not a
278    /// schedule channel or if the config is invalid.
279    pub fn schedule_config(&self) -> Option<ScheduleChannelConfig> {
280        if self.channel_type != ChannelType::Schedule {
281            return None;
282        }
283        serde_json::from_value(self.channel_config.clone()).ok()
284    }
285
286    /// Parse channel_config as WebhookChannelConfig. Returns None if not a
287    /// webhook channel or if the config is invalid.
288    pub fn webhook_config(&self) -> Option<WebhookChannelConfig> {
289        if self.channel_type != ChannelType::Webhook {
290            return None;
291        }
292        serde_json::from_value(self.channel_config.clone()).ok()
293    }
294
295    /// Parse channel_config as FcpChannelConfig. Returns None if not an FCP
296    /// channel or if the config is invalid.
297    pub fn fcp_config(&self) -> Option<FcpChannelConfig> {
298        if self.channel_type != ChannelType::Fcp {
299            return None;
300        }
301        serde_json::from_value(self.channel_config.clone()).ok()
302    }
303
304    /// Parse channel_config as A2aChannelConfig. Returns None if not an A2A
305    /// channel or if the config is invalid.
306    pub fn a2a_config(&self) -> Option<A2aChannelConfig> {
307        if self.channel_type != ChannelType::A2a {
308            return None;
309        }
310        serde_json::from_value(self.channel_config.clone()).ok()
311    }
312
313    /// Parse channel_config as ApiEndpointChannelConfig. Returns None if not an
314    /// api_endpoint channel or if the config is invalid.
315    pub fn api_endpoint_config(&self) -> Option<ApiEndpointChannelConfig> {
316        if self.channel_type != ChannelType::ApiEndpoint {
317            return None;
318        }
319        serde_json::from_value(self.channel_config.clone()).ok()
320    }
321
322    /// Parse channel_config as PublicChatChannelConfig. Returns None if not a
323    /// Public Chat channel or if the config is invalid.
324    pub fn public_chat_config(&self) -> Option<PublicChatChannelConfig> {
325        if self.channel_type != ChannelType::PublicChat {
326            return None;
327        }
328        serde_json::from_value(self.channel_config.clone()).ok()
329    }
330}
331
332impl App {
333    /// Find the first Slack channel on this app.
334    pub fn slack_channel(&self) -> Option<&AppChannel> {
335        self.channels
336            .iter()
337            .find(|ch| ch.channel_type == ChannelType::Slack && ch.enabled)
338    }
339
340    /// Find the first enabled AG-UI channel on this app.
341    pub fn ag_ui_channel(&self) -> Option<&AppChannel> {
342        self.channels
343            .iter()
344            .find(|ch| ch.channel_type == ChannelType::AgUi && ch.enabled)
345    }
346
347    /// Find the first enabled schedule channel on this app.
348    pub fn schedule_channel(&self) -> Option<&AppChannel> {
349        self.channels
350            .iter()
351            .find(|ch| ch.channel_type == ChannelType::Schedule && ch.enabled)
352    }
353
354    /// Find the first enabled webhook channel on this app.
355    pub fn webhook_channel(&self) -> Option<&AppChannel> {
356        self.channels
357            .iter()
358            .find(|ch| ch.channel_type == ChannelType::Webhook && ch.enabled)
359    }
360
361    /// Find the first enabled FCP channel on this app.
362    pub fn fcp_channel(&self) -> Option<&AppChannel> {
363        self.channels
364            .iter()
365            .find(|ch| ch.channel_type == ChannelType::Fcp && ch.enabled)
366    }
367
368    /// Find the first enabled A2A channel on this app.
369    pub fn a2a_channel(&self) -> Option<&AppChannel> {
370        self.channels
371            .iter()
372            .find(|ch| ch.channel_type == ChannelType::A2a && ch.enabled)
373    }
374
375    /// Find the first enabled api_endpoint channel on this app.
376    pub fn api_endpoint_channel(&self) -> Option<&AppChannel> {
377        self.channels
378            .iter()
379            .find(|ch| ch.channel_type == ChannelType::ApiEndpoint && ch.enabled)
380    }
381
382    /// Find the first enabled Public Chat channel on this app.
383    pub fn public_chat_channel(&self) -> Option<&AppChannel> {
384        self.channels
385            .iter()
386            .find(|ch| ch.channel_type == ChannelType::PublicChat && ch.enabled)
387    }
388
389    /// Find a channel by its public ID.
390    pub fn channel_by_id(&self, id: &AppChannelId) -> Option<&AppChannel> {
391        self.channels.iter().find(|ch| ch.public_id == *id)
392    }
393}
394
395/// Session strategy for incoming messages (how messages map to sessions).
396///
397/// This is the Slack-specific config type that serializes in `SlackChannelConfig`.
398/// Converts to/from the generic `SessionRoutingStrategy` in `crate::channel`.
399#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
400#[cfg_attr(feature = "openapi", derive(ToSchema))]
401#[serde(rename_all = "snake_case")]
402pub enum SessionStrategy {
403    /// Each Slack thread gets its own session (default).
404    #[default]
405    PerThread,
406    /// One session per channel.
407    PerChannel,
408    /// One session per user.
409    PerUser,
410}
411
412impl From<SessionStrategy> for crate::channel::SessionRoutingStrategy {
413    fn from(s: SessionStrategy) -> Self {
414        match s {
415            SessionStrategy::PerThread => Self::PerThread,
416            SessionStrategy::PerChannel => Self::PerChannel,
417            SessionStrategy::PerUser => Self::PerUser,
418        }
419    }
420}
421
422impl From<crate::channel::SessionRoutingStrategy> for SessionStrategy {
423    fn from(s: crate::channel::SessionRoutingStrategy) -> Self {
424        match s {
425            crate::channel::SessionRoutingStrategy::PerThread => Self::PerThread,
426            crate::channel::SessionRoutingStrategy::PerChannel => Self::PerChannel,
427            crate::channel::SessionRoutingStrategy::PerUser => Self::PerUser,
428        }
429    }
430}
431
432/// How replies are delivered back to Slack.
433///
434/// This is the Slack-specific config type that serializes in `SlackChannelConfig`.
435/// Converts to/from the generic `ChannelReplyMode` in `crate::channel`.
436#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
437#[cfg_attr(feature = "openapi", derive(ToSchema))]
438#[serde(rename_all = "snake_case")]
439pub enum SlackReplyMode {
440    /// Forward completed assistant messages directly to Slack.
441    #[default]
442    AllMessages,
443    /// Only send deterministic updates emitted via `report_progress`.
444    ReportProgressOnly,
445}
446
447impl From<SlackReplyMode> for crate::channel::ChannelReplyMode {
448    fn from(m: SlackReplyMode) -> Self {
449        match m {
450            SlackReplyMode::AllMessages => Self::AllMessages,
451            SlackReplyMode::ReportProgressOnly => Self::ReportProgressOnly,
452        }
453    }
454}
455
456impl From<crate::channel::ChannelReplyMode> for SlackReplyMode {
457    fn from(m: crate::channel::ChannelReplyMode) -> Self {
458        match m {
459            crate::channel::ChannelReplyMode::AllMessages => Self::AllMessages,
460            crate::channel::ChannelReplyMode::ReportProgressOnly => Self::ReportProgressOnly,
461        }
462    }
463}
464
465/// Typed Slack channel configuration.
466/// Parsed from the `channel_config` JSON field on App.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468#[cfg_attr(feature = "openapi", derive(ToSchema))]
469pub struct SlackChannelConfig {
470    /// Slack signing secret for verifying webhook requests.
471    pub signing_secret: String,
472    /// Slack Bot OAuth token for sending responses.
473    pub bot_token: String,
474    /// Slack channel ID to listen on (e.g., "C0123456789").
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub channel_id: Option<String>,
477    /// Slack team/workspace ID.
478    #[serde(skip_serializing_if = "Option::is_none")]
479    pub team_id: Option<String>,
480    /// How incoming messages map to sessions.
481    #[serde(default)]
482    pub session_strategy: SessionStrategy,
483    /// How replies are delivered back to Slack.
484    #[serde(default)]
485    pub reply_mode: SlackReplyMode,
486    /// Set when Slack successfully verifies the webhook URL (url_verification challenge).
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub webhook_verified_at: Option<DateTime<Utc>>,
489    /// Set when the first real message is received from Slack.
490    #[serde(skip_serializing_if = "Option::is_none")]
491    pub first_message_received_at: Option<DateTime<Utc>>,
492}
493
494/// Default session expiration for public channel threads (6 hours).
495pub const DEFAULT_SESSION_EXPIRATION_SECONDS: u32 = 6 * 60 * 60;
496
497/// Default public AG-UI text shown while a tool call is running.
498pub const DEFAULT_AG_UI_GENERIC_TOOL_TEXT: &str = "Working...";
499
500/// Public AG-UI tool activity visibility.
501#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
502#[cfg_attr(feature = "openapi", derive(ToSchema))]
503#[serde(rename_all = "snake_case")]
504pub enum AgUiToolVisibility {
505    /// Do not expose tool activity in public AG-UI streams.
506    None,
507    /// Expose only editable generic text, without tool names, args, or output.
508    #[default]
509    Generic,
510    /// Expose backend-authored narration, without raw tool names, args, or output.
511    Narrated,
512}
513
514/// App-published endpoint authentication mode.
515///
516/// Stored inline on `app_channels.channel_config.auth` so users can protect a
517/// single App/channel without first creating org-level identity-provider state.
518#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
519#[cfg_attr(feature = "openapi", derive(ToSchema))]
520#[serde(rename_all = "snake_case")]
521pub enum AppEndpointAuthMode {
522    Anonymous,
523    SharedSecret,
524    ApiKey,
525    GoogleOidc,
526    Oidc,
527    OAuth2Introspection,
528    HttpBasic,
529    Mtls,
530}
531
532/// OIDC/OAuth/basic/mTLS provider details for one App endpoint.
533#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
534#[cfg_attr(feature = "openapi", derive(ToSchema))]
535#[serde(tag = "type", rename_all = "snake_case")]
536pub enum AppEndpointAuthProviderConfig {
537    GoogleOidc {
538        client_id: String,
539        #[serde(default, skip_serializing_if = "Vec::is_empty")]
540        allowed_domains: Vec<String>,
541    },
542    Oidc {
543        issuer: String,
544        #[serde(default, skip_serializing_if = "Option::is_none")]
545        jwks_url: Option<String>,
546    },
547    OAuth2Introspection {
548        introspection_url: String,
549        #[serde(default, skip_serializing_if = "Option::is_none")]
550        client_id: Option<String>,
551        #[serde(default, skip_serializing_if = "Option::is_none")]
552        client_secret: Option<String>,
553    },
554    HttpBasic {
555        username: String,
556        #[serde(default, skip_serializing_if = "Option::is_none")]
557        password: Option<String>,
558        #[serde(default, skip_serializing_if = "Option::is_none")]
559        password_hash: Option<String>,
560    },
561    Mtls {
562        header_name: String,
563        #[serde(default, skip_serializing_if = "Vec::is_empty")]
564        allowed_values: Vec<String>,
565        /// Header the trusted reverse proxy uses to prove its identity.
566        /// Required. Configs without this field fail closed at verification time.
567        #[serde(default, skip_serializing_if = "Option::is_none")]
568        proxy_secret_header: Option<String>,
569        /// Shared secret the trusted proxy includes in `proxy_secret_header`.
570        /// Write-only: redacted in GET responses. See TM-AUTH-021.
571        #[serde(default, skip_serializing_if = "Option::is_none")]
572        proxy_secret: Option<String>,
573    },
574}
575
576/// Claim and credential requirements common to App endpoint auth providers.
577#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
578#[cfg_attr(feature = "openapi", derive(ToSchema))]
579pub struct AppEndpointAuthRequirements {
580    /// JWT `aud` values to require on inbound tokens. Empty list disables audience checking.
581    #[serde(default, skip_serializing_if = "Vec::is_empty")]
582    pub audiences: Vec<String>,
583    /// OAuth scope strings to require (space-delimited per scope entry). Empty list disables scope checking.
584    #[serde(default, skip_serializing_if = "Vec::is_empty")]
585    pub scopes: Vec<String>,
586    /// Arbitrary claim equality predicates. Empty map disables claim filtering.
587    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
588    pub claims: serde_json::Map<String, serde_json::Value>,
589    /// Allowlist of `sub` claim values. Empty list disables subject filtering.
590    #[serde(default, skip_serializing_if = "Vec::is_empty")]
591    pub subjects: Vec<String>,
592    /// Allowlist of group memberships (from `groups` claim). Empty list disables group filtering.
593    #[serde(default, skip_serializing_if = "Vec::is_empty")]
594    pub groups: Vec<String>,
595    /// Allowlist of email/identifier domains. Empty list disables domain filtering.
596    #[serde(default, skip_serializing_if = "Vec::is_empty")]
597    pub domains: Vec<String>,
598}
599
600/// Inline auth config for one App endpoint/channel.
601#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
602#[cfg_attr(feature = "openapi", derive(ToSchema))]
603#[cfg_attr(
604    feature = "openapi",
605    schema(example = json!({"mode": "api_key", "requirements": {"audiences": ["everruns-api"], "scopes": ["app:invoke"]}}))
606)]
607pub struct AppEndpointAuthConfig {
608    pub mode: AppEndpointAuthMode,
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub provider: Option<AppEndpointAuthProviderConfig>,
611    #[serde(default)]
612    pub requirements: AppEndpointAuthRequirements,
613}
614
615/// Typed AG-UI channel configuration.
616///
617/// Parsed from the `channel_config` JSON field on App.
618#[derive(Debug, Clone, Serialize, Deserialize)]
619#[cfg_attr(feature = "openapi", derive(ToSchema))]
620pub struct AgUiChannelConfig {
621    /// Whether anonymous access is allowed for this endpoint.
622    /// Enabled by default for the initial AG-UI rollout.
623    #[serde(default = "default_true")]
624    pub anonymous: bool,
625    /// Optional shared bearer token for the public AG-UI endpoint.
626    /// When set, requests must include the token in a supported header.
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub token: Option<String>,
629    /// How long (in seconds) a thread can be resumed after its session was
630    /// created. Once this elapses, the same `thread_id` cannot reuse the
631    /// existing session and must start a new one. `0` disables expiration.
632    /// Defaults to 6 hours.
633    #[serde(default = "default_session_expiration_seconds")]
634    pub session_expiration_seconds: u32,
635    /// Optional per-IP rate limit applied to this app's AG-UI endpoint, in
636    /// requests per minute. `None` or `Some(0)` disables the per-app limit
637    /// (the global API limit still applies). Set a positive value to enforce
638    /// a stricter cap on anonymous traffic for this app.
639    #[serde(default, skip_serializing_if = "Option::is_none")]
640    pub rate_limit_per_minute: Option<u32>,
641    /// Public tool activity visibility for anonymous AG-UI streams.
642    #[serde(default)]
643    pub tool_visibility: AgUiToolVisibility,
644    /// Generic public text shown when `tool_visibility` is `generic`.
645    #[serde(
646        default = "default_ag_ui_generic_tool_text",
647        skip_serializing_if = "is_default_ag_ui_generic_tool_text"
648    )]
649    pub generic_tool_text: String,
650    /// Optional inline auth config for this public endpoint. When omitted,
651    /// legacy `anonymous` + `token` behavior applies.
652    #[serde(default, skip_serializing_if = "Option::is_none")]
653    pub auth: Option<AppEndpointAuthConfig>,
654}
655
656fn default_session_expiration_seconds() -> u32 {
657    DEFAULT_SESSION_EXPIRATION_SECONDS
658}
659
660fn default_ag_ui_generic_tool_text() -> String {
661    DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string()
662}
663
664fn is_default_ag_ui_generic_tool_text(value: &str) -> bool {
665    value == DEFAULT_AG_UI_GENERIC_TOOL_TEXT
666}
667
668/// Default FCP handshake body. Returned by `GET` when the channel config does
669/// not override `handshake`. Kept generic so an unconfigured FCP endpoint
670/// still satisfies the FCP `SHOULD` for handshake responses.
671pub const DEFAULT_FCP_HANDSHAKE: &str = "FCP endpoint.\n\nPOST plain text or `application/json` (`{\"message\": \"...\"}`) to\nthis URL to talk to the agent. Replies are returned as `text/markdown`.\n\nSession state, when supported, is carried by the `fcp_session` cookie.";
672
673/// Default response timeout for a blocking FCP POST, in seconds.
674pub const DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS: u32 = 120;
675
676/// Typed FCP channel configuration.
677///
678/// FCP is intentionally schema-free at the wire layer (see
679/// `specs/fcp-channel.md`). The fields here only control server-side
680/// behavior — handshake content, a single shared bearer token, session
681/// reuse, rate limiting — and never constrain the body the actor sends in.
682///
683/// FCP deliberately exposes a **smaller** auth surface than AG-UI/A2A: it
684/// supports anonymous access and a single shared bearer token, and nothing
685/// else. Inline OIDC/HTTP-Basic/mTLS verifier modes are intentionally
686/// **not** wired here so the FCP ingress path does not share authentication
687/// machinery with other channels or with the main API's user auth stack.
688/// Operators that need IdP-backed auth in front of an FCP endpoint should
689/// terminate that at the edge (reverse proxy, IAP, mTLS) rather than asking
690/// the FCP handler to grow another auth mode.
691#[derive(Debug, Clone, Serialize, Deserialize)]
692#[cfg_attr(feature = "openapi", derive(ToSchema))]
693pub struct FcpChannelConfig {
694    /// Whether anonymous access is allowed for this endpoint. When `false`
695    /// a non-empty `token` must authenticate every `POST`.
696    #[serde(default = "default_true")]
697    pub anonymous: bool,
698    /// Optional shared bearer token. When set, callers must send
699    /// `Authorization: Bearer <token>` (or the `X-Everruns-FCP-Token`
700    /// header). Validated by constant-time comparison inside the FCP
701    /// handler — never via the shared App endpoint auth verifier.
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    pub token: Option<String>,
704    /// Markdown body returned for `GET` requests (the FCP handshake). When
705    /// omitted, a generic handshake derived from the app's name and
706    /// description is used.
707    #[serde(default, skip_serializing_if = "Option::is_none")]
708    pub handshake: Option<String>,
709    /// How long (in seconds) the FCP session cookie keeps a session
710    /// resumable. `0` disables expiration. Defaults to 6 hours so a
711    /// long-running conversation expires on a sensible cadence.
712    #[serde(default = "default_session_expiration_seconds")]
713    pub session_expiration_seconds: u32,
714    /// Optional per-IP rate limit (requests per minute) for the FCP endpoint.
715    /// `None` or `Some(0)` disables the per-app limit; the global API limit
716    /// still applies. Counted in an FCP-specific limiter namespace so it
717    /// cannot be shared or exhausted by other channels.
718    #[serde(default, skip_serializing_if = "Option::is_none")]
719    pub rate_limit_per_minute: Option<u32>,
720    /// Maximum number of seconds the FCP endpoint waits for the agent to
721    /// produce a reply before returning a `504`. Defaults to 120 s.
722    #[serde(default = "default_fcp_response_timeout_seconds")]
723    pub response_timeout_seconds: u32,
724}
725
726fn default_fcp_response_timeout_seconds() -> u32 {
727    DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
728}
729
730/// How app-triggered invocations route into sessions.
731#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
732#[cfg_attr(feature = "openapi", derive(ToSchema))]
733#[cfg_attr(feature = "openapi", schema(example = "shared_session"))]
734#[serde(rename_all = "snake_case")]
735pub enum InvocationSessionMode {
736    /// Reuse a single durable session for every invocation of the channel.
737    #[default]
738    SharedSession,
739    /// Create a fresh session for every invocation.
740    SessionPerInvocation,
741}
742
743/// Typed schedule channel configuration.
744///
745/// `message` is also the template body. `{{path.to.value}}` placeholders are
746/// expanded at invocation time.
747#[derive(Debug, Clone, Serialize, Deserialize)]
748#[cfg_attr(feature = "openapi", derive(ToSchema))]
749pub struct ScheduleChannelConfig {
750    /// Cron expression that drives the durable schedule.
751    pub cron_expression: String,
752    /// IANA timezone identifier for cron evaluation.
753    #[serde(default = "default_timezone")]
754    pub timezone: String,
755    /// Whether invocations reuse a stable session or create a new one.
756    #[serde(default)]
757    pub session_mode: InvocationSessionMode,
758    /// Message content or template sent when the schedule fires.
759    pub message: String,
760}
761
762/// Typed webhook channel configuration.
763///
764/// `message` is also the template body. `{{path.to.value}}` placeholders are
765/// expanded against the incoming webhook payload and metadata.
766#[derive(Debug, Clone, Serialize, Deserialize)]
767#[cfg_attr(feature = "openapi", derive(ToSchema))]
768pub struct WebhookChannelConfig {
769    /// Shared secret required from the incoming webhook request.
770    pub token: String,
771    /// Whether invocations reuse a stable session or create a new one.
772    #[serde(default)]
773    pub session_mode: InvocationSessionMode,
774    /// Message content or template sent when the webhook arrives.
775    pub message: String,
776    /// Optional per-IP rate limit applied to this app's webhook endpoint, in
777    /// requests per minute. `None` or `Some(0)` disables the per-channel limit
778    /// (the global API limit still applies). Mirrors
779    /// `A2aChannelConfig::rate_limit_per_minute` (EVE-627).
780    #[serde(default, skip_serializing_if = "Option::is_none")]
781    pub rate_limit_per_minute: Option<u32>,
782}
783
784fn default_timezone() -> String {
785    "UTC".to_string()
786}
787
788/// Typed A2A (Agent2Agent) channel configuration.
789///
790/// The plaintext API key is **never** stored. Only the SHA-256 hex hash and a
791/// non-secret display prefix are persisted. The plaintext is returned exactly
792/// once at create / regenerate time.
793///
794/// `message` is the template body. `{{path.to.value}}` placeholders expand
795/// against the incoming A2A request payload and metadata (see
796/// `specs/a2a-channel.md`).
797#[derive(Debug, Clone, Serialize, Deserialize)]
798#[cfg_attr(feature = "openapi", derive(ToSchema))]
799pub struct A2aChannelConfig {
800    /// SHA-256 hex digest of the API key.
801    pub api_key_hash: String,
802    /// Public, non-secret display prefix (e.g. `evra2a_abc1...`).
803    pub api_key_prefix: String,
804    /// Whether invocations reuse a stable session or create a new one.
805    #[serde(default)]
806    pub session_mode: InvocationSessionMode,
807    /// Message template rendered into the session per invocation.
808    pub message: String,
809    /// Optional human-readable agent name surfaced in the Agent Card.
810    #[serde(default, skip_serializing_if = "Option::is_none")]
811    pub agent_card_name: Option<String>,
812    /// Optional description surfaced in the Agent Card.
813    #[serde(default, skip_serializing_if = "Option::is_none")]
814    pub agent_card_description: Option<String>,
815    /// Optional per-IP rate limit applied to this app's A2A endpoint, in
816    /// requests per minute. `None` or `Some(0)` disables the per-channel
817    /// limit (the global API limit still applies). Set a positive value to
818    /// enforce a stricter cap on unattended agent-to-agent traffic for this
819    /// app. Mirrors `AgUiChannelConfig::rate_limit_per_minute`.
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub rate_limit_per_minute: Option<u32>,
822    /// Optional inline auth config for this A2A endpoint. When omitted,
823    /// legacy per-channel API-key behavior applies.
824    #[serde(default, skip_serializing_if = "Option::is_none")]
825    pub auth: Option<AppEndpointAuthConfig>,
826    /// Optional shared HMAC signing secret. When set, requests must include
827    /// `X-Everruns-A2A-Timestamp` + `X-Everruns-A2A-Signature` headers and
828    /// the server verifies an HMAC-SHA256 signature over the exact
829    /// basestring `v0:{timestamp}:{body}` (Slack-style, no whitespace
830    /// between segments) plus a 5-minute timestamp window plus a
831    /// signature-keyed dedup so a captured request cannot be replayed
832    /// while the API key is still valid (TM-A2A-010). When `None`, the
833    /// channel keeps the existing API-key-only behavior. Layered **on top
834    /// of** `auth` — independent concerns: `auth` selects who can call,
835    /// `signing_secret` adds replay protection on top.
836    #[serde(default, skip_serializing_if = "Option::is_none")]
837    pub signing_secret: Option<String>,
838}
839
840/// Typed api_endpoint channel configuration.
841///
842/// Exposes an app-scoped, execution-only API key over native session routes
843/// mounted under the app (`/v1/apps/{app_id}/api/{channel_id}/...`). The key is
844/// structurally execution-only: it reaches only these app-mounted routes and
845/// has no path to any management API. The plaintext key is **never** stored —
846/// only the SHA-256 hex hash and a non-secret display prefix are persisted, and
847/// the plaintext is returned exactly once at create / regenerate time. See
848/// `specs/app-api-keys.md`.
849#[derive(Debug, Clone, Serialize, Deserialize)]
850#[cfg_attr(feature = "openapi", derive(ToSchema))]
851pub struct ApiEndpointChannelConfig {
852    /// SHA-256 hex digest of the API key.
853    pub api_key_hash: String,
854    /// Public, non-secret display prefix (e.g. `evr_app_abc1...`).
855    pub api_key_prefix: String,
856    /// Whether invocations reuse a stable session or create a new one.
857    #[serde(default)]
858    pub session_mode: InvocationSessionMode,
859    /// Optional per-IP rate limit applied to this app's api_endpoint, in
860    /// requests per minute. `None` or `Some(0)` disables the per-channel limit
861    /// (the global API limit still applies). Mirrors
862    /// `A2aChannelConfig::rate_limit_per_minute`.
863    #[serde(default, skip_serializing_if = "Option::is_none")]
864    pub rate_limit_per_minute: Option<u32>,
865    /// Optional inline auth config for this endpoint. When omitted, the
866    /// generated per-channel API-key bearer scheme applies.
867    #[serde(default, skip_serializing_if = "Option::is_none")]
868    pub auth: Option<AppEndpointAuthConfig>,
869}
870
871/// Branding shown on a Public Chat surface. All fields optional; the public app
872/// falls back to the App's name and the default design-system theme when unset.
873/// Branding is non-secret and is returned as-is in API responses.
874#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
875#[cfg_attr(feature = "openapi", derive(ToSchema))]
876pub struct PublicChatBranding {
877    /// Display name shown in the chat header. Falls back to the App name.
878    #[serde(default, skip_serializing_if = "Option::is_none")]
879    pub display_name: Option<String>,
880    /// Absolute URL of a logo image shown in the chat header.
881    #[serde(default, skip_serializing_if = "Option::is_none")]
882    pub logo_url: Option<String>,
883    /// Primary accent color as a CSS hex string (e.g. `#0A1636`). Applied by
884    /// overriding the design-system primary variable.
885    #[serde(default, skip_serializing_if = "Option::is_none")]
886    pub primary_color: Option<String>,
887    /// Welcome message shown before the visitor sends their first message.
888    #[serde(default, skip_serializing_if = "Option::is_none")]
889    pub welcome_message: Option<String>,
890}
891
892/// Bot-mitigation challenge provider for anonymous Public Chat access.
893#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
894#[cfg_attr(feature = "openapi", derive(ToSchema))]
895#[serde(rename_all = "snake_case")]
896pub enum CaptchaProvider {
897    /// Cloudflare Turnstile. The only provider supported in the first release.
898    #[default]
899    Turnstile,
900}
901
902/// CAPTCHA / bot-mitigation configuration for a Public Chat channel.
903///
904/// When present and enabled, anonymous visitors must pass a challenge before a
905/// session is created or any turn runs; signed-in visitors bypass it. The
906/// `secret_key` is write-only: it is stored to call the provider's verify
907/// endpoint server-side and is redacted in API responses (only
908/// `secret_key_configured: bool` is surfaced). The `site_key` is public and
909/// returned so the web app can render the widget.
910#[derive(Debug, Clone, Serialize, Deserialize)]
911#[cfg_attr(feature = "openapi", derive(ToSchema))]
912pub struct PublicChatCaptchaConfig {
913    /// Challenge provider. Defaults to Cloudflare Turnstile.
914    #[serde(default)]
915    pub provider: CaptchaProvider,
916    /// Whether the challenge is enforced. Defaults to true so adding a captcha
917    /// config turns it on; set to false to keep keys configured but inactive.
918    #[serde(default = "default_true")]
919    pub enabled: bool,
920    /// Public site key rendered by the client widget.
921    pub site_key: String,
922    /// Secret key used for server-side verification. Write-only; redacted on read.
923    #[serde(default, skip_serializing_if = "Option::is_none")]
924    pub secret_key: Option<String>,
925}
926
927/// Typed Public Chat channel configuration.
928///
929/// Parsed from the `channel_config` JSON field on App. Public Chat reuses
930/// AG-UI's streaming semantics and the shared App endpoint auth verifier, and
931/// adds branding and bot-mitigation tailored to a public, link-shareable chat
932/// website. See `specs/public-chat.md`.
933#[derive(Debug, Clone, Serialize, Deserialize)]
934#[cfg_attr(feature = "openapi", derive(ToSchema))]
935pub struct PublicChatChannelConfig {
936    /// Whether anonymous access is allowed. Anonymous-by-default mirrors AG-UI.
937    /// When `false`, visitors must authenticate (e.g. via `auth` Google OIDC).
938    #[serde(default = "default_true")]
939    pub anonymous: bool,
940    /// Optional shared bearer token for simple gated access. When set, requests
941    /// must include the token in a supported header. Redacted on read.
942    #[serde(default, skip_serializing_if = "Option::is_none")]
943    pub token: Option<String>,
944    /// How long (in seconds) a visitor session can be resumed before a fresh
945    /// one must be started. `0` disables expiration. Defaults to 6 hours.
946    #[serde(default = "default_session_expiration_seconds")]
947    pub session_expiration_seconds: u32,
948    /// Optional per-IP, per-app rate limit in requests per minute. `None` or
949    /// `Some(0)` disables the per-app cap (the global API limit still applies).
950    #[serde(default, skip_serializing_if = "Option::is_none")]
951    pub rate_limit_per_minute: Option<u32>,
952    /// Public tool activity visibility. Same rules as AG-UI: raw tool names,
953    /// args, results, and internal IDs are never exposed on public streams.
954    #[serde(default)]
955    pub tool_visibility: AgUiToolVisibility,
956    /// Generic public text shown when `tool_visibility` is `generic`/`narrated`.
957    #[serde(
958        default = "default_ag_ui_generic_tool_text",
959        skip_serializing_if = "is_default_ag_ui_generic_tool_text"
960    )]
961    pub generic_tool_text: String,
962    /// Optional inline auth config for this public endpoint (e.g. Google OIDC).
963    /// When omitted, anonymous + optional `token` behavior applies.
964    #[serde(default, skip_serializing_if = "Option::is_none")]
965    pub auth: Option<AppEndpointAuthConfig>,
966    /// Branding shown on the public chat surface.
967    #[serde(default, skip_serializing_if = "PublicChatBranding::is_empty")]
968    pub branding: PublicChatBranding,
969    /// Optional bot-mitigation / CAPTCHA configuration (Cloudflare Turnstile).
970    #[serde(default, skip_serializing_if = "Option::is_none")]
971    pub captcha: Option<PublicChatCaptchaConfig>,
972}
973
974impl PublicChatBranding {
975    /// True when no branding fields are set, so the whole object can be omitted
976    /// from serialized output.
977    pub fn is_empty(&self) -> bool {
978        self.display_name.is_none()
979            && self.logo_url.is_none()
980            && self.primary_color.is_none()
981            && self.welcome_message.is_none()
982    }
983}
984
985impl PublicChatChannelConfig {
986    /// Project the streaming-relevant fields onto an `AgUiChannelConfig` so the
987    /// shared AG-UI ingress/streaming core can serve Public Chat without a
988    /// parallel implementation. Branding and captcha are Public-Chat-only and
989    /// are handled by the Public Chat handler, not the shared core.
990    pub fn ag_ui_stream_config(&self) -> AgUiChannelConfig {
991        AgUiChannelConfig {
992            anonymous: self.anonymous,
993            token: self.token.clone(),
994            session_expiration_seconds: self.session_expiration_seconds,
995            rate_limit_per_minute: self.rate_limit_per_minute,
996            tool_visibility: self.tool_visibility,
997            generic_tool_text: self.generic_tool_text.clone(),
998            auth: self.auth.clone(),
999        }
1000    }
1001
1002    /// Whether the Turnstile/CAPTCHA challenge should be enforced for an
1003    /// anonymous visitor. Returns false when no captcha is configured or it is
1004    /// explicitly disabled. Signed-in visitors (validated via `auth`) bypass the
1005    /// challenge and are handled by the caller.
1006    pub fn captcha_enforced(&self) -> bool {
1007        self.captcha.as_ref().is_some_and(|c| c.enabled)
1008    }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013    use super::*;
1014
1015    #[test]
1016    fn test_app_status_display() {
1017        assert_eq!(AppStatus::Draft.to_string(), "draft");
1018        assert_eq!(AppStatus::Published.to_string(), "published");
1019        assert_eq!(AppStatus::Archived.to_string(), "archived");
1020        assert_eq!(AppStatus::Deleted.to_string(), "deleted");
1021    }
1022
1023    #[test]
1024    fn test_app_status_from_str() {
1025        assert_eq!(AppStatus::from("draft"), AppStatus::Draft);
1026        assert_eq!(AppStatus::from("published"), AppStatus::Published);
1027        assert_eq!(AppStatus::from("archived"), AppStatus::Archived);
1028        assert_eq!(AppStatus::from("deleted"), AppStatus::Deleted);
1029        assert_eq!(AppStatus::from("unknown"), AppStatus::Draft);
1030        assert_eq!(AppStatus::from(""), AppStatus::Draft);
1031    }
1032
1033    #[test]
1034    fn test_app_status_serde_roundtrip() {
1035        let json = serde_json::to_string(&AppStatus::Published).unwrap();
1036        assert_eq!(json, r#""published""#);
1037        let parsed: AppStatus = serde_json::from_str(&json).unwrap();
1038        assert_eq!(parsed, AppStatus::Published);
1039    }
1040
1041    #[test]
1042    fn test_channel_type_display() {
1043        assert_eq!(ChannelType::Slack.to_string(), "slack");
1044        assert_eq!(ChannelType::AgUi.to_string(), "ag_ui");
1045        assert_eq!(ChannelType::Schedule.to_string(), "schedule");
1046        assert_eq!(ChannelType::Webhook.to_string(), "webhook");
1047        assert_eq!(ChannelType::A2a.to_string(), "a2a");
1048        assert_eq!(ChannelType::Fcp.to_string(), "fcp");
1049        assert_eq!(ChannelType::PublicChat.to_string(), "public_chat");
1050    }
1051
1052    #[test]
1053    fn test_channel_type_from_str_opt() {
1054        assert_eq!(ChannelType::from_str_opt("slack"), Some(ChannelType::Slack));
1055        assert_eq!(ChannelType::from_str_opt("ag_ui"), Some(ChannelType::AgUi));
1056        assert_eq!(
1057            ChannelType::from_str_opt("schedule"),
1058            Some(ChannelType::Schedule)
1059        );
1060        assert_eq!(
1061            ChannelType::from_str_opt("webhook"),
1062            Some(ChannelType::Webhook)
1063        );
1064        assert_eq!(ChannelType::from_str_opt("a2a"), Some(ChannelType::A2a));
1065        assert_eq!(ChannelType::from_str_opt("fcp"), Some(ChannelType::Fcp));
1066        assert_eq!(
1067            ChannelType::from_str_opt("public_chat"),
1068            Some(ChannelType::PublicChat)
1069        );
1070        assert_eq!(ChannelType::from_str_opt("unknown"), None);
1071        assert_eq!(ChannelType::from_str_opt(""), None);
1072    }
1073
1074    #[test]
1075    fn test_channel_type_serde_roundtrip() {
1076        let json = serde_json::to_string(&ChannelType::Slack).unwrap();
1077        assert_eq!(json, r#""slack""#);
1078        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1079        assert_eq!(parsed, ChannelType::Slack);
1080
1081        let json = serde_json::to_string(&ChannelType::AgUi).unwrap();
1082        assert_eq!(json, r#""ag_ui""#);
1083        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1084        assert_eq!(parsed, ChannelType::AgUi);
1085
1086        let json = serde_json::to_string(&ChannelType::Schedule).unwrap();
1087        assert_eq!(json, r#""schedule""#);
1088        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1089        assert_eq!(parsed, ChannelType::Schedule);
1090
1091        let json = serde_json::to_string(&ChannelType::Webhook).unwrap();
1092        assert_eq!(json, r#""webhook""#);
1093        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1094        assert_eq!(parsed, ChannelType::Webhook);
1095    }
1096
1097    #[test]
1098    fn test_session_strategy_default() {
1099        assert_eq!(SessionStrategy::default(), SessionStrategy::PerThread);
1100    }
1101
1102    #[test]
1103    fn test_session_strategy_serde() {
1104        let json = serde_json::to_string(&SessionStrategy::PerChannel).unwrap();
1105        assert_eq!(json, r#""per_channel""#);
1106        let parsed: SessionStrategy = serde_json::from_str(&json).unwrap();
1107        assert_eq!(parsed, SessionStrategy::PerChannel);
1108
1109        let json = serde_json::to_string(&SessionStrategy::PerUser).unwrap();
1110        assert_eq!(json, r#""per_user""#);
1111    }
1112
1113    #[test]
1114    fn test_slack_channel_config_full() {
1115        let json = r#"{
1116            "signing_secret": "sec123",
1117            "bot_token": "xoxb-tok",
1118            "channel_id": "C123",
1119            "team_id": "T123",
1120            "session_strategy": "per_channel",
1121            "reply_mode": "report_progress_only"
1122        }"#;
1123        let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
1124        assert_eq!(config.signing_secret, "sec123");
1125        assert_eq!(config.bot_token, "xoxb-tok");
1126        assert_eq!(config.channel_id.as_deref(), Some("C123"));
1127        assert_eq!(config.team_id.as_deref(), Some("T123"));
1128        assert_eq!(config.session_strategy, SessionStrategy::PerChannel);
1129        assert_eq!(config.reply_mode, SlackReplyMode::ReportProgressOnly);
1130    }
1131
1132    #[test]
1133    fn test_slack_channel_config_minimal() {
1134        let json = r#"{"signing_secret": "s", "bot_token": "t"}"#;
1135        let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
1136        assert!(config.channel_id.is_none());
1137        assert!(config.team_id.is_none());
1138        assert_eq!(config.session_strategy, SessionStrategy::PerThread);
1139        assert_eq!(config.reply_mode, SlackReplyMode::AllMessages);
1140        assert!(config.webhook_verified_at.is_none());
1141        assert!(config.first_message_received_at.is_none());
1142    }
1143
1144    #[test]
1145    fn test_slack_channel_config_with_verification_timestamps() {
1146        let json = r#"{
1147            "signing_secret": "s",
1148            "bot_token": "t",
1149            "webhook_verified_at": "2025-01-01T00:00:00Z",
1150            "first_message_received_at": "2025-01-01T01:00:00Z"
1151        }"#;
1152        let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
1153        assert!(config.webhook_verified_at.is_some());
1154        assert!(config.first_message_received_at.is_some());
1155
1156        // Round-trip: timestamps should be preserved
1157        let serialized = serde_json::to_value(&config).unwrap();
1158        assert!(serialized.get("webhook_verified_at").is_some());
1159        assert!(serialized.get("first_message_received_at").is_some());
1160    }
1161
1162    #[test]
1163    fn test_slack_channel_config_timestamps_skipped_when_none() {
1164        let config = SlackChannelConfig {
1165            signing_secret: "s".into(),
1166            bot_token: "t".into(),
1167            channel_id: None,
1168            team_id: None,
1169            session_strategy: SessionStrategy::PerThread,
1170            reply_mode: SlackReplyMode::AllMessages,
1171            webhook_verified_at: None,
1172            first_message_received_at: None,
1173        };
1174        let json = serde_json::to_value(&config).unwrap();
1175        assert!(json.get("webhook_verified_at").is_none());
1176        assert!(json.get("first_message_received_at").is_none());
1177    }
1178
1179    #[test]
1180    fn test_slack_reply_mode_serde_roundtrip() {
1181        let json = serde_json::to_string(&SlackReplyMode::ReportProgressOnly).unwrap();
1182        assert_eq!(json, r#""report_progress_only""#);
1183        let parsed: SlackReplyMode = serde_json::from_str(&json).unwrap();
1184        assert_eq!(parsed, SlackReplyMode::ReportProgressOnly);
1185    }
1186
1187    #[test]
1188    fn test_slack_channel_config_missing_required_field() {
1189        let json = r#"{"signing_secret": "s"}"#;
1190        assert!(serde_json::from_str::<SlackChannelConfig>(json).is_err());
1191    }
1192
1193    #[test]
1194    fn test_ag_ui_channel_config_defaults_to_anonymous() {
1195        let config: AgUiChannelConfig = serde_json::from_str("{}").unwrap();
1196        assert!(config.anonymous);
1197        assert_eq!(
1198            config.session_expiration_seconds,
1199            DEFAULT_SESSION_EXPIRATION_SECONDS
1200        );
1201        assert!(config.rate_limit_per_minute.is_none());
1202        assert!(config.token.is_none());
1203        assert!(config.auth.is_none());
1204        assert_eq!(config.tool_visibility, AgUiToolVisibility::Generic);
1205        assert_eq!(config.generic_tool_text, DEFAULT_AG_UI_GENERIC_TOOL_TEXT);
1206    }
1207
1208    #[test]
1209    fn test_ag_ui_channel_config_roundtrip() {
1210        let config = AgUiChannelConfig {
1211            anonymous: true,
1212            token: Some("agui-token".to_string()),
1213            session_expiration_seconds: 3600,
1214            rate_limit_per_minute: Some(120),
1215            tool_visibility: AgUiToolVisibility::None,
1216            generic_tool_text: "Please wait".to_string(),
1217            auth: None,
1218        };
1219        let json = serde_json::to_string(&config).unwrap();
1220        let parsed: AgUiChannelConfig = serde_json::from_str(&json).unwrap();
1221        assert!(parsed.anonymous);
1222        assert_eq!(parsed.token.as_deref(), Some("agui-token"));
1223        assert_eq!(parsed.session_expiration_seconds, 3600);
1224        assert_eq!(parsed.rate_limit_per_minute, Some(120));
1225        assert_eq!(parsed.tool_visibility, AgUiToolVisibility::None);
1226        assert_eq!(parsed.generic_tool_text, "Please wait");
1227    }
1228
1229    #[test]
1230    fn test_ag_ui_channel_config_zero_disables_expiration() {
1231        let config: AgUiChannelConfig =
1232            serde_json::from_str(r#"{"session_expiration_seconds": 0}"#).unwrap();
1233        assert_eq!(config.session_expiration_seconds, 0);
1234    }
1235
1236    #[test]
1237    fn test_ag_ui_channel_config_omits_rate_limit_when_unset() {
1238        let config = AgUiChannelConfig {
1239            anonymous: true,
1240            token: None,
1241            session_expiration_seconds: DEFAULT_SESSION_EXPIRATION_SECONDS,
1242            rate_limit_per_minute: None,
1243            tool_visibility: AgUiToolVisibility::Generic,
1244            generic_tool_text: DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string(),
1245            auth: None,
1246        };
1247        let json = serde_json::to_value(&config).unwrap();
1248        assert!(json.get("rate_limit_per_minute").is_none());
1249        assert!(json.get("generic_tool_text").is_none());
1250    }
1251
1252    #[test]
1253    fn test_invocation_session_mode_defaults_to_shared_session() {
1254        assert_eq!(
1255            InvocationSessionMode::default(),
1256            InvocationSessionMode::SharedSession
1257        );
1258    }
1259
1260    #[test]
1261    fn test_schedule_channel_config_defaults() {
1262        let config: ScheduleChannelConfig =
1263            serde_json::from_str(r#"{"cron_expression":"0 * * * * * *","message":"Run checks"}"#)
1264                .unwrap();
1265        assert_eq!(config.timezone, "UTC");
1266        assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1267    }
1268
1269    #[test]
1270    fn test_webhook_channel_config_defaults() {
1271        let config: WebhookChannelConfig =
1272            serde_json::from_str(r#"{"token":"top-secret","message":"{{payload.action}}"}"#)
1273                .unwrap();
1274        assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1275        // EVE-627: rate limit is optional and absent by default.
1276        assert!(config.rate_limit_per_minute.is_none());
1277    }
1278
1279    #[test]
1280    fn test_webhook_channel_config_rate_limit_roundtrip() {
1281        // EVE-627: an explicit per-channel rate limit parses and re-serializes.
1282        let config: WebhookChannelConfig =
1283            serde_json::from_str(r#"{"token":"t","message":"m","rate_limit_per_minute":120}"#)
1284                .unwrap();
1285        assert_eq!(config.rate_limit_per_minute, Some(120));
1286        let json = serde_json::to_value(&config).unwrap();
1287        assert_eq!(
1288            json.get("rate_limit_per_minute").and_then(|v| v.as_u64()),
1289            Some(120)
1290        );
1291
1292        // Absent when None (skip_serializing_if).
1293        let none_cfg: WebhookChannelConfig =
1294            serde_json::from_str(r#"{"token":"t","message":"m"}"#).unwrap();
1295        let none_json = serde_json::to_value(&none_cfg).unwrap();
1296        assert!(none_json.get("rate_limit_per_minute").is_none());
1297    }
1298
1299    fn test_app(channels: Vec<AppChannel>) -> App {
1300        App {
1301            public_id: AppId::from_uuid(Uuid::nil()),
1302            internal_id: Uuid::nil(),
1303            org_id: 1,
1304            name: "test".into(),
1305            description: None,
1306            harness_id: HarnessId::from_uuid(Uuid::nil()),
1307            agent_id: Some(AgentId::from_uuid(Uuid::nil())),
1308            agent_version_policy: AgentVersionPolicy::Default,
1309            agent_version_id: None,
1310            agent_identity_id: None,
1311            owner_principal_id: PrincipalId::from_seed(1),
1312            resolved_owner_user_id: None,
1313            owner: None,
1314            effective_owner: None,
1315            channels,
1316            status: AppStatus::Draft,
1317            published_at: None,
1318            created_at: Utc::now(),
1319            updated_at: Utc::now(),
1320            archived_at: None,
1321            deleted_at: None,
1322        }
1323    }
1324
1325    fn test_channel(channel_type: ChannelType, config: serde_json::Value) -> AppChannel {
1326        AppChannel {
1327            public_id: AppChannelId::from_uuid(Uuid::nil()),
1328            internal_id: Uuid::nil(),
1329            channel_type,
1330            channel_config: config,
1331            enabled: true,
1332            created_at: Utc::now(),
1333            updated_at: Utc::now(),
1334        }
1335    }
1336
1337    #[test]
1338    fn test_app_channel_slack_config_valid() {
1339        let ch = test_channel(
1340            ChannelType::Slack,
1341            serde_json::json!({"signing_secret": "sec", "bot_token": "tok"}),
1342        );
1343        let config = ch.slack_config().unwrap();
1344        assert_eq!(config.signing_secret, "sec");
1345    }
1346
1347    #[test]
1348    fn test_app_channel_slack_config_invalid_json() {
1349        let ch = test_channel(ChannelType::Slack, serde_json::json!({"bad": "data"}));
1350        assert!(ch.slack_config().is_none());
1351    }
1352
1353    #[test]
1354    fn test_app_slack_channel_lookup() {
1355        let ch = test_channel(
1356            ChannelType::Slack,
1357            serde_json::json!({"signing_secret": "s", "bot_token": "t"}),
1358        );
1359        let app = test_app(vec![ch]);
1360        assert!(app.slack_channel().is_some());
1361    }
1362
1363    #[test]
1364    fn test_app_slack_channel_none_when_empty() {
1365        let app = test_app(vec![]);
1366        assert!(app.slack_channel().is_none());
1367    }
1368
1369    #[test]
1370    fn test_app_channel_ag_ui_config_valid() {
1371        let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
1372        let config = ch.ag_ui_config().unwrap();
1373        assert!(config.anonymous);
1374    }
1375
1376    #[test]
1377    fn test_app_ag_ui_channel_lookup() {
1378        let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
1379        let app = test_app(vec![ch]);
1380        assert!(app.ag_ui_channel().is_some());
1381    }
1382
1383    #[test]
1384    fn test_app_channel_fcp_config_defaults() {
1385        let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
1386        let config = ch.fcp_config().unwrap();
1387        assert!(config.anonymous);
1388        assert!(config.token.is_none());
1389        assert!(config.handshake.is_none());
1390        assert_eq!(
1391            config.session_expiration_seconds,
1392            DEFAULT_SESSION_EXPIRATION_SECONDS
1393        );
1394        assert_eq!(
1395            config.response_timeout_seconds,
1396            DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
1397        );
1398    }
1399
1400    #[test]
1401    fn test_app_fcp_channel_lookup() {
1402        let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
1403        let app = test_app(vec![ch]);
1404        assert!(app.fcp_channel().is_some());
1405    }
1406
1407    #[test]
1408    fn test_app_channel_schedule_config_valid() {
1409        let ch = test_channel(
1410            ChannelType::Schedule,
1411            serde_json::json!({
1412                "cron_expression": "0 * * * * * *",
1413                "message": "Run checks"
1414            }),
1415        );
1416        let config = ch.schedule_config().unwrap();
1417        assert_eq!(config.message, "Run checks");
1418    }
1419
1420    #[test]
1421    fn test_app_schedule_channel_lookup() {
1422        let ch = test_channel(
1423            ChannelType::Schedule,
1424            serde_json::json!({
1425                "cron_expression": "0 * * * * * *",
1426                "message": "Run checks"
1427            }),
1428        );
1429        let app = test_app(vec![ch]);
1430        assert!(app.schedule_channel().is_some());
1431    }
1432
1433    #[test]
1434    fn test_app_channel_webhook_config_valid() {
1435        let ch = test_channel(
1436            ChannelType::Webhook,
1437            serde_json::json!({
1438                "token": "secret",
1439                "message": "{{payload.ref}}"
1440            }),
1441        );
1442        let config = ch.webhook_config().unwrap();
1443        assert_eq!(config.token, "secret");
1444    }
1445
1446    #[test]
1447    fn test_app_webhook_channel_lookup() {
1448        let ch = test_channel(
1449            ChannelType::Webhook,
1450            serde_json::json!({
1451                "token": "secret",
1452                "message": "{{payload.ref}}"
1453            }),
1454        );
1455        let app = test_app(vec![ch]);
1456        assert!(app.webhook_channel().is_some());
1457    }
1458
1459    #[test]
1460    fn test_a2a_channel_config_defaults() {
1461        let config: A2aChannelConfig = serde_json::from_str(
1462            r#"{"api_key_hash":"abc","api_key_prefix":"evra2a_abc1...","message":"{{a2a.text}}"}"#,
1463        )
1464        .unwrap();
1465        assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1466        assert!(config.agent_card_name.is_none());
1467        assert!(config.agent_card_description.is_none());
1468        assert!(config.rate_limit_per_minute.is_none());
1469        assert!(config.auth.is_none());
1470        assert!(config.signing_secret.is_none());
1471    }
1472
1473    #[test]
1474    fn test_a2a_channel_config_roundtrip() {
1475        let config = A2aChannelConfig {
1476            api_key_hash: "deadbeef".into(),
1477            api_key_prefix: "evra2a_dead...".into(),
1478            session_mode: InvocationSessionMode::SessionPerInvocation,
1479            message: "{{a2a.text}}".into(),
1480            agent_card_name: Some("Inbox triage".into()),
1481            agent_card_description: Some("Triages github events".into()),
1482            rate_limit_per_minute: Some(120),
1483            auth: None,
1484            signing_secret: None,
1485        };
1486        let json = serde_json::to_string(&config).unwrap();
1487        let parsed: A2aChannelConfig = serde_json::from_str(&json).unwrap();
1488        assert_eq!(parsed.api_key_hash, "deadbeef");
1489        assert_eq!(
1490            parsed.session_mode,
1491            InvocationSessionMode::SessionPerInvocation
1492        );
1493        assert_eq!(parsed.agent_card_name.as_deref(), Some("Inbox triage"));
1494        assert_eq!(parsed.rate_limit_per_minute, Some(120));
1495    }
1496
1497    #[test]
1498    fn test_a2a_channel_config_omits_optional_fields() {
1499        let config = A2aChannelConfig {
1500            api_key_hash: "h".into(),
1501            api_key_prefix: "evra2a_h...".into(),
1502            session_mode: InvocationSessionMode::SharedSession,
1503            message: "m".into(),
1504            agent_card_name: None,
1505            agent_card_description: None,
1506            rate_limit_per_minute: None,
1507            auth: None,
1508            signing_secret: None,
1509        };
1510        let json = serde_json::to_value(&config).unwrap();
1511        assert!(json.get("agent_card_name").is_none());
1512        assert!(json.get("agent_card_description").is_none());
1513        assert!(json.get("rate_limit_per_minute").is_none());
1514        assert!(json.get("signing_secret").is_none());
1515    }
1516
1517    #[test]
1518    fn test_app_channel_a2a_config_valid() {
1519        let ch = test_channel(
1520            ChannelType::A2a,
1521            serde_json::json!({
1522                "api_key_hash": "h",
1523                "api_key_prefix": "evra2a_h...",
1524                "message": "{{a2a.text}}"
1525            }),
1526        );
1527        let config = ch.a2a_config().unwrap();
1528        assert_eq!(config.api_key_prefix, "evra2a_h...");
1529    }
1530
1531    #[test]
1532    fn test_app_a2a_channel_lookup() {
1533        let ch = test_channel(
1534            ChannelType::A2a,
1535            serde_json::json!({
1536                "api_key_hash": "h",
1537                "api_key_prefix": "evra2a_h...",
1538                "message": "{{a2a.text}}"
1539            }),
1540        );
1541        let app = test_app(vec![ch]);
1542        assert!(app.a2a_channel().is_some());
1543    }
1544
1545    #[test]
1546    fn test_channel_type_public_chat_serde_roundtrip() {
1547        let json = serde_json::to_string(&ChannelType::PublicChat).unwrap();
1548        assert_eq!(json, r#""public_chat""#);
1549        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1550        assert_eq!(parsed, ChannelType::PublicChat);
1551    }
1552
1553    #[test]
1554    fn test_public_chat_channel_config_defaults() {
1555        let config: PublicChatChannelConfig = serde_json::from_str("{}").unwrap();
1556        assert!(config.anonymous);
1557        assert!(config.token.is_none());
1558        assert_eq!(
1559            config.session_expiration_seconds,
1560            DEFAULT_SESSION_EXPIRATION_SECONDS
1561        );
1562        assert!(config.rate_limit_per_minute.is_none());
1563        assert_eq!(config.tool_visibility, AgUiToolVisibility::Generic);
1564        assert_eq!(config.generic_tool_text, DEFAULT_AG_UI_GENERIC_TOOL_TEXT);
1565        assert!(config.auth.is_none());
1566        assert!(config.branding.is_empty());
1567        assert!(config.captcha.is_none());
1568    }
1569
1570    #[test]
1571    fn test_public_chat_channel_config_omits_empty_branding_and_defaults() {
1572        let config: PublicChatChannelConfig = serde_json::from_str("{}").unwrap();
1573        let json = serde_json::to_value(&config).unwrap();
1574        assert!(json.get("branding").is_none());
1575        assert!(json.get("captcha").is_none());
1576        assert!(json.get("generic_tool_text").is_none());
1577        assert!(json.get("rate_limit_per_minute").is_none());
1578    }
1579
1580    #[test]
1581    fn test_public_chat_channel_config_full_roundtrip() {
1582        let json = r##"{
1583            "anonymous": false,
1584            "token": "shared-secret",
1585            "session_expiration_seconds": 3600,
1586            "rate_limit_per_minute": 30,
1587            "tool_visibility": "narrated",
1588            "generic_tool_text": "Thinking...",
1589            "branding": {
1590                "display_name": "Support",
1591                "logo_url": "https://example.com/logo.png",
1592                "primary_color": "#0A1636",
1593                "welcome_message": "How can I help?"
1594            },
1595            "captcha": {
1596                "provider": "turnstile",
1597                "enabled": true,
1598                "site_key": "1x00000000000000000000AA",
1599                "secret_key": "1x0000000000000000000000000000000AA"
1600            }
1601        }"##;
1602        let config: PublicChatChannelConfig = serde_json::from_str(json).unwrap();
1603        assert!(!config.anonymous);
1604        assert_eq!(config.token.as_deref(), Some("shared-secret"));
1605        assert_eq!(config.session_expiration_seconds, 3600);
1606        assert_eq!(config.rate_limit_per_minute, Some(30));
1607        assert_eq!(config.tool_visibility, AgUiToolVisibility::Narrated);
1608        let branding = &config.branding;
1609        assert_eq!(branding.display_name.as_deref(), Some("Support"));
1610        assert_eq!(branding.primary_color.as_deref(), Some("#0A1636"));
1611        let captcha = config.captcha.unwrap();
1612        assert_eq!(captcha.provider, CaptchaProvider::Turnstile);
1613        assert!(captcha.enabled);
1614        assert_eq!(captcha.site_key, "1x00000000000000000000AA");
1615        assert!(captcha.secret_key.is_some());
1616    }
1617
1618    #[test]
1619    fn test_app_channel_public_chat_config_valid() {
1620        let ch = test_channel(
1621            ChannelType::PublicChat,
1622            serde_json::json!({"anonymous": true}),
1623        );
1624        let config = ch.public_chat_config().unwrap();
1625        assert!(config.anonymous);
1626    }
1627
1628    #[test]
1629    fn test_app_public_chat_channel_lookup() {
1630        let ch = test_channel(
1631            ChannelType::PublicChat,
1632            serde_json::json!({"branding": {"display_name": "Helpdesk"}}),
1633        );
1634        let app = test_app(vec![ch]);
1635        assert!(app.public_chat_channel().is_some());
1636    }
1637
1638    #[test]
1639    fn test_app_serde_skips_internal_fields() {
1640        let app = test_app(vec![]);
1641        let json = serde_json::to_value(&app).unwrap();
1642        assert!(json.get("id").is_some()); // public_id serialized as "id"
1643        assert!(json.get("internal_id").is_none()); // skipped
1644        assert!(json.get("org_id").is_none()); // skipped
1645        assert!(json.get("published_at").is_none()); // None skipped
1646    }
1647}