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}
116
117impl std::fmt::Display for ChannelType {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        match self {
120            ChannelType::Slack => write!(f, "slack"),
121            ChannelType::AgUi => write!(f, "ag_ui"),
122            ChannelType::Schedule => write!(f, "schedule"),
123            ChannelType::Webhook => write!(f, "webhook"),
124            ChannelType::A2a => write!(f, "a2a"),
125            ChannelType::Fcp => write!(f, "fcp"),
126        }
127    }
128}
129
130impl ChannelType {
131    pub fn from_str_opt(s: &str) -> Option<Self> {
132        match s {
133            "slack" => Some(ChannelType::Slack),
134            "ag_ui" => Some(ChannelType::AgUi),
135            "schedule" => Some(ChannelType::Schedule),
136            "webhook" => Some(ChannelType::Webhook),
137            "a2a" => Some(ChannelType::A2a),
138            "fcp" => Some(ChannelType::Fcp),
139            _ => None,
140        }
141    }
142}
143
144/// App configuration for deploying agents to channels.
145/// An app binds a harness and optional agent to distribution channels with a
146/// publish lifecycle.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148#[cfg_attr(feature = "openapi", derive(ToSchema))]
149pub struct App {
150    /// External identifier (app_<32-hex>). Shown as "id" in API.
151    #[serde(rename = "id")]
152    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "app_01933b5a000070008000000000000001"))]
153    pub public_id: AppId,
154    /// Internal UUID primary key. Used for FK references. Never exposed in API.
155    #[serde(skip, default = "Uuid::nil")]
156    pub internal_id: Uuid,
157    /// Organization ID. Internal only, not exposed in API.
158    #[serde(skip, default)]
159    pub org_id: i64,
160    /// Display name of the app.
161    pub name: String,
162    /// Human-readable description of what the app does.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub description: Option<String>,
165    /// ID of the harness to use (format: harness_{32-hex}).
166    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "harness_01933b5a00007000800000000000001"))]
167    pub harness_id: HarnessId,
168    /// Optional ID of the agent to use (format: agent_{32-hex}).
169    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agent_01933b5a00007000800000000000001"))]
170    pub agent_id: Option<AgentId>,
171    /// Version resolution policy for the optional agent.
172    #[serde(default)]
173    pub agent_version_policy: AgentVersionPolicy,
174    /// Pinned agent version. Required when policy is `pinned`.
175    #[serde(skip_serializing_if = "Option::is_none")]
176    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "agentver_01933b5a00007000800000000000001"))]
177    pub agent_version_id: Option<AgentVersionId>,
178    /// Optional virtual identity that represents the app in unattended/channel execution.
179    #[serde(skip_serializing_if = "Option::is_none")]
180    #[cfg_attr(feature = "openapi", schema(value_type = Option<String>, example = "identity_01933b5a00007000800000000000001"))]
181    pub agent_identity_id: Option<AgentIdentityId>,
182    /// Owning principal for this app.
183    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "principal_01933b5a000070008000000000000001"))]
184    pub owner_principal_id: PrincipalId,
185    /// Denormalized effective human owner of the owning principal lineage.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub resolved_owner_user_id: Option<Uuid>,
188    /// Owning principal summary.
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub owner: Option<PrincipalSummary>,
191    /// Effective human owner summary.
192    #[serde(skip_serializing_if = "Option::is_none")]
193    pub effective_owner: Option<PrincipalSummary>,
194    /// Distribution channels attached to this app.
195    #[serde(default)]
196    pub channels: Vec<AppChannel>,
197    /// Current lifecycle status.
198    pub status: AppStatus,
199    /// Timestamp when the app was last published.
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub published_at: Option<DateTime<Utc>>,
202    /// Timestamp when the app was created.
203    pub created_at: DateTime<Utc>,
204    /// Timestamp when the app was last updated.
205    pub updated_at: DateTime<Utc>,
206    /// Timestamp when the app was archived.
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub archived_at: Option<DateTime<Utc>>,
209    /// Timestamp when the app was deleted.
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub deleted_at: Option<DateTime<Utc>>,
212}
213
214/// A single distribution channel attached to an App.
215/// Each channel has its own type, config, and lifecycle status.
216#[derive(Debug, Clone, Serialize, Deserialize)]
217#[cfg_attr(feature = "openapi", derive(ToSchema))]
218pub struct AppChannel {
219    /// External identifier (appchan_<32-hex>). Shown as "id" in API.
220    #[serde(rename = "id")]
221    #[cfg_attr(feature = "openapi", schema(value_type = String, example = "appchan_01933b5a000070008000000000000001"))]
222    pub public_id: AppChannelId,
223    /// Internal UUID primary key. Never exposed in API.
224    #[serde(skip, default = "Uuid::nil")]
225    pub internal_id: Uuid,
226    /// Channel type (e.g. slack).
227    pub channel_type: ChannelType,
228    /// Channel-specific configuration (validated per channel type).
229    #[serde(default)]
230    pub channel_config: serde_json::Value,
231    /// Whether this channel is enabled.
232    #[serde(default = "default_true")]
233    pub enabled: bool,
234    /// Timestamp when this channel was created.
235    pub created_at: DateTime<Utc>,
236    /// Timestamp when this channel was last updated.
237    pub updated_at: DateTime<Utc>,
238}
239
240fn default_true() -> bool {
241    true
242}
243
244impl AppChannel {
245    /// Parse channel_config as SlackChannelConfig. Returns None if not a Slack channel
246    /// or if the config is invalid.
247    pub fn slack_config(&self) -> Option<SlackChannelConfig> {
248        if self.channel_type != ChannelType::Slack {
249            return None;
250        }
251        serde_json::from_value(self.channel_config.clone()).ok()
252    }
253
254    /// Parse channel_config as AgUiChannelConfig. Returns None if not an AG-UI
255    /// channel or if the config is invalid.
256    pub fn ag_ui_config(&self) -> Option<AgUiChannelConfig> {
257        if self.channel_type != ChannelType::AgUi {
258            return None;
259        }
260        serde_json::from_value(self.channel_config.clone()).ok()
261    }
262
263    /// Parse channel_config as ScheduleChannelConfig. Returns None if not a
264    /// schedule channel or if the config is invalid.
265    pub fn schedule_config(&self) -> Option<ScheduleChannelConfig> {
266        if self.channel_type != ChannelType::Schedule {
267            return None;
268        }
269        serde_json::from_value(self.channel_config.clone()).ok()
270    }
271
272    /// Parse channel_config as WebhookChannelConfig. Returns None if not a
273    /// webhook channel or if the config is invalid.
274    pub fn webhook_config(&self) -> Option<WebhookChannelConfig> {
275        if self.channel_type != ChannelType::Webhook {
276            return None;
277        }
278        serde_json::from_value(self.channel_config.clone()).ok()
279    }
280
281    /// Parse channel_config as FcpChannelConfig. Returns None if not an FCP
282    /// channel or if the config is invalid.
283    pub fn fcp_config(&self) -> Option<FcpChannelConfig> {
284        if self.channel_type != ChannelType::Fcp {
285            return None;
286        }
287        serde_json::from_value(self.channel_config.clone()).ok()
288    }
289
290    /// Parse channel_config as A2aChannelConfig. Returns None if not an A2A
291    /// channel or if the config is invalid.
292    pub fn a2a_config(&self) -> Option<A2aChannelConfig> {
293        if self.channel_type != ChannelType::A2a {
294            return None;
295        }
296        serde_json::from_value(self.channel_config.clone()).ok()
297    }
298}
299
300impl App {
301    /// Find the first Slack channel on this app.
302    pub fn slack_channel(&self) -> Option<&AppChannel> {
303        self.channels
304            .iter()
305            .find(|ch| ch.channel_type == ChannelType::Slack && ch.enabled)
306    }
307
308    /// Find the first enabled AG-UI channel on this app.
309    pub fn ag_ui_channel(&self) -> Option<&AppChannel> {
310        self.channels
311            .iter()
312            .find(|ch| ch.channel_type == ChannelType::AgUi && ch.enabled)
313    }
314
315    /// Find the first enabled schedule channel on this app.
316    pub fn schedule_channel(&self) -> Option<&AppChannel> {
317        self.channels
318            .iter()
319            .find(|ch| ch.channel_type == ChannelType::Schedule && ch.enabled)
320    }
321
322    /// Find the first enabled webhook channel on this app.
323    pub fn webhook_channel(&self) -> Option<&AppChannel> {
324        self.channels
325            .iter()
326            .find(|ch| ch.channel_type == ChannelType::Webhook && ch.enabled)
327    }
328
329    /// Find the first enabled FCP channel on this app.
330    pub fn fcp_channel(&self) -> Option<&AppChannel> {
331        self.channels
332            .iter()
333            .find(|ch| ch.channel_type == ChannelType::Fcp && ch.enabled)
334    }
335
336    /// Find the first enabled A2A channel on this app.
337    pub fn a2a_channel(&self) -> Option<&AppChannel> {
338        self.channels
339            .iter()
340            .find(|ch| ch.channel_type == ChannelType::A2a && ch.enabled)
341    }
342
343    /// Find a channel by its public ID.
344    pub fn channel_by_id(&self, id: &AppChannelId) -> Option<&AppChannel> {
345        self.channels.iter().find(|ch| ch.public_id == *id)
346    }
347}
348
349/// Session strategy for incoming messages (how messages map to sessions).
350///
351/// This is the Slack-specific config type that serializes in `SlackChannelConfig`.
352/// Converts to/from the generic `SessionRoutingStrategy` in `crate::channel`.
353#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
354#[cfg_attr(feature = "openapi", derive(ToSchema))]
355#[serde(rename_all = "snake_case")]
356pub enum SessionStrategy {
357    /// Each Slack thread gets its own session (default).
358    #[default]
359    PerThread,
360    /// One session per channel.
361    PerChannel,
362    /// One session per user.
363    PerUser,
364}
365
366impl From<SessionStrategy> for crate::channel::SessionRoutingStrategy {
367    fn from(s: SessionStrategy) -> Self {
368        match s {
369            SessionStrategy::PerThread => Self::PerThread,
370            SessionStrategy::PerChannel => Self::PerChannel,
371            SessionStrategy::PerUser => Self::PerUser,
372        }
373    }
374}
375
376impl From<crate::channel::SessionRoutingStrategy> for SessionStrategy {
377    fn from(s: crate::channel::SessionRoutingStrategy) -> Self {
378        match s {
379            crate::channel::SessionRoutingStrategy::PerThread => Self::PerThread,
380            crate::channel::SessionRoutingStrategy::PerChannel => Self::PerChannel,
381            crate::channel::SessionRoutingStrategy::PerUser => Self::PerUser,
382        }
383    }
384}
385
386/// How replies are delivered back to Slack.
387///
388/// This is the Slack-specific config type that serializes in `SlackChannelConfig`.
389/// Converts to/from the generic `ChannelReplyMode` in `crate::channel`.
390#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
391#[cfg_attr(feature = "openapi", derive(ToSchema))]
392#[serde(rename_all = "snake_case")]
393pub enum SlackReplyMode {
394    /// Forward completed assistant messages directly to Slack.
395    #[default]
396    AllMessages,
397    /// Only send deterministic updates emitted via `report_progress`.
398    ReportProgressOnly,
399}
400
401impl From<SlackReplyMode> for crate::channel::ChannelReplyMode {
402    fn from(m: SlackReplyMode) -> Self {
403        match m {
404            SlackReplyMode::AllMessages => Self::AllMessages,
405            SlackReplyMode::ReportProgressOnly => Self::ReportProgressOnly,
406        }
407    }
408}
409
410impl From<crate::channel::ChannelReplyMode> for SlackReplyMode {
411    fn from(m: crate::channel::ChannelReplyMode) -> Self {
412        match m {
413            crate::channel::ChannelReplyMode::AllMessages => Self::AllMessages,
414            crate::channel::ChannelReplyMode::ReportProgressOnly => Self::ReportProgressOnly,
415        }
416    }
417}
418
419/// Typed Slack channel configuration.
420/// Parsed from the `channel_config` JSON field on App.
421#[derive(Debug, Clone, Serialize, Deserialize)]
422#[cfg_attr(feature = "openapi", derive(ToSchema))]
423pub struct SlackChannelConfig {
424    /// Slack signing secret for verifying webhook requests.
425    pub signing_secret: String,
426    /// Slack Bot OAuth token for sending responses.
427    pub bot_token: String,
428    /// Slack channel ID to listen on (e.g., "C0123456789").
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub channel_id: Option<String>,
431    /// Slack team/workspace ID.
432    #[serde(skip_serializing_if = "Option::is_none")]
433    pub team_id: Option<String>,
434    /// How incoming messages map to sessions.
435    #[serde(default)]
436    pub session_strategy: SessionStrategy,
437    /// How replies are delivered back to Slack.
438    #[serde(default)]
439    pub reply_mode: SlackReplyMode,
440    /// Set when Slack successfully verifies the webhook URL (url_verification challenge).
441    #[serde(skip_serializing_if = "Option::is_none")]
442    pub webhook_verified_at: Option<DateTime<Utc>>,
443    /// Set when the first real message is received from Slack.
444    #[serde(skip_serializing_if = "Option::is_none")]
445    pub first_message_received_at: Option<DateTime<Utc>>,
446}
447
448/// Default session expiration for public channel threads (6 hours).
449pub const DEFAULT_SESSION_EXPIRATION_SECONDS: u32 = 6 * 60 * 60;
450
451/// Default public AG-UI text shown while a tool call is running.
452pub const DEFAULT_AG_UI_GENERIC_TOOL_TEXT: &str = "Working...";
453
454/// Public AG-UI tool activity visibility.
455#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
456#[cfg_attr(feature = "openapi", derive(ToSchema))]
457#[serde(rename_all = "snake_case")]
458pub enum AgUiToolVisibility {
459    /// Do not expose tool activity in public AG-UI streams.
460    None,
461    /// Expose only editable generic text, without tool names, args, or output.
462    #[default]
463    Generic,
464    /// Expose backend-authored narration, without raw tool names, args, or output.
465    Narrated,
466}
467
468/// App-published endpoint authentication mode.
469///
470/// Stored inline on `app_channels.channel_config.auth` so users can protect a
471/// single App/channel without first creating org-level identity-provider state.
472#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
473#[cfg_attr(feature = "openapi", derive(ToSchema))]
474#[serde(rename_all = "snake_case")]
475pub enum AppEndpointAuthMode {
476    Anonymous,
477    SharedSecret,
478    ApiKey,
479    GoogleOidc,
480    Oidc,
481    OAuth2Introspection,
482    HttpBasic,
483    Mtls,
484}
485
486/// OIDC/OAuth/basic/mTLS provider details for one App endpoint.
487#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
488#[cfg_attr(feature = "openapi", derive(ToSchema))]
489#[serde(tag = "type", rename_all = "snake_case")]
490pub enum AppEndpointAuthProviderConfig {
491    GoogleOidc {
492        client_id: String,
493        #[serde(default, skip_serializing_if = "Vec::is_empty")]
494        allowed_domains: Vec<String>,
495    },
496    Oidc {
497        issuer: String,
498        #[serde(default, skip_serializing_if = "Option::is_none")]
499        jwks_url: Option<String>,
500    },
501    OAuth2Introspection {
502        introspection_url: String,
503        #[serde(default, skip_serializing_if = "Option::is_none")]
504        client_id: Option<String>,
505        #[serde(default, skip_serializing_if = "Option::is_none")]
506        client_secret: Option<String>,
507    },
508    HttpBasic {
509        username: String,
510        #[serde(default, skip_serializing_if = "Option::is_none")]
511        password: Option<String>,
512        #[serde(default, skip_serializing_if = "Option::is_none")]
513        password_hash: Option<String>,
514    },
515    Mtls {
516        header_name: String,
517        #[serde(default, skip_serializing_if = "Vec::is_empty")]
518        allowed_values: Vec<String>,
519    },
520}
521
522/// Claim and credential requirements common to App endpoint auth providers.
523#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
524#[cfg_attr(feature = "openapi", derive(ToSchema))]
525pub struct AppEndpointAuthRequirements {
526    /// JWT `aud` values to require on inbound tokens. Empty list disables audience checking.
527    #[serde(default, skip_serializing_if = "Vec::is_empty")]
528    pub audiences: Vec<String>,
529    /// OAuth scope strings to require (space-delimited per scope entry). Empty list disables scope checking.
530    #[serde(default, skip_serializing_if = "Vec::is_empty")]
531    pub scopes: Vec<String>,
532    /// Arbitrary claim equality predicates. Empty map disables claim filtering.
533    #[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
534    pub claims: serde_json::Map<String, serde_json::Value>,
535    /// Allowlist of `sub` claim values. Empty list disables subject filtering.
536    #[serde(default, skip_serializing_if = "Vec::is_empty")]
537    pub subjects: Vec<String>,
538    /// Allowlist of group memberships (from `groups` claim). Empty list disables group filtering.
539    #[serde(default, skip_serializing_if = "Vec::is_empty")]
540    pub groups: Vec<String>,
541    /// Allowlist of email/identifier domains. Empty list disables domain filtering.
542    #[serde(default, skip_serializing_if = "Vec::is_empty")]
543    pub domains: Vec<String>,
544}
545
546/// Inline auth config for one App endpoint/channel.
547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
548#[cfg_attr(feature = "openapi", derive(ToSchema))]
549#[cfg_attr(
550    feature = "openapi",
551    schema(example = json!({"mode": "api_key", "requirements": {"audiences": ["everruns-api"], "scopes": ["app:invoke"]}}))
552)]
553pub struct AppEndpointAuthConfig {
554    pub mode: AppEndpointAuthMode,
555    #[serde(default, skip_serializing_if = "Option::is_none")]
556    pub provider: Option<AppEndpointAuthProviderConfig>,
557    #[serde(default)]
558    pub requirements: AppEndpointAuthRequirements,
559}
560
561/// Typed AG-UI channel configuration.
562///
563/// Parsed from the `channel_config` JSON field on App.
564#[derive(Debug, Clone, Serialize, Deserialize)]
565#[cfg_attr(feature = "openapi", derive(ToSchema))]
566pub struct AgUiChannelConfig {
567    /// Whether anonymous access is allowed for this endpoint.
568    /// Enabled by default for the initial AG-UI rollout.
569    #[serde(default = "default_true")]
570    pub anonymous: bool,
571    /// Optional shared bearer token for the public AG-UI endpoint.
572    /// When set, requests must include the token in a supported header.
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub token: Option<String>,
575    /// How long (in seconds) a thread can be resumed after its session was
576    /// created. Once this elapses, the same `thread_id` cannot reuse the
577    /// existing session and must start a new one. `0` disables expiration.
578    /// Defaults to 6 hours.
579    #[serde(default = "default_session_expiration_seconds")]
580    pub session_expiration_seconds: u32,
581    /// Optional per-IP rate limit applied to this app's AG-UI endpoint, in
582    /// requests per minute. `None` or `Some(0)` disables the per-app limit
583    /// (the global API limit still applies). Set a positive value to enforce
584    /// a stricter cap on anonymous traffic for this app.
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub rate_limit_per_minute: Option<u32>,
587    /// Public tool activity visibility for anonymous AG-UI streams.
588    #[serde(default)]
589    pub tool_visibility: AgUiToolVisibility,
590    /// Generic public text shown when `tool_visibility` is `generic`.
591    #[serde(
592        default = "default_ag_ui_generic_tool_text",
593        skip_serializing_if = "is_default_ag_ui_generic_tool_text"
594    )]
595    pub generic_tool_text: String,
596    /// Optional inline auth config for this public endpoint. When omitted,
597    /// legacy `anonymous` + `token` behavior applies.
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub auth: Option<AppEndpointAuthConfig>,
600}
601
602fn default_session_expiration_seconds() -> u32 {
603    DEFAULT_SESSION_EXPIRATION_SECONDS
604}
605
606fn default_ag_ui_generic_tool_text() -> String {
607    DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string()
608}
609
610fn is_default_ag_ui_generic_tool_text(value: &str) -> bool {
611    value == DEFAULT_AG_UI_GENERIC_TOOL_TEXT
612}
613
614/// Default FCP handshake body. Returned by `GET` when the channel config does
615/// not override `handshake`. Kept generic so an unconfigured FCP endpoint
616/// still satisfies the FCP `SHOULD` for handshake responses.
617pub 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.";
618
619/// Default response timeout for a blocking FCP POST, in seconds.
620pub const DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS: u32 = 120;
621
622/// Typed FCP channel configuration.
623///
624/// FCP is intentionally schema-free at the wire layer (see
625/// `specs/fcp-channel.md`). The fields here only control server-side
626/// behavior — handshake content, a single shared bearer token, session
627/// reuse, rate limiting — and never constrain the body the actor sends in.
628///
629/// FCP deliberately exposes a **smaller** auth surface than AG-UI/A2A: it
630/// supports anonymous access and a single shared bearer token, and nothing
631/// else. Inline OIDC/HTTP-Basic/mTLS verifier modes are intentionally
632/// **not** wired here so the FCP ingress path does not share authentication
633/// machinery with other channels or with the main API's user auth stack.
634/// Operators that need IdP-backed auth in front of an FCP endpoint should
635/// terminate that at the edge (reverse proxy, IAP, mTLS) rather than asking
636/// the FCP handler to grow another auth mode.
637#[derive(Debug, Clone, Serialize, Deserialize)]
638#[cfg_attr(feature = "openapi", derive(ToSchema))]
639pub struct FcpChannelConfig {
640    /// Whether anonymous access is allowed for this endpoint. When `false`
641    /// a non-empty `token` must authenticate every `POST`.
642    #[serde(default = "default_true")]
643    pub anonymous: bool,
644    /// Optional shared bearer token. When set, callers must send
645    /// `Authorization: Bearer <token>` (or the `X-Everruns-FCP-Token`
646    /// header). Validated by constant-time comparison inside the FCP
647    /// handler — never via the shared App endpoint auth verifier.
648    #[serde(default, skip_serializing_if = "Option::is_none")]
649    pub token: Option<String>,
650    /// Markdown body returned for `GET` requests (the FCP handshake). When
651    /// omitted, a generic handshake derived from the app's name and
652    /// description is used.
653    #[serde(default, skip_serializing_if = "Option::is_none")]
654    pub handshake: Option<String>,
655    /// How long (in seconds) the FCP session cookie keeps a session
656    /// resumable. `0` disables expiration. Defaults to 6 hours so a
657    /// long-running conversation expires on a sensible cadence.
658    #[serde(default = "default_session_expiration_seconds")]
659    pub session_expiration_seconds: u32,
660    /// Optional per-IP rate limit (requests per minute) for the FCP endpoint.
661    /// `None` or `Some(0)` disables the per-app limit; the global API limit
662    /// still applies. Counted in an FCP-specific limiter namespace so it
663    /// cannot be shared or exhausted by other channels.
664    #[serde(default, skip_serializing_if = "Option::is_none")]
665    pub rate_limit_per_minute: Option<u32>,
666    /// Maximum number of seconds the FCP endpoint waits for the agent to
667    /// produce a reply before returning a `504`. Defaults to 120 s.
668    #[serde(default = "default_fcp_response_timeout_seconds")]
669    pub response_timeout_seconds: u32,
670}
671
672fn default_fcp_response_timeout_seconds() -> u32 {
673    DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
674}
675
676/// How app-triggered invocations route into sessions.
677#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
678#[cfg_attr(feature = "openapi", derive(ToSchema))]
679#[cfg_attr(feature = "openapi", schema(example = "shared_session"))]
680#[serde(rename_all = "snake_case")]
681pub enum InvocationSessionMode {
682    /// Reuse a single durable session for every invocation of the channel.
683    #[default]
684    SharedSession,
685    /// Create a fresh session for every invocation.
686    SessionPerInvocation,
687}
688
689/// Typed schedule channel configuration.
690///
691/// `message` is also the template body. `{{path.to.value}}` placeholders are
692/// expanded at invocation time.
693#[derive(Debug, Clone, Serialize, Deserialize)]
694#[cfg_attr(feature = "openapi", derive(ToSchema))]
695pub struct ScheduleChannelConfig {
696    /// Cron expression that drives the durable schedule.
697    pub cron_expression: String,
698    /// IANA timezone identifier for cron evaluation.
699    #[serde(default = "default_timezone")]
700    pub timezone: String,
701    /// Whether invocations reuse a stable session or create a new one.
702    #[serde(default)]
703    pub session_mode: InvocationSessionMode,
704    /// Message content or template sent when the schedule fires.
705    pub message: String,
706}
707
708/// Typed webhook channel configuration.
709///
710/// `message` is also the template body. `{{path.to.value}}` placeholders are
711/// expanded against the incoming webhook payload and metadata.
712#[derive(Debug, Clone, Serialize, Deserialize)]
713#[cfg_attr(feature = "openapi", derive(ToSchema))]
714pub struct WebhookChannelConfig {
715    /// Shared secret required from the incoming webhook request.
716    pub token: String,
717    /// Whether invocations reuse a stable session or create a new one.
718    #[serde(default)]
719    pub session_mode: InvocationSessionMode,
720    /// Message content or template sent when the webhook arrives.
721    pub message: String,
722}
723
724fn default_timezone() -> String {
725    "UTC".to_string()
726}
727
728/// Typed A2A (Agent2Agent) channel configuration.
729///
730/// The plaintext API key is **never** stored. Only the SHA-256 hex hash and a
731/// non-secret display prefix are persisted. The plaintext is returned exactly
732/// once at create / regenerate time.
733///
734/// `message` is the template body. `{{path.to.value}}` placeholders expand
735/// against the incoming A2A request payload and metadata (see
736/// `specs/a2a-channel.md`).
737#[derive(Debug, Clone, Serialize, Deserialize)]
738#[cfg_attr(feature = "openapi", derive(ToSchema))]
739pub struct A2aChannelConfig {
740    /// SHA-256 hex digest of the API key.
741    pub api_key_hash: String,
742    /// Public, non-secret display prefix (e.g. `evra2a_abc1...`).
743    pub api_key_prefix: String,
744    /// Whether invocations reuse a stable session or create a new one.
745    #[serde(default)]
746    pub session_mode: InvocationSessionMode,
747    /// Message template rendered into the session per invocation.
748    pub message: String,
749    /// Optional human-readable agent name surfaced in the Agent Card.
750    #[serde(default, skip_serializing_if = "Option::is_none")]
751    pub agent_card_name: Option<String>,
752    /// Optional description surfaced in the Agent Card.
753    #[serde(default, skip_serializing_if = "Option::is_none")]
754    pub agent_card_description: Option<String>,
755    /// Optional per-IP rate limit applied to this app's A2A endpoint, in
756    /// requests per minute. `None` or `Some(0)` disables the per-channel
757    /// limit (the global API limit still applies). Set a positive value to
758    /// enforce a stricter cap on unattended agent-to-agent traffic for this
759    /// app. Mirrors `AgUiChannelConfig::rate_limit_per_minute`.
760    #[serde(default, skip_serializing_if = "Option::is_none")]
761    pub rate_limit_per_minute: Option<u32>,
762    /// Optional inline auth config for this A2A endpoint. When omitted,
763    /// legacy per-channel API-key behavior applies.
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub auth: Option<AppEndpointAuthConfig>,
766    /// Optional shared HMAC signing secret. When set, requests must include
767    /// `X-Everruns-A2A-Timestamp` + `X-Everruns-A2A-Signature` headers and
768    /// the server verifies an HMAC-SHA256 signature over the exact
769    /// basestring `v0:{timestamp}:{body}` (Slack-style, no whitespace
770    /// between segments) plus a 5-minute timestamp window plus a
771    /// signature-keyed dedup so a captured request cannot be replayed
772    /// while the API key is still valid (TM-A2A-010). When `None`, the
773    /// channel keeps the existing API-key-only behavior. Layered **on top
774    /// of** `auth` — independent concerns: `auth` selects who can call,
775    /// `signing_secret` adds replay protection on top.
776    #[serde(default, skip_serializing_if = "Option::is_none")]
777    pub signing_secret: Option<String>,
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    #[test]
785    fn test_app_status_display() {
786        assert_eq!(AppStatus::Draft.to_string(), "draft");
787        assert_eq!(AppStatus::Published.to_string(), "published");
788        assert_eq!(AppStatus::Archived.to_string(), "archived");
789        assert_eq!(AppStatus::Deleted.to_string(), "deleted");
790    }
791
792    #[test]
793    fn test_app_status_from_str() {
794        assert_eq!(AppStatus::from("draft"), AppStatus::Draft);
795        assert_eq!(AppStatus::from("published"), AppStatus::Published);
796        assert_eq!(AppStatus::from("archived"), AppStatus::Archived);
797        assert_eq!(AppStatus::from("deleted"), AppStatus::Deleted);
798        assert_eq!(AppStatus::from("unknown"), AppStatus::Draft);
799        assert_eq!(AppStatus::from(""), AppStatus::Draft);
800    }
801
802    #[test]
803    fn test_app_status_serde_roundtrip() {
804        let json = serde_json::to_string(&AppStatus::Published).unwrap();
805        assert_eq!(json, r#""published""#);
806        let parsed: AppStatus = serde_json::from_str(&json).unwrap();
807        assert_eq!(parsed, AppStatus::Published);
808    }
809
810    #[test]
811    fn test_channel_type_display() {
812        assert_eq!(ChannelType::Slack.to_string(), "slack");
813        assert_eq!(ChannelType::AgUi.to_string(), "ag_ui");
814        assert_eq!(ChannelType::Schedule.to_string(), "schedule");
815        assert_eq!(ChannelType::Webhook.to_string(), "webhook");
816        assert_eq!(ChannelType::A2a.to_string(), "a2a");
817        assert_eq!(ChannelType::Fcp.to_string(), "fcp");
818    }
819
820    #[test]
821    fn test_channel_type_from_str_opt() {
822        assert_eq!(ChannelType::from_str_opt("slack"), Some(ChannelType::Slack));
823        assert_eq!(ChannelType::from_str_opt("ag_ui"), Some(ChannelType::AgUi));
824        assert_eq!(
825            ChannelType::from_str_opt("schedule"),
826            Some(ChannelType::Schedule)
827        );
828        assert_eq!(
829            ChannelType::from_str_opt("webhook"),
830            Some(ChannelType::Webhook)
831        );
832        assert_eq!(ChannelType::from_str_opt("a2a"), Some(ChannelType::A2a));
833        assert_eq!(ChannelType::from_str_opt("fcp"), Some(ChannelType::Fcp));
834        assert_eq!(ChannelType::from_str_opt("unknown"), None);
835        assert_eq!(ChannelType::from_str_opt(""), None);
836    }
837
838    #[test]
839    fn test_channel_type_serde_roundtrip() {
840        let json = serde_json::to_string(&ChannelType::Slack).unwrap();
841        assert_eq!(json, r#""slack""#);
842        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
843        assert_eq!(parsed, ChannelType::Slack);
844
845        let json = serde_json::to_string(&ChannelType::AgUi).unwrap();
846        assert_eq!(json, r#""ag_ui""#);
847        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
848        assert_eq!(parsed, ChannelType::AgUi);
849
850        let json = serde_json::to_string(&ChannelType::Schedule).unwrap();
851        assert_eq!(json, r#""schedule""#);
852        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
853        assert_eq!(parsed, ChannelType::Schedule);
854
855        let json = serde_json::to_string(&ChannelType::Webhook).unwrap();
856        assert_eq!(json, r#""webhook""#);
857        let parsed: ChannelType = serde_json::from_str(&json).unwrap();
858        assert_eq!(parsed, ChannelType::Webhook);
859    }
860
861    #[test]
862    fn test_session_strategy_default() {
863        assert_eq!(SessionStrategy::default(), SessionStrategy::PerThread);
864    }
865
866    #[test]
867    fn test_session_strategy_serde() {
868        let json = serde_json::to_string(&SessionStrategy::PerChannel).unwrap();
869        assert_eq!(json, r#""per_channel""#);
870        let parsed: SessionStrategy = serde_json::from_str(&json).unwrap();
871        assert_eq!(parsed, SessionStrategy::PerChannel);
872
873        let json = serde_json::to_string(&SessionStrategy::PerUser).unwrap();
874        assert_eq!(json, r#""per_user""#);
875    }
876
877    #[test]
878    fn test_slack_channel_config_full() {
879        let json = r#"{
880            "signing_secret": "sec123",
881            "bot_token": "xoxb-tok",
882            "channel_id": "C123",
883            "team_id": "T123",
884            "session_strategy": "per_channel",
885            "reply_mode": "report_progress_only"
886        }"#;
887        let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
888        assert_eq!(config.signing_secret, "sec123");
889        assert_eq!(config.bot_token, "xoxb-tok");
890        assert_eq!(config.channel_id.as_deref(), Some("C123"));
891        assert_eq!(config.team_id.as_deref(), Some("T123"));
892        assert_eq!(config.session_strategy, SessionStrategy::PerChannel);
893        assert_eq!(config.reply_mode, SlackReplyMode::ReportProgressOnly);
894    }
895
896    #[test]
897    fn test_slack_channel_config_minimal() {
898        let json = r#"{"signing_secret": "s", "bot_token": "t"}"#;
899        let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
900        assert!(config.channel_id.is_none());
901        assert!(config.team_id.is_none());
902        assert_eq!(config.session_strategy, SessionStrategy::PerThread);
903        assert_eq!(config.reply_mode, SlackReplyMode::AllMessages);
904        assert!(config.webhook_verified_at.is_none());
905        assert!(config.first_message_received_at.is_none());
906    }
907
908    #[test]
909    fn test_slack_channel_config_with_verification_timestamps() {
910        let json = r#"{
911            "signing_secret": "s",
912            "bot_token": "t",
913            "webhook_verified_at": "2025-01-01T00:00:00Z",
914            "first_message_received_at": "2025-01-01T01:00:00Z"
915        }"#;
916        let config: SlackChannelConfig = serde_json::from_str(json).unwrap();
917        assert!(config.webhook_verified_at.is_some());
918        assert!(config.first_message_received_at.is_some());
919
920        // Round-trip: timestamps should be preserved
921        let serialized = serde_json::to_value(&config).unwrap();
922        assert!(serialized.get("webhook_verified_at").is_some());
923        assert!(serialized.get("first_message_received_at").is_some());
924    }
925
926    #[test]
927    fn test_slack_channel_config_timestamps_skipped_when_none() {
928        let config = SlackChannelConfig {
929            signing_secret: "s".into(),
930            bot_token: "t".into(),
931            channel_id: None,
932            team_id: None,
933            session_strategy: SessionStrategy::PerThread,
934            reply_mode: SlackReplyMode::AllMessages,
935            webhook_verified_at: None,
936            first_message_received_at: None,
937        };
938        let json = serde_json::to_value(&config).unwrap();
939        assert!(json.get("webhook_verified_at").is_none());
940        assert!(json.get("first_message_received_at").is_none());
941    }
942
943    #[test]
944    fn test_slack_reply_mode_serde_roundtrip() {
945        let json = serde_json::to_string(&SlackReplyMode::ReportProgressOnly).unwrap();
946        assert_eq!(json, r#""report_progress_only""#);
947        let parsed: SlackReplyMode = serde_json::from_str(&json).unwrap();
948        assert_eq!(parsed, SlackReplyMode::ReportProgressOnly);
949    }
950
951    #[test]
952    fn test_slack_channel_config_missing_required_field() {
953        let json = r#"{"signing_secret": "s"}"#;
954        assert!(serde_json::from_str::<SlackChannelConfig>(json).is_err());
955    }
956
957    #[test]
958    fn test_ag_ui_channel_config_defaults_to_anonymous() {
959        let config: AgUiChannelConfig = serde_json::from_str("{}").unwrap();
960        assert!(config.anonymous);
961        assert_eq!(
962            config.session_expiration_seconds,
963            DEFAULT_SESSION_EXPIRATION_SECONDS
964        );
965        assert!(config.rate_limit_per_minute.is_none());
966        assert!(config.token.is_none());
967        assert!(config.auth.is_none());
968        assert_eq!(config.tool_visibility, AgUiToolVisibility::Generic);
969        assert_eq!(config.generic_tool_text, DEFAULT_AG_UI_GENERIC_TOOL_TEXT);
970    }
971
972    #[test]
973    fn test_ag_ui_channel_config_roundtrip() {
974        let config = AgUiChannelConfig {
975            anonymous: true,
976            token: Some("agui-token".to_string()),
977            session_expiration_seconds: 3600,
978            rate_limit_per_minute: Some(120),
979            tool_visibility: AgUiToolVisibility::None,
980            generic_tool_text: "Please wait".to_string(),
981            auth: None,
982        };
983        let json = serde_json::to_string(&config).unwrap();
984        let parsed: AgUiChannelConfig = serde_json::from_str(&json).unwrap();
985        assert!(parsed.anonymous);
986        assert_eq!(parsed.token.as_deref(), Some("agui-token"));
987        assert_eq!(parsed.session_expiration_seconds, 3600);
988        assert_eq!(parsed.rate_limit_per_minute, Some(120));
989        assert_eq!(parsed.tool_visibility, AgUiToolVisibility::None);
990        assert_eq!(parsed.generic_tool_text, "Please wait");
991    }
992
993    #[test]
994    fn test_ag_ui_channel_config_zero_disables_expiration() {
995        let config: AgUiChannelConfig =
996            serde_json::from_str(r#"{"session_expiration_seconds": 0}"#).unwrap();
997        assert_eq!(config.session_expiration_seconds, 0);
998    }
999
1000    #[test]
1001    fn test_ag_ui_channel_config_omits_rate_limit_when_unset() {
1002        let config = AgUiChannelConfig {
1003            anonymous: true,
1004            token: None,
1005            session_expiration_seconds: DEFAULT_SESSION_EXPIRATION_SECONDS,
1006            rate_limit_per_minute: None,
1007            tool_visibility: AgUiToolVisibility::Generic,
1008            generic_tool_text: DEFAULT_AG_UI_GENERIC_TOOL_TEXT.to_string(),
1009            auth: None,
1010        };
1011        let json = serde_json::to_value(&config).unwrap();
1012        assert!(json.get("rate_limit_per_minute").is_none());
1013        assert!(json.get("generic_tool_text").is_none());
1014    }
1015
1016    #[test]
1017    fn test_invocation_session_mode_defaults_to_shared_session() {
1018        assert_eq!(
1019            InvocationSessionMode::default(),
1020            InvocationSessionMode::SharedSession
1021        );
1022    }
1023
1024    #[test]
1025    fn test_schedule_channel_config_defaults() {
1026        let config: ScheduleChannelConfig =
1027            serde_json::from_str(r#"{"cron_expression":"0 * * * * * *","message":"Run checks"}"#)
1028                .unwrap();
1029        assert_eq!(config.timezone, "UTC");
1030        assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1031    }
1032
1033    #[test]
1034    fn test_webhook_channel_config_defaults() {
1035        let config: WebhookChannelConfig =
1036            serde_json::from_str(r#"{"token":"top-secret","message":"{{payload.action}}"}"#)
1037                .unwrap();
1038        assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1039    }
1040
1041    fn test_app(channels: Vec<AppChannel>) -> App {
1042        App {
1043            public_id: AppId::from_uuid(Uuid::nil()),
1044            internal_id: Uuid::nil(),
1045            org_id: 1,
1046            name: "test".into(),
1047            description: None,
1048            harness_id: HarnessId::from_uuid(Uuid::nil()),
1049            agent_id: Some(AgentId::from_uuid(Uuid::nil())),
1050            agent_version_policy: AgentVersionPolicy::Default,
1051            agent_version_id: None,
1052            agent_identity_id: None,
1053            owner_principal_id: PrincipalId::from_seed(1),
1054            resolved_owner_user_id: None,
1055            owner: None,
1056            effective_owner: None,
1057            channels,
1058            status: AppStatus::Draft,
1059            published_at: None,
1060            created_at: Utc::now(),
1061            updated_at: Utc::now(),
1062            archived_at: None,
1063            deleted_at: None,
1064        }
1065    }
1066
1067    fn test_channel(channel_type: ChannelType, config: serde_json::Value) -> AppChannel {
1068        AppChannel {
1069            public_id: AppChannelId::from_uuid(Uuid::nil()),
1070            internal_id: Uuid::nil(),
1071            channel_type,
1072            channel_config: config,
1073            enabled: true,
1074            created_at: Utc::now(),
1075            updated_at: Utc::now(),
1076        }
1077    }
1078
1079    #[test]
1080    fn test_app_channel_slack_config_valid() {
1081        let ch = test_channel(
1082            ChannelType::Slack,
1083            serde_json::json!({"signing_secret": "sec", "bot_token": "tok"}),
1084        );
1085        let config = ch.slack_config().unwrap();
1086        assert_eq!(config.signing_secret, "sec");
1087    }
1088
1089    #[test]
1090    fn test_app_channel_slack_config_invalid_json() {
1091        let ch = test_channel(ChannelType::Slack, serde_json::json!({"bad": "data"}));
1092        assert!(ch.slack_config().is_none());
1093    }
1094
1095    #[test]
1096    fn test_app_slack_channel_lookup() {
1097        let ch = test_channel(
1098            ChannelType::Slack,
1099            serde_json::json!({"signing_secret": "s", "bot_token": "t"}),
1100        );
1101        let app = test_app(vec![ch]);
1102        assert!(app.slack_channel().is_some());
1103    }
1104
1105    #[test]
1106    fn test_app_slack_channel_none_when_empty() {
1107        let app = test_app(vec![]);
1108        assert!(app.slack_channel().is_none());
1109    }
1110
1111    #[test]
1112    fn test_app_channel_ag_ui_config_valid() {
1113        let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
1114        let config = ch.ag_ui_config().unwrap();
1115        assert!(config.anonymous);
1116    }
1117
1118    #[test]
1119    fn test_app_ag_ui_channel_lookup() {
1120        let ch = test_channel(ChannelType::AgUi, serde_json::json!({"anonymous": true}));
1121        let app = test_app(vec![ch]);
1122        assert!(app.ag_ui_channel().is_some());
1123    }
1124
1125    #[test]
1126    fn test_app_channel_fcp_config_defaults() {
1127        let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
1128        let config = ch.fcp_config().unwrap();
1129        assert!(config.anonymous);
1130        assert!(config.token.is_none());
1131        assert!(config.handshake.is_none());
1132        assert_eq!(
1133            config.session_expiration_seconds,
1134            DEFAULT_SESSION_EXPIRATION_SECONDS
1135        );
1136        assert_eq!(
1137            config.response_timeout_seconds,
1138            DEFAULT_FCP_RESPONSE_TIMEOUT_SECONDS
1139        );
1140    }
1141
1142    #[test]
1143    fn test_app_fcp_channel_lookup() {
1144        let ch = test_channel(ChannelType::Fcp, serde_json::json!({}));
1145        let app = test_app(vec![ch]);
1146        assert!(app.fcp_channel().is_some());
1147    }
1148
1149    #[test]
1150    fn test_app_channel_schedule_config_valid() {
1151        let ch = test_channel(
1152            ChannelType::Schedule,
1153            serde_json::json!({
1154                "cron_expression": "0 * * * * * *",
1155                "message": "Run checks"
1156            }),
1157        );
1158        let config = ch.schedule_config().unwrap();
1159        assert_eq!(config.message, "Run checks");
1160    }
1161
1162    #[test]
1163    fn test_app_schedule_channel_lookup() {
1164        let ch = test_channel(
1165            ChannelType::Schedule,
1166            serde_json::json!({
1167                "cron_expression": "0 * * * * * *",
1168                "message": "Run checks"
1169            }),
1170        );
1171        let app = test_app(vec![ch]);
1172        assert!(app.schedule_channel().is_some());
1173    }
1174
1175    #[test]
1176    fn test_app_channel_webhook_config_valid() {
1177        let ch = test_channel(
1178            ChannelType::Webhook,
1179            serde_json::json!({
1180                "token": "secret",
1181                "message": "{{payload.ref}}"
1182            }),
1183        );
1184        let config = ch.webhook_config().unwrap();
1185        assert_eq!(config.token, "secret");
1186    }
1187
1188    #[test]
1189    fn test_app_webhook_channel_lookup() {
1190        let ch = test_channel(
1191            ChannelType::Webhook,
1192            serde_json::json!({
1193                "token": "secret",
1194                "message": "{{payload.ref}}"
1195            }),
1196        );
1197        let app = test_app(vec![ch]);
1198        assert!(app.webhook_channel().is_some());
1199    }
1200
1201    #[test]
1202    fn test_a2a_channel_config_defaults() {
1203        let config: A2aChannelConfig = serde_json::from_str(
1204            r#"{"api_key_hash":"abc","api_key_prefix":"evra2a_abc1...","message":"{{a2a.text}}"}"#,
1205        )
1206        .unwrap();
1207        assert_eq!(config.session_mode, InvocationSessionMode::SharedSession);
1208        assert!(config.agent_card_name.is_none());
1209        assert!(config.agent_card_description.is_none());
1210        assert!(config.rate_limit_per_minute.is_none());
1211        assert!(config.auth.is_none());
1212        assert!(config.signing_secret.is_none());
1213    }
1214
1215    #[test]
1216    fn test_a2a_channel_config_roundtrip() {
1217        let config = A2aChannelConfig {
1218            api_key_hash: "deadbeef".into(),
1219            api_key_prefix: "evra2a_dead...".into(),
1220            session_mode: InvocationSessionMode::SessionPerInvocation,
1221            message: "{{a2a.text}}".into(),
1222            agent_card_name: Some("Inbox triage".into()),
1223            agent_card_description: Some("Triages github events".into()),
1224            rate_limit_per_minute: Some(120),
1225            auth: None,
1226            signing_secret: None,
1227        };
1228        let json = serde_json::to_string(&config).unwrap();
1229        let parsed: A2aChannelConfig = serde_json::from_str(&json).unwrap();
1230        assert_eq!(parsed.api_key_hash, "deadbeef");
1231        assert_eq!(
1232            parsed.session_mode,
1233            InvocationSessionMode::SessionPerInvocation
1234        );
1235        assert_eq!(parsed.agent_card_name.as_deref(), Some("Inbox triage"));
1236        assert_eq!(parsed.rate_limit_per_minute, Some(120));
1237    }
1238
1239    #[test]
1240    fn test_a2a_channel_config_omits_optional_fields() {
1241        let config = A2aChannelConfig {
1242            api_key_hash: "h".into(),
1243            api_key_prefix: "evra2a_h...".into(),
1244            session_mode: InvocationSessionMode::SharedSession,
1245            message: "m".into(),
1246            agent_card_name: None,
1247            agent_card_description: None,
1248            rate_limit_per_minute: None,
1249            auth: None,
1250            signing_secret: None,
1251        };
1252        let json = serde_json::to_value(&config).unwrap();
1253        assert!(json.get("agent_card_name").is_none());
1254        assert!(json.get("agent_card_description").is_none());
1255        assert!(json.get("rate_limit_per_minute").is_none());
1256        assert!(json.get("signing_secret").is_none());
1257    }
1258
1259    #[test]
1260    fn test_app_channel_a2a_config_valid() {
1261        let ch = test_channel(
1262            ChannelType::A2a,
1263            serde_json::json!({
1264                "api_key_hash": "h",
1265                "api_key_prefix": "evra2a_h...",
1266                "message": "{{a2a.text}}"
1267            }),
1268        );
1269        let config = ch.a2a_config().unwrap();
1270        assert_eq!(config.api_key_prefix, "evra2a_h...");
1271    }
1272
1273    #[test]
1274    fn test_app_a2a_channel_lookup() {
1275        let ch = test_channel(
1276            ChannelType::A2a,
1277            serde_json::json!({
1278                "api_key_hash": "h",
1279                "api_key_prefix": "evra2a_h...",
1280                "message": "{{a2a.text}}"
1281            }),
1282        );
1283        let app = test_app(vec![ch]);
1284        assert!(app.a2a_channel().is_some());
1285    }
1286
1287    #[test]
1288    fn test_app_serde_skips_internal_fields() {
1289        let app = test_app(vec![]);
1290        let json = serde_json::to_value(&app).unwrap();
1291        assert!(json.get("id").is_some()); // public_id serialized as "id"
1292        assert!(json.get("internal_id").is_none()); // skipped
1293        assert!(json.get("org_id").is_none()); // skipped
1294        assert!(json.get("published_at").is_none()); // None skipped
1295    }
1296}