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    /// Whether provider-curated reasoning summaries may be emitted on public
651    /// AG-UI streams. Defaults off because published apps can be anonymous and
652    /// summaries may derive from private prompts, tools, or retrieved data.
653    #[serde(default, skip_serializing_if = "is_false")]
654    pub reasoning_summary_visible: bool,
655    /// Optional inline auth config for this public endpoint. When omitted,
656    /// legacy `anonymous` + `token` behavior applies.
657    #[serde(default, skip_serializing_if = "Option::is_none")]
658    pub auth: Option<AppEndpointAuthConfig>,
659}
660
661fn default_session_expiration_seconds() -> u32 {
662    DEFAULT_SESSION_EXPIRATION_SECONDS
663}
664
665fn default_ag_ui_generic_tool_text() -> String {
666    DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string()
667}
668
669fn is_default_ag_ui_generic_tool_text(value: &str) -> bool {
670    value == DEFAULT_AG_UI_GENERIC_TOOL_TEXT
671}
672
673fn is_false(value: &bool) -> bool {
674    !*value
675}
676
677/// Default FCP handshake body. Returned by `GET` when the channel config does
678/// not override `handshake`. Kept generic so an unconfigured FCP endpoint
679/// still satisfies the FCP `SHOULD` for handshake responses.
680pub 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.";
681
682/// Default response timeout for a blocking FCP POST, in seconds.
683pub const DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS: u32 = 120;
684
685/// Typed FCP channel configuration.
686///
687/// FCP is intentionally schema-free at the wire layer (see
688/// `specs/fcp-channel.md`). The fields here only control server-side
689/// behavior — handshake content, a single shared bearer token, session
690/// reuse, rate limiting — and never constrain the body the actor sends in.
691///
692/// FCP deliberately exposes a **smaller** auth surface than AG-UI/A2A: it
693/// supports anonymous access and a single shared bearer token, and nothing
694/// else. Inline OIDC/HTTP-Basic/mTLS verifier modes are intentionally
695/// **not** wired here so the FCP ingress path does not share authentication
696/// machinery with other channels or with the main API's user auth stack.
697/// Operators that need IdP-backed auth in front of an FCP endpoint should
698/// terminate that at the edge (reverse proxy, IAP, mTLS) rather than asking
699/// the FCP handler to grow another auth mode.
700#[derive(Debug, Clone, Serialize, Deserialize)]
701#[cfg_attr(feature = "openapi", derive(ToSchema))]
702pub struct FcpChannelConfig {
703    /// Whether anonymous access is allowed for this endpoint. When `false`
704    /// a non-empty `token` must authenticate every `POST`.
705    #[serde(default = "default_true")]
706    pub anonymous: bool,
707    /// Optional shared bearer token. When set, callers must send
708    /// `Authorization: Bearer <token>` (or the `X-Everruns-FCP-Token`
709    /// header). Validated by constant-time comparison inside the FCP
710    /// handler — never via the shared App endpoint auth verifier.
711    #[serde(default, skip_serializing_if = "Option::is_none")]
712    pub token: Option<String>,
713    /// Markdown body returned for `GET` requests (the FCP handshake). When
714    /// omitted, a generic handshake derived from the app's name and
715    /// description is used.
716    #[serde(default, skip_serializing_if = "Option::is_none")]
717    pub handshake: Option<String>,
718    /// How long (in seconds) the FCP session cookie keeps a session
719    /// resumable. `0` disables expiration. Defaults to 6 hours so a
720    /// long-running conversation expires on a sensible cadence.
721    #[serde(default = "default_session_expiration_seconds")]
722    pub session_expiration_seconds: u32,
723    /// Optional per-IP rate limit (requests per minute) for the FCP endpoint.
724    /// `None` or `Some(0)` disables the per-app limit; the global API limit
725    /// still applies. Counted in an FCP-specific limiter namespace so it
726    /// cannot be shared or exhausted by other channels.
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub rate_limit_per_minute: Option<u32>,
729    /// Maximum number of seconds the FCP endpoint waits for the agent to
730    /// produce a reply before returning a `504`. Defaults to 120 s.
731    #[serde(default = "default_fcp_response_timeout_seconds")]
732    pub response_timeout_seconds: u32,
733}
734
735fn default_fcp_response_timeout_seconds() -> u32 {
736    DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
737}
738
739/// How app-triggered invocations route into sessions.
740#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
741#[cfg_attr(feature = "openapi", derive(ToSchema))]
742#[cfg_attr(feature = "openapi", schema(example = "shared_session"))]
743#[serde(rename_all = "snake_case")]
744pub enum InvocationSessionMode {
745    /// Reuse a single durable session for every invocation of the channel.
746    #[default]
747    SharedSession,
748    /// Create a fresh session for every invocation.
749    SessionPerInvocation,
750}
751
752/// Typed schedule channel configuration.
753///
754/// `message` is also the template body. `{{path.to.value}}` placeholders are
755/// expanded at invocation time.
756#[derive(Debug, Clone, Serialize, Deserialize)]
757#[cfg_attr(feature = "openapi", derive(ToSchema))]
758pub struct ScheduleChannelConfig {
759    /// Cron expression that drives the durable schedule.
760    pub cron_expression: String,
761    /// IANA timezone identifier for cron evaluation.
762    #[serde(default = "default_timezone")]
763    pub timezone: String,
764    /// Whether invocations reuse a stable session or create a new one.
765    #[serde(default)]
766    pub session_mode: InvocationSessionMode,
767    /// Message content or template sent when the schedule fires.
768    pub message: String,
769}
770
771/// Typed webhook channel configuration.
772///
773/// `message` is also the template body. `{{path.to.value}}` placeholders are
774/// expanded against the incoming webhook payload and metadata.
775#[derive(Debug, Clone, Serialize, Deserialize)]
776#[cfg_attr(feature = "openapi", derive(ToSchema))]
777pub struct WebhookChannelConfig {
778    /// Shared secret required from the incoming webhook request.
779    pub token: String,
780    /// Whether invocations reuse a stable session or create a new one.
781    #[serde(default)]
782    pub session_mode: InvocationSessionMode,
783    /// Message content or template sent when the webhook arrives.
784    pub message: String,
785    /// Optional per-IP rate limit applied to this app's webhook endpoint, in
786    /// requests per minute. `None` or `Some(0)` disables the per-channel limit
787    /// (the global API limit still applies). Mirrors
788    /// `A2aChannelConfig::rate_limit_per_minute` (EVE-627).
789    #[serde(default, skip_serializing_if = "Option::is_none")]
790    pub rate_limit_per_minute: Option<u32>,
791}
792
793fn default_timezone() -> String {
794    "UTC".to_string()
795}
796
797/// Typed A2A (Agent2Agent) channel configuration.
798///
799/// The plaintext API key is **never** stored. Only the SHA-256 hex hash and a
800/// non-secret display prefix are persisted. The plaintext is returned exactly
801/// once at create / regenerate time.
802///
803/// `message` is the template body. `{{path.to.value}}` placeholders expand
804/// against the incoming A2A request payload and metadata (see
805/// `specs/a2a-channel.md`).
806#[derive(Debug, Clone, Serialize, Deserialize)]
807#[cfg_attr(feature = "openapi", derive(ToSchema))]
808pub struct A2aChannelConfig {
809    /// SHA-256 hex digest of the API key.
810    pub api_key_hash: String,
811    /// Public, non-secret display prefix (e.g. `evra2a_abc1...`).
812    pub api_key_prefix: String,
813    /// Whether invocations reuse a stable session or create a new one.
814    #[serde(default)]
815    pub session_mode: InvocationSessionMode,
816    /// Message template rendered into the session per invocation.
817    pub message: String,
818    /// Optional human-readable agent name surfaced in the Agent Card.
819    #[serde(default, skip_serializing_if = "Option::is_none")]
820    pub agent_card_name: Option<String>,
821    /// Optional description surfaced in the Agent Card.
822    #[serde(default, skip_serializing_if = "Option::is_none")]
823    pub agent_card_description: Option<String>,
824    /// Optional per-IP rate limit applied to this app's A2A endpoint, in
825    /// requests per minute. `None` or `Some(0)` disables the per-channel
826    /// limit (the global API limit still applies). Set a positive value to
827    /// enforce a stricter cap on unattended agent-to-agent traffic for this
828    /// app. Mirrors `AgUiChannelConfig::rate_limit_per_minute`.
829    #[serde(default, skip_serializing_if = "Option::is_none")]
830    pub rate_limit_per_minute: Option<u32>,
831    /// Optional inline auth config for this A2A endpoint. When omitted,
832    /// legacy per-channel API-key behavior applies.
833    #[serde(default, skip_serializing_if = "Option::is_none")]
834    pub auth: Option<AppEndpointAuthConfig>,
835    /// Optional shared HMAC signing secret. When set, requests must include
836    /// `X-Everruns-A2A-Timestamp` + `X-Everruns-A2A-Signature` headers and
837    /// the server verifies an HMAC-SHA256 signature over the exact
838    /// basestring `v0:{timestamp}:{body}` (Slack-style, no whitespace
839    /// between segments) plus a 5-minute timestamp window plus a
840    /// signature-keyed dedup so a captured request cannot be replayed
841    /// while the API key is still valid (TM-A2A-010). When `None`, the
842    /// channel keeps the existing API-key-only behavior. Layered **on top
843    /// of** `auth` — independent concerns: `auth` selects who can call,
844    /// `signing_secret` adds replay protection on top.
845    #[serde(default, skip_serializing_if = "Option::is_none")]
846    pub signing_secret: Option<String>,
847}
848
849/// Typed api_endpoint channel configuration.
850///
851/// Exposes an app-scoped, execution-only API key over native session routes
852/// mounted under the app (`/v1/apps/{app_id}/api/{channel_id}/...`). The key is
853/// structurally execution-only: it reaches only these app-mounted routes and
854/// has no path to any management API. The plaintext key is **never** stored —
855/// only the SHA-256 hex hash and a non-secret display prefix are persisted, and
856/// the plaintext is returned exactly once at create / regenerate time. See
857/// `specs/app-api-keys.md`.
858#[derive(Debug, Clone, Serialize, Deserialize)]
859#[cfg_attr(feature = "openapi", derive(ToSchema))]
860pub struct ApiEndpointChannelConfig {
861    /// SHA-256 hex digest of the API key.
862    pub api_key_hash: String,
863    /// Public, non-secret display prefix (e.g. `evr_app_abc1...`).
864    pub api_key_prefix: String,
865    /// Whether invocations reuse a stable session or create a new one.
866    #[serde(default)]
867    pub session_mode: InvocationSessionMode,
868    /// Optional per-IP rate limit applied to this app's api_endpoint, in
869    /// requests per minute. `None` or `Some(0)` disables the per-channel limit
870    /// (the global API limit still applies). Mirrors
871    /// `A2aChannelConfig::rate_limit_per_minute`.
872    #[serde(default, skip_serializing_if = "Option::is_none")]
873    pub rate_limit_per_minute: Option<u32>,
874    /// Optional inline auth config for this endpoint. When omitted, the
875    /// generated per-channel API-key bearer scheme applies.
876    #[serde(default, skip_serializing_if = "Option::is_none")]
877    pub auth: Option<AppEndpointAuthConfig>,
878}
879
880/// Branding shown on a Public Chat surface. All fields optional; the public app
881/// falls back to the App's name and the default design-system theme when unset.
882/// Branding is non-secret and is returned as-is in API responses.
883#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
884#[cfg_attr(feature = "openapi", derive(ToSchema))]
885pub struct PublicChatBranding {
886    /// Display name shown in the chat header. Falls back to the App name.
887    #[serde(default, skip_serializing_if = "Option::is_none")]
888    pub display_name: Option<String>,
889    /// Absolute URL of a logo image shown in the chat header.
890    #[serde(default, skip_serializing_if = "Option::is_none")]
891    pub logo_url: Option<String>,
892    /// Primary accent color as a CSS hex string (e.g. `#0A1636`). Applied by
893    /// overriding the design-system primary variable.
894    #[serde(default, skip_serializing_if = "Option::is_none")]
895    pub primary_color: Option<String>,
896    /// Welcome message shown before the visitor sends their first message.
897    #[serde(default, skip_serializing_if = "Option::is_none")]
898    pub welcome_message: Option<String>,
899}
900
901/// Bot-mitigation challenge provider for anonymous Public Chat access.
902#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
903#[cfg_attr(feature = "openapi", derive(ToSchema))]
904#[serde(rename_all = "snake_case")]
905pub enum CaptchaProvider {
906    /// Cloudflare Turnstile. The only provider supported in the first release.
907    #[default]
908    Turnstile,
909}
910
911/// CAPTCHA / bot-mitigation configuration for a Public Chat channel.
912///
913/// When present and enabled, anonymous visitors must pass a challenge before a
914/// session is created or any turn runs; signed-in visitors bypass it. The
915/// `secret_key` is write-only: it is stored to call the provider's verify
916/// endpoint server-side and is redacted in API responses (only
917/// `secret_key_configured: bool` is surfaced). The `site_key` is public and
918/// returned so the web app can render the widget.
919#[derive(Debug, Clone, Serialize, Deserialize)]
920#[cfg_attr(feature = "openapi", derive(ToSchema))]
921pub struct PublicChatCaptchaConfig {
922    /// Challenge provider. Defaults to Cloudflare Turnstile.
923    #[serde(default)]
924    pub provider: CaptchaProvider,
925    /// Whether the challenge is enforced. Defaults to true so adding a captcha
926    /// config turns it on; set to false to keep keys configured but inactive.
927    #[serde(default = "default_true")]
928    pub enabled: bool,
929    /// Public site key rendered by the client widget.
930    pub site_key: String,
931    /// Secret key used for server-side verification. Write-only; redacted on read.
932    #[serde(default, skip_serializing_if = "Option::is_none")]
933    pub secret_key: Option<String>,
934}
935
936/// Typed Public Chat channel configuration.
937///
938/// Parsed from the `channel_config` JSON field on App. Public Chat reuses
939/// AG-UI's streaming semantics and the shared App endpoint auth verifier, and
940/// adds branding and bot-mitigation tailored to a public, link-shareable chat
941/// website. See `specs/public-chat.md`.
942#[derive(Debug, Clone, Serialize, Deserialize)]
943#[cfg_attr(feature = "openapi", derive(ToSchema))]
944pub struct PublicChatChannelConfig {
945    /// Whether anonymous access is allowed. Anonymous-by-default mirrors AG-UI.
946    /// When `false`, visitors must authenticate (e.g. via `auth` Google OIDC).
947    #[serde(default = "default_true")]
948    pub anonymous: bool,
949    /// Optional shared bearer token for simple gated access. When set, requests
950    /// must include the token in a supported header. Redacted on read.
951    #[serde(default, skip_serializing_if = "Option::is_none")]
952    pub token: Option<String>,
953    /// How long (in seconds) a visitor session can be resumed before a fresh
954    /// one must be started. `0` disables expiration. Defaults to 6 hours.
955    #[serde(default = "default_session_expiration_seconds")]
956    pub session_expiration_seconds: u32,
957    /// Optional per-IP, per-app rate limit in requests per minute. `None` or
958    /// `Some(0)` disables the per-app cap (the global API limit still applies).
959    #[serde(default, skip_serializing_if = "Option::is_none")]
960    pub rate_limit_per_minute: Option<u32>,
961    /// Public tool activity visibility. Same rules as AG-UI: raw tool names,
962    /// args, results, and internal IDs are never exposed on public streams.
963    #[serde(default)]
964    pub tool_visibility: AgUiToolVisibility,
965    /// Generic public text shown when `tool_visibility` is `generic`/`narrated`.
966    #[serde(
967        default = "default_ag_ui_generic_tool_text",
968        skip_serializing_if = "is_default_ag_ui_generic_tool_text"
969    )]
970    pub generic_tool_text: String,
971    /// Optional inline auth config for this public endpoint (e.g. Google OIDC).
972    /// When omitted, anonymous + optional `token` behavior applies.
973    #[serde(default, skip_serializing_if = "Option::is_none")]
974    pub auth: Option<AppEndpointAuthConfig>,
975    /// Branding shown on the public chat surface.
976    #[serde(default, skip_serializing_if = "PublicChatBranding::is_empty")]
977    pub branding: PublicChatBranding,
978    /// Optional bot-mitigation / CAPTCHA configuration (Cloudflare Turnstile).
979    #[serde(default, skip_serializing_if = "Option::is_none")]
980    pub captcha: Option<PublicChatCaptchaConfig>,
981}
982
983impl PublicChatBranding {
984    /// True when no branding fields are set, so the whole object can be omitted
985    /// from serialized output.
986    pub fn is_empty(&self) -> bool {
987        self.display_name.is_none()
988            && self.logo_url.is_none()
989            && self.primary_color.is_none()
990            && self.welcome_message.is_none()
991    }
992}
993
994impl PublicChatChannelConfig {
995    /// Project the streaming-relevant fields onto an `AgUiChannelConfig` so the
996    /// shared AG-UI ingress/streaming core can serve Public Chat without a
997    /// parallel implementation. Branding and captcha are Public-Chat-only and
998    /// are handled by the Public Chat handler, not the shared core.
999    pub fn ag_ui_stream_config(&self) -> AgUiChannelConfig {
1000        AgUiChannelConfig {
1001            anonymous: self.anonymous,
1002            token: self.token.clone(),
1003            session_expiration_seconds: self.session_expiration_seconds,
1004            rate_limit_per_minute: self.rate_limit_per_minute,
1005            tool_visibility: self.tool_visibility,
1006            generic_tool_text: self.generic_tool_text.clone(),
1007            reasoning_summary_visible: false,
1008            auth: self.auth.clone(),
1009        }
1010    }
1011
1012    /// Whether the Turnstile/CAPTCHA challenge should be enforced for an
1013    /// anonymous visitor. Returns false when no captcha is configured or it is
1014    /// explicitly disabled. Signed-in visitors (validated via `auth`) bypass the
1015    /// challenge and are handled by the caller.
1016    pub fn captcha_enforced(&self) -> bool {
1017        self.captcha.as_ref().is_some_and(|c| c.enabled)
1018    }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023    use super::*;
1024
1025    #[test]
1026    fn test_app_status_display() {
1027        assert_eq!(AppStatus::Draft.to_string(), "draft");
1028        assert_eq!(AppStatus::Published.to_string(), "published");
1029        assert_eq!(AppStatus::Archived.to_string(), "archived");
1030        assert_eq!(AppStatus::Deleted.to_string(), "deleted");
1031    }
1032
1033    #[test]
1034    fn test_app_status_from_str() {
1035        assert_eq!(AppStatus::from("draft"), AppStatus::Draft);
1036        assert_eq!(AppStatus::from("published"), AppStatus::Published);
1037        assert_eq!(AppStatus::from("archived"), AppStatus::Archived);
1038        assert_eq!(AppStatus::from("deleted"), AppStatus::Deleted);
1039        assert_eq!(AppStatus::from("unknown"), AppStatus::Draft);
1040        assert_eq!(AppStatus::from(""), AppStatus::Draft);
1041    }
1042
1043    #[test]
1044    fn test_app_status_serde_roundtrip() {
1045        let json = serde_json::to_string(&AppStatus::Published).unwrap();
1046        assert_eq!(json, r#""published""#);
1047        let parsed: AppStatus = serde_json::from_str(&json).unwrap();
1048        assert_eq!(parsed, AppStatus::Published);
1049    }
1050
1051    #[test]
1052    fn test_channel_type_display() {
1053        assert_eq!(ChannelType::Slack.to_string(), "slack");
1054        assert_eq!(ChannelType::AgUi.to_string(), "ag_ui");
1055        assert_eq!(ChannelType::Schedule.to_string(), "schedule");
1056        assert_eq!(ChannelType::Webhook.to_string(), "webhook");
1057        assert_eq!(ChannelType::A2a.to_string(), "a2a");
1058        assert_eq!(ChannelType::Fcp.to_string(), "fcp");
1059        assert_eq!(ChannelType::PublicChat.to_string(), "public_chat");
1060    }
1061
1062    #[test]
1063    fn test_channel_type_from_str_opt() {
1064        assert_eq!(ChannelType::from_str_opt("slack"), Some(ChannelType::Slack));
1065        assert_eq!(ChannelType::from_str_opt("ag_ui"), Some(ChannelType::AgUi));
1066        assert_eq!(
1067            ChannelType::from_str_opt("schedule"),
1068            Some(ChannelType::Schedule)
1069        );
1070        assert_eq!(
1071            ChannelType::from_str_opt("webhook"),
1072            Some(ChannelType::Webhook)
1073        );
1074        assert_eq!(ChannelType::from_str_opt("a2a"), Some(ChannelType::A2a));
1075        assert_eq!(ChannelType::from_str_opt("fcp"), Some(ChannelType::Fcp));
1076        assert_eq!(
1077            ChannelType::from_str_opt("public_chat"),
1078            Some(ChannelType::PublicChat)
1079        );
1080        assert_eq!(ChannelType::from_str_opt("unknown"), None);
1081        assert_eq!(ChannelType::from_str_opt(""), None);
1082    }
1083
1084    #[test]
1085    fn test_channel_type_serde_roundtrip() {
1086        let json = serde_json::to_string(&ChannelType::Slack).unwrap();
1087        assert_eq!(json, r#""slack""#);
1088        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1089        assert_eq!(parsed, ChannelType::Slack);
1090
1091        let json = serde_json::to_string(&ChannelType::AgUi).unwrap();
1092        assert_eq!(json, r#""ag_ui""#);
1093        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1094        assert_eq!(parsed, ChannelType::AgUi);
1095
1096        let json = serde_json::to_string(&ChannelType::Schedule).unwrap();
1097        assert_eq!(json, r#""schedule""#);
1098        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1099        assert_eq!(parsed, ChannelType::Schedule);
1100
1101        let json = serde_json::to_string(&ChannelType::Webhook).unwrap();
1102        assert_eq!(json, r#""webhook""#);
1103        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1104        assert_eq!(parsed, ChannelType::Webhook);
1105    }
1106
1107    #[test]
1108    fn test_session_strategy_default() {
1109        assert_eq!(SessionStrategy::default(), SessionStrategy::PerThread);
1110    }
1111
1112    #[test]
1113    fn test_session_strategy_serde() {
1114        let json = serde_json::to_string(&SessionStrategy::PerChannel).unwrap();
1115        assert_eq!(json, r#""per_channel""#);
1116        let parsed: SessionStrategy = serde_json::from_str(&json).unwrap();
1117        assert_eq!(parsed, SessionStrategy::PerChannel);
1118
1119        let json = serde_json::to_string(&SessionStrategy::PerUser).unwrap();
1120        assert_eq!(json, r#""per_user""#);
1121    }
1122
1123    #[test]
1124    fn test_slack_channel_config_full() {
1125        let json = r#"{
1126            "signing_secret": "sec123",
1127            "bot_token": "xoxb-tok",
1128            "channel_id": "C123",
1129            "team_id": "T123",
1130            "session_strategy": "per_channel",
1131            "reply_mode": "report_progress_only"
1132        }"#;
1133        let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
1134        assert_eq!(config.signing_secret, "sec123");
1135        assert_eq!(config.bot_token, "xoxb-tok");
1136        assert_eq!(config.channel_id.as_deref(), Some("C123"));
1137        assert_eq!(config.team_id.as_deref(), Some("T123"));
1138        assert_eq!(config.session_strategy, SessionStrategy::PerChannel);
1139        assert_eq!(config.reply_mode, SlackReplyMode::ReportProgressOnly);
1140    }
1141
1142    #[test]
1143    fn test_slack_channel_config_minimal() {
1144        let json = r#"{"signing_secret": "s", "bot_token": "t"}"#;
1145        let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
1146        assert!(config.channel_id.is_none());
1147        assert!(config.team_id.is_none());
1148        assert_eq!(config.session_strategy, SessionStrategy::PerThread);
1149        assert_eq!(config.reply_mode, SlackReplyMode::AllMessages);
1150        assert!(config.webhook_verified_at.is_none());
1151        assert!(config.first_message_received_at.is_none());
1152    }
1153
1154    #[test]
1155    fn test_slack_channel_config_with_verification_timestamps() {
1156        let json = r#"{
1157            "signing_secret": "s",
1158            "bot_token": "t",
1159            "webhook_verified_at": "2025-01-01T00:00:00Z",
1160            "first_message_received_at": "2025-01-01T01:00:00Z"
1161        }"#;
1162        let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
1163        assert!(config.webhook_verified_at.is_some());
1164        assert!(config.first_message_received_at.is_some());
1165
1166        // Round-trip: timestamps should be preserved
1167        let serialized = serde_json::to_value(&config).unwrap();
1168        assert!(serialized.get("webhook_verified_at").is_some());
1169        assert!(serialized.get("first_message_received_at").is_some());
1170    }
1171
1172    #[test]
1173    fn test_slack_channel_config_timestamps_skipped_when_none() {
1174        let config = SlackChannelConfig {
1175            signing_secret: "s".into(),
1176            bot_token: "t".into(),
1177            channel_id: None,
1178            team_id: None,
1179            session_strategy: SessionStrategy::PerThread,
1180            reply_mode: SlackReplyMode::AllMessages,
1181            webhook_verified_at: None,
1182            first_message_received_at: None,
1183        };
1184        let json = serde_json::to_value(&config).unwrap();
1185        assert!(json.get("webhook_verified_at").is_none());
1186        assert!(json.get("first_message_received_at").is_none());
1187    }
1188
1189    #[test]
1190    fn test_slack_reply_mode_serde_roundtrip() {
1191        let json = serde_json::to_string(&SlackReplyMode::ReportProgressOnly).unwrap();
1192        assert_eq!(json, r#""report_progress_only""#);
1193        let parsed: SlackReplyMode = serde_json::from_str(&json).unwrap();
1194        assert_eq!(parsed, SlackReplyMode::ReportProgressOnly);
1195    }
1196
1197    #[test]
1198    fn test_slack_channel_config_missing_required_field() {
1199        let json = r#"{"signing_secret": "s"}"#;
1200        assert!(serde_json::from_str::<SlackChannelConfig>(json).is_err());
1201    }
1202
1203    #[test]
1204    fn test_ag_ui_channel_config_defaults_to_anonymous() {
1205        let config: AgUiChannelConfig = serde_json::from_str("{}").unwrap();
1206        assert!(config.anonymous);
1207        assert_eq!(
1208            config.session_expiration_seconds,
1209            DEFAULT_SESSION_EXPIRATION_SECONDS
1210        );
1211        assert!(config.rate_limit_per_minute.is_none());
1212        assert!(config.token.is_none());
1213        assert!(config.auth.is_none());
1214        assert_eq!(config.tool_visibility, AgUiToolVisibility::Generic);
1215        assert_eq!(config.generic_tool_text, DEFAULT_AG_UI_GENERIC_TOOL_TEXT);
1216        assert!(!config.reasoning_summary_visible);
1217    }
1218
1219    #[test]
1220    fn test_ag_ui_channel_config_roundtrip() {
1221        let config = AgUiChannelConfig {
1222            anonymous: true,
1223            token: Some("agui-token".to_string()),
1224            session_expiration_seconds: 3600,
1225            rate_limit_per_minute: Some(120),
1226            tool_visibility: AgUiToolVisibility::None,
1227            generic_tool_text: "Please wait".to_string(),
1228            reasoning_summary_visible: true,
1229            auth: None,
1230        };
1231        let json = serde_json::to_string(&config).unwrap();
1232        let parsed: AgUiChannelConfig = serde_json::from_str(&json).unwrap();
1233        assert!(parsed.anonymous);
1234        assert_eq!(parsed.token.as_deref(), Some("agui-token"));
1235        assert_eq!(parsed.session_expiration_seconds, 3600);
1236        assert_eq!(parsed.rate_limit_per_minute, Some(120));
1237        assert_eq!(parsed.tool_visibility, AgUiToolVisibility::None);
1238        assert_eq!(parsed.generic_tool_text, "Please wait");
1239        assert!(parsed.reasoning_summary_visible);
1240    }
1241
1242    #[test]
1243    fn test_ag_ui_channel_config_zero_disables_expiration() {
1244        let config: AgUiChannelConfig =
1245            serde_json::from_str(r#"{"session_expiration_seconds": 0}"#).unwrap();
1246        assert_eq!(config.session_expiration_seconds, 0);
1247    }
1248
1249    #[test]
1250    fn test_ag_ui_channel_config_omits_rate_limit_when_unset() {
1251        let config = AgUiChannelConfig {
1252            anonymous: true,
1253            token: None,
1254            session_expiration_seconds: DEFAULT_SESSION_EXPIRATION_SECONDS,
1255            rate_limit_per_minute: None,
1256            tool_visibility: AgUiToolVisibility::Generic,
1257            generic_tool_text: DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string(),
1258            reasoning_summary_visible: false,
1259            auth: None,
1260        };
1261        let json = serde_json::to_value(&config).unwrap();
1262        assert!(json.get("rate_limit_per_minute").is_none());
1263        assert!(json.get("generic_tool_text").is_none());
1264    }
1265
1266    #[test]
1267    fn test_invocation_session_mode_defaults_to_shared_session() {
1268        assert_eq!(
1269            InvocationSessionMode::default(),
1270            InvocationSessionMode::SharedSession
1271        );
1272    }
1273
1274    #[test]
1275    fn test_schedule_channel_config_defaults() {
1276        let config: ScheduleChannelConfig =
1277            serde_json::from_str(r#"{"cron_expression":"0 * * * * * *","message":"Run checks"}"#)
1278                .unwrap();
1279        assert_eq!(config.timezone, "UTC");
1280        assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1281    }
1282
1283    #[test]
1284    fn test_webhook_channel_config_defaults() {
1285        let config: WebhookChannelConfig =
1286            serde_json::from_str(r#"{"token":"top-secret","message":"{{payload.action}}"}"#)
1287                .unwrap();
1288        assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1289        // EVE-627: rate limit is optional and absent by default.
1290        assert!(config.rate_limit_per_minute.is_none());
1291    }
1292
1293    #[test]
1294    fn test_webhook_channel_config_rate_limit_roundtrip() {
1295        // EVE-627: an explicit per-channel rate limit parses and re-serializes.
1296        let config: WebhookChannelConfig =
1297            serde_json::from_str(r#"{"token":"t","message":"m","rate_limit_per_minute":120}"#)
1298                .unwrap();
1299        assert_eq!(config.rate_limit_per_minute, Some(120));
1300        let json = serde_json::to_value(&config).unwrap();
1301        assert_eq!(
1302            json.get("rate_limit_per_minute").and_then(|v| v.as_u64()),
1303            Some(120)
1304        );
1305
1306        // Absent when None (skip_serializing_if).
1307        let none_cfg: WebhookChannelConfig =
1308            serde_json::from_str(r#"{"token":"t","message":"m"}"#).unwrap();
1309        let none_json = serde_json::to_value(&none_cfg).unwrap();
1310        assert!(none_json.get("rate_limit_per_minute").is_none());
1311    }
1312
1313    fn test_app(channels: Vec<AppChannel>) -> App {
1314        App {
1315            public_id: AppId::from_uuid(Uuid::nil()),
1316            internal_id: Uuid::nil(),
1317            org_id: 1,
1318            name: "test".into(),
1319            description: None,
1320            harness_id: HarnessId::from_uuid(Uuid::nil()),
1321            agent_id: Some(AgentId::from_uuid(Uuid::nil())),
1322            agent_version_policy: AgentVersionPolicy::Default,
1323            agent_version_id: None,
1324            agent_identity_id: None,
1325            owner_principal_id: PrincipalId::from_seed(1),
1326            resolved_owner_user_id: None,
1327            owner: None,
1328            effective_owner: None,
1329            channels,
1330            status: AppStatus::Draft,
1331            published_at: None,
1332            created_at: Utc::now(),
1333            updated_at: Utc::now(),
1334            archived_at: None,
1335            deleted_at: None,
1336        }
1337    }
1338
1339    fn test_channel(channel_type: ChannelType, config: serde_json::Value) -> AppChannel {
1340        AppChannel {
1341            public_id: AppChannelId::from_uuid(Uuid::nil()),
1342            internal_id: Uuid::nil(),
1343            channel_type,
1344            channel_config: config,
1345            enabled: true,
1346            created_at: Utc::now(),
1347            updated_at: Utc::now(),
1348        }
1349    }
1350
1351    #[test]
1352    fn test_app_channel_slack_config_valid() {
1353        let ch = test_channel(
1354            ChannelType::Slack,
1355            serde_json::json!({"signing_secret": "sec", "bot_token": "tok"}),
1356        );
1357        let config = ch.slack_config().unwrap();
1358        assert_eq!(config.signing_secret, "sec");
1359    }
1360
1361    #[test]
1362    fn test_app_channel_slack_config_invalid_json() {
1363        let ch = test_channel(ChannelType::Slack, serde_json::json!({"bad": "data"}));
1364        assert!(ch.slack_config().is_none());
1365    }
1366
1367    #[test]
1368    fn test_app_slack_channel_lookup() {
1369        let ch = test_channel(
1370            ChannelType::Slack,
1371            serde_json::json!({"signing_secret": "s", "bot_token": "t"}),
1372        );
1373        let app = test_app(vec![ch]);
1374        assert!(app.slack_channel().is_some());
1375    }
1376
1377    #[test]
1378    fn test_app_slack_channel_none_when_empty() {
1379        let app = test_app(vec![]);
1380        assert!(app.slack_channel().is_none());
1381    }
1382
1383    #[test]
1384    fn test_app_channel_ag_ui_config_valid() {
1385        let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
1386        let config = ch.ag_ui_config().unwrap();
1387        assert!(config.anonymous);
1388    }
1389
1390    #[test]
1391    fn test_app_ag_ui_channel_lookup() {
1392        let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
1393        let app = test_app(vec![ch]);
1394        assert!(app.ag_ui_channel().is_some());
1395    }
1396
1397    #[test]
1398    fn test_app_channel_fcp_config_defaults() {
1399        let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
1400        let config = ch.fcp_config().unwrap();
1401        assert!(config.anonymous);
1402        assert!(config.token.is_none());
1403        assert!(config.handshake.is_none());
1404        assert_eq!(
1405            config.session_expiration_seconds,
1406            DEFAULT_SESSION_EXPIRATION_SECONDS
1407        );
1408        assert_eq!(
1409            config.response_timeout_seconds,
1410            DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
1411        );
1412    }
1413
1414    #[test]
1415    fn test_app_fcp_channel_lookup() {
1416        let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
1417        let app = test_app(vec![ch]);
1418        assert!(app.fcp_channel().is_some());
1419    }
1420
1421    #[test]
1422    fn test_app_channel_schedule_config_valid() {
1423        let ch = test_channel(
1424            ChannelType::Schedule,
1425            serde_json::json!({
1426                "cron_expression": "0 * * * * * *",
1427                "message": "Run checks"
1428            }),
1429        );
1430        let config = ch.schedule_config().unwrap();
1431        assert_eq!(config.message, "Run checks");
1432    }
1433
1434    #[test]
1435    fn test_app_schedule_channel_lookup() {
1436        let ch = test_channel(
1437            ChannelType::Schedule,
1438            serde_json::json!({
1439                "cron_expression": "0 * * * * * *",
1440                "message": "Run checks"
1441            }),
1442        );
1443        let app = test_app(vec![ch]);
1444        assert!(app.schedule_channel().is_some());
1445    }
1446
1447    #[test]
1448    fn test_app_channel_webhook_config_valid() {
1449        let ch = test_channel(
1450            ChannelType::Webhook,
1451            serde_json::json!({
1452                "token": "secret",
1453                "message": "{{payload.ref}}"
1454            }),
1455        );
1456        let config = ch.webhook_config().unwrap();
1457        assert_eq!(config.token, "secret");
1458    }
1459
1460    #[test]
1461    fn test_app_webhook_channel_lookup() {
1462        let ch = test_channel(
1463            ChannelType::Webhook,
1464            serde_json::json!({
1465                "token": "secret",
1466                "message": "{{payload.ref}}"
1467            }),
1468        );
1469        let app = test_app(vec![ch]);
1470        assert!(app.webhook_channel().is_some());
1471    }
1472
1473    #[test]
1474    fn test_a2a_channel_config_defaults() {
1475        let config: A2aChannelConfig = serde_json::from_str(
1476            r#"{"api_key_hash":"abc","api_key_prefix":"evra2a_abc1...","message":"{{a2a.text}}"}"#,
1477        )
1478        .unwrap();
1479        assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1480        assert!(config.agent_card_name.is_none());
1481        assert!(config.agent_card_description.is_none());
1482        assert!(config.rate_limit_per_minute.is_none());
1483        assert!(config.auth.is_none());
1484        assert!(config.signing_secret.is_none());
1485    }
1486
1487    #[test]
1488    fn test_a2a_channel_config_roundtrip() {
1489        let config = A2aChannelConfig {
1490            api_key_hash: "deadbeef".into(),
1491            api_key_prefix: "evra2a_dead...".into(),
1492            session_mode: InvocationSessionMode::SessionPerInvocation,
1493            message: "{{a2a.text}}".into(),
1494            agent_card_name: Some("Inbox triage".into()),
1495            agent_card_description: Some("Triages github events".into()),
1496            rate_limit_per_minute: Some(120),
1497            auth: None,
1498            signing_secret: None,
1499        };
1500        let json = serde_json::to_string(&config).unwrap();
1501        let parsed: A2aChannelConfig = serde_json::from_str(&json).unwrap();
1502        assert_eq!(parsed.api_key_hash, "deadbeef");
1503        assert_eq!(
1504            parsed.session_mode,
1505            InvocationSessionMode::SessionPerInvocation
1506        );
1507        assert_eq!(parsed.agent_card_name.as_deref(), Some("Inbox triage"));
1508        assert_eq!(parsed.rate_limit_per_minute, Some(120));
1509    }
1510
1511    #[test]
1512    fn test_a2a_channel_config_omits_optional_fields() {
1513        let config = A2aChannelConfig {
1514            api_key_hash: "h".into(),
1515            api_key_prefix: "evra2a_h...".into(),
1516            session_mode: InvocationSessionMode::SharedSession,
1517            message: "m".into(),
1518            agent_card_name: None,
1519            agent_card_description: None,
1520            rate_limit_per_minute: None,
1521            auth: None,
1522            signing_secret: None,
1523        };
1524        let json = serde_json::to_value(&config).unwrap();
1525        assert!(json.get("agent_card_name").is_none());
1526        assert!(json.get("agent_card_description").is_none());
1527        assert!(json.get("rate_limit_per_minute").is_none());
1528        assert!(json.get("signing_secret").is_none());
1529    }
1530
1531    #[test]
1532    fn test_app_channel_a2a_config_valid() {
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 config = ch.a2a_config().unwrap();
1542        assert_eq!(config.api_key_prefix, "evra2a_h...");
1543    }
1544
1545    #[test]
1546    fn test_app_a2a_channel_lookup() {
1547        let ch = test_channel(
1548            ChannelType::A2a,
1549            serde_json::json!({
1550                "api_key_hash": "h",
1551                "api_key_prefix": "evra2a_h...",
1552                "message": "{{a2a.text}}"
1553            }),
1554        );
1555        let app = test_app(vec![ch]);
1556        assert!(app.a2a_channel().is_some());
1557    }
1558
1559    #[test]
1560    fn test_channel_type_public_chat_serde_roundtrip() {
1561        let json = serde_json::to_string(&ChannelType::PublicChat).unwrap();
1562        assert_eq!(json, r#""public_chat""#);
1563        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
1564        assert_eq!(parsed, ChannelType::PublicChat);
1565    }
1566
1567    #[test]
1568    fn test_public_chat_channel_config_defaults() {
1569        let config: PublicChatChannelConfig = serde_json::from_str("{}").unwrap();
1570        assert!(config.anonymous);
1571        assert!(config.token.is_none());
1572        assert_eq!(
1573            config.session_expiration_seconds,
1574            DEFAULT_SESSION_EXPIRATION_SECONDS
1575        );
1576        assert!(config.rate_limit_per_minute.is_none());
1577        assert_eq!(config.tool_visibility, AgUiToolVisibility::Generic);
1578        assert_eq!(config.generic_tool_text, DEFAULT_AG_UI_GENERIC_TOOL_TEXT);
1579        assert!(config.auth.is_none());
1580        assert!(config.branding.is_empty());
1581        assert!(config.captcha.is_none());
1582    }
1583
1584    #[test]
1585    fn test_public_chat_channel_config_omits_empty_branding_and_defaults() {
1586        let config: PublicChatChannelConfig = serde_json::from_str("{}").unwrap();
1587        let json = serde_json::to_value(&config).unwrap();
1588        assert!(json.get("branding").is_none());
1589        assert!(json.get("captcha").is_none());
1590        assert!(json.get("generic_tool_text").is_none());
1591        assert!(json.get("rate_limit_per_minute").is_none());
1592    }
1593
1594    #[test]
1595    fn test_public_chat_channel_config_full_roundtrip() {
1596        let json = r##"{
1597            "anonymous": false,
1598            "token": "shared-secret",
1599            "session_expiration_seconds": 3600,
1600            "rate_limit_per_minute": 30,
1601            "tool_visibility": "narrated",
1602            "generic_tool_text": "Thinking...",
1603            "branding": {
1604                "display_name": "Support",
1605                "logo_url": "https://example.com/logo.png",
1606                "primary_color": "#0A1636",
1607                "welcome_message": "How can I help?"
1608            },
1609            "captcha": {
1610                "provider": "turnstile",
1611                "enabled": true,
1612                "site_key": "1x00000000000000000000AA",
1613                "secret_key": "1x0000000000000000000000000000000AA"
1614            }
1615        }"##;
1616        let config: PublicChatChannelConfig = serde_json::from_str(json).unwrap();
1617        assert!(!config.anonymous);
1618        assert_eq!(config.token.as_deref(), Some("shared-secret"));
1619        assert_eq!(config.session_expiration_seconds, 3600);
1620        assert_eq!(config.rate_limit_per_minute, Some(30));
1621        assert_eq!(config.tool_visibility, AgUiToolVisibility::Narrated);
1622        let branding = &config.branding;
1623        assert_eq!(branding.display_name.as_deref(), Some("Support"));
1624        assert_eq!(branding.primary_color.as_deref(), Some("#0A1636"));
1625        let captcha = config.captcha.unwrap();
1626        assert_eq!(captcha.provider, CaptchaProvider::Turnstile);
1627        assert!(captcha.enabled);
1628        assert_eq!(captcha.site_key, "1x00000000000000000000AA");
1629        assert!(captcha.secret_key.is_some());
1630    }
1631
1632    #[test]
1633    fn test_app_channel_public_chat_config_valid() {
1634        let ch = test_channel(
1635            ChannelType::PublicChat,
1636            serde_json::json!({"anonymous": true}),
1637        );
1638        let config = ch.public_chat_config().unwrap();
1639        assert!(config.anonymous);
1640    }
1641
1642    #[test]
1643    fn test_app_public_chat_channel_lookup() {
1644        let ch = test_channel(
1645            ChannelType::PublicChat,
1646            serde_json::json!({"branding": {"display_name": "Helpdesk"}}),
1647        );
1648        let app = test_app(vec![ch]);
1649        assert!(app.public_chat_channel().is_some());
1650    }
1651
1652    #[test]
1653    fn test_app_serde_skips_internal_fields() {
1654        let app = test_app(vec![]);
1655        let json = serde_json::to_value(&app).unwrap();
1656        assert!(json.get("id").is_some()); // public_id serialized as "id"
1657        assert!(json.get("internal_id").is_none()); // skipped
1658        assert!(json.get("org_id").is_none()); // skipped
1659        assert!(json.get("published_at").is_none()); // None skipped
1660    }
1661}