Skip to main content

opensession_api/
lib.rs

1//! Shared API types, crypto, and SQL builders for opensession.io
2//!
3//! This crate is the **single source of truth** for all API request/response types.
4//! TypeScript types are auto-generated via `ts-rs` and consumed by the frontend.
5//!
6//! To regenerate TypeScript types:
7//!   cargo test -p opensession-api -- export_typescript --nocapture
8
9use serde::{Deserialize, Serialize};
10
11#[cfg(feature = "backend")]
12pub mod crypto;
13#[cfg(feature = "backend")]
14pub mod db;
15pub mod deploy;
16pub mod oauth;
17#[cfg(feature = "backend")]
18pub mod service;
19
20// Re-export core HAIL types for convenience
21pub use opensession_core::trace::{
22    Agent, Content, ContentBlock, Event, EventType, Session, SessionContext, Stats,
23};
24
25// ─── Shared Enums ────────────────────────────────────────────────────────────
26
27/// Role within a team.
28#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
29#[serde(rename_all = "snake_case")]
30#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
31#[cfg_attr(feature = "ts", ts(export))]
32pub enum TeamRole {
33    Admin,
34    Member,
35}
36
37impl TeamRole {
38    pub fn as_str(&self) -> &str {
39        match self {
40            Self::Admin => "admin",
41            Self::Member => "member",
42        }
43    }
44}
45
46impl std::fmt::Display for TeamRole {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        f.write_str(self.as_str())
49    }
50}
51
52/// Status of a team invitation.
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "snake_case")]
55#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
56#[cfg_attr(feature = "ts", ts(export))]
57pub enum InvitationStatus {
58    Pending,
59    Accepted,
60    Declined,
61}
62
63impl InvitationStatus {
64    pub fn as_str(&self) -> &str {
65        match self {
66            Self::Pending => "pending",
67            Self::Accepted => "accepted",
68            Self::Declined => "declined",
69        }
70    }
71}
72
73impl std::fmt::Display for InvitationStatus {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.write_str(self.as_str())
76    }
77}
78
79/// Sort order for session listings.
80#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
81#[serde(rename_all = "snake_case")]
82#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
83#[cfg_attr(feature = "ts", ts(export))]
84pub enum SortOrder {
85    #[default]
86    Recent,
87    Popular,
88    Longest,
89}
90
91impl SortOrder {
92    pub fn as_str(&self) -> &str {
93        match self {
94            Self::Recent => "recent",
95            Self::Popular => "popular",
96            Self::Longest => "longest",
97        }
98    }
99}
100
101impl std::fmt::Display for SortOrder {
102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103        f.write_str(self.as_str())
104    }
105}
106
107/// Time range filter for queries.
108#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
109#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
110#[cfg_attr(feature = "ts", ts(export))]
111pub enum TimeRange {
112    #[serde(rename = "24h")]
113    Hours24,
114    #[serde(rename = "7d")]
115    Days7,
116    #[serde(rename = "30d")]
117    Days30,
118    #[default]
119    #[serde(rename = "all")]
120    All,
121}
122
123impl TimeRange {
124    pub fn as_str(&self) -> &str {
125        match self {
126            Self::Hours24 => "24h",
127            Self::Days7 => "7d",
128            Self::Days30 => "30d",
129            Self::All => "all",
130        }
131    }
132}
133
134impl std::fmt::Display for TimeRange {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        f.write_str(self.as_str())
137    }
138}
139
140/// Type of link between two sessions.
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142#[serde(rename_all = "snake_case")]
143#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
144#[cfg_attr(feature = "ts", ts(export))]
145pub enum LinkType {
146    Handoff,
147    Related,
148    Parent,
149    Child,
150}
151
152impl LinkType {
153    pub fn as_str(&self) -> &str {
154        match self {
155            Self::Handoff => "handoff",
156            Self::Related => "related",
157            Self::Parent => "parent",
158            Self::Child => "child",
159        }
160    }
161}
162
163impl std::fmt::Display for LinkType {
164    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
165        f.write_str(self.as_str())
166    }
167}
168
169// ─── Utilities ───────────────────────────────────────────────────────────────
170
171/// Safely convert `u64` to `i64`, saturating at `i64::MAX` instead of wrapping.
172pub fn saturating_i64(v: u64) -> i64 {
173    i64::try_from(v).unwrap_or(i64::MAX)
174}
175
176// ─── Auth ────────────────────────────────────────────────────────────────────
177
178/// Legacy register (nickname-only). Kept for backward compatibility with CLI.
179#[derive(Debug, Deserialize)]
180#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
181#[cfg_attr(feature = "ts", ts(export))]
182pub struct RegisterRequest {
183    pub nickname: String,
184}
185
186/// Email + password registration.
187#[derive(Debug, Serialize, Deserialize)]
188#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
189#[cfg_attr(feature = "ts", ts(export))]
190pub struct AuthRegisterRequest {
191    pub email: String,
192    pub password: String,
193    pub nickname: String,
194}
195
196/// Email + password login.
197#[derive(Debug, Serialize, Deserialize)]
198#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
199#[cfg_attr(feature = "ts", ts(export))]
200pub struct LoginRequest {
201    pub email: String,
202    pub password: String,
203}
204
205/// Returned on successful login / register / refresh.
206#[derive(Debug, Serialize, Deserialize)]
207#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
208#[cfg_attr(feature = "ts", ts(export))]
209pub struct AuthTokenResponse {
210    pub access_token: String,
211    pub refresh_token: String,
212    pub expires_in: u64,
213    pub user_id: String,
214    pub nickname: String,
215}
216
217/// Refresh token request.
218#[derive(Debug, Serialize, Deserialize)]
219#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
220#[cfg_attr(feature = "ts", ts(export))]
221pub struct RefreshRequest {
222    pub refresh_token: String,
223}
224
225/// Logout request (invalidate refresh token).
226#[derive(Debug, Serialize, Deserialize)]
227#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
228#[cfg_attr(feature = "ts", ts(export))]
229pub struct LogoutRequest {
230    pub refresh_token: String,
231}
232
233/// Change password request.
234#[derive(Debug, Serialize, Deserialize)]
235#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
236#[cfg_attr(feature = "ts", ts(export))]
237pub struct ChangePasswordRequest {
238    pub current_password: String,
239    pub new_password: String,
240}
241
242/// Returned on successful legacy register (nickname-only, CLI-compatible).
243#[derive(Debug, Serialize)]
244#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
245#[cfg_attr(feature = "ts", ts(export))]
246pub struct RegisterResponse {
247    pub user_id: String,
248    pub nickname: String,
249    pub api_key: String,
250}
251
252/// Returned by `POST /api/auth/verify` — confirms token validity.
253#[derive(Debug, Serialize, Deserialize)]
254#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
255#[cfg_attr(feature = "ts", ts(export))]
256pub struct VerifyResponse {
257    pub user_id: String,
258    pub nickname: String,
259}
260
261/// Full user profile returned by `GET /api/auth/me`.
262#[derive(Debug, Serialize, Deserialize)]
263#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
264#[cfg_attr(feature = "ts", ts(export))]
265pub struct UserSettingsResponse {
266    pub user_id: String,
267    pub nickname: String,
268    pub api_key: String,
269    pub created_at: String,
270    pub email: Option<String>,
271    pub avatar_url: Option<String>,
272    /// Linked OAuth providers (generic — replaces github_username)
273    #[serde(default)]
274    pub oauth_providers: Vec<oauth::LinkedProvider>,
275    /// Legacy: GitHub username (populated from oauth_providers for backward compat)
276    #[serde(skip_serializing_if = "Option::is_none")]
277    pub github_username: Option<String>,
278}
279
280/// Generic success response for operations that don't return data.
281#[derive(Debug, Serialize, Deserialize)]
282#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
283#[cfg_attr(feature = "ts", ts(export))]
284pub struct OkResponse {
285    pub ok: bool,
286}
287
288/// Response for API key regeneration.
289#[derive(Debug, Serialize, Deserialize)]
290#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
291#[cfg_attr(feature = "ts", ts(export))]
292pub struct RegenerateKeyResponse {
293    pub api_key: String,
294}
295
296/// Response for OAuth link initiation (redirect URL).
297#[derive(Debug, Serialize)]
298#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
299#[cfg_attr(feature = "ts", ts(export))]
300pub struct OAuthLinkResponse {
301    pub url: String,
302}
303
304// ─── Sessions ────────────────────────────────────────────────────────────────
305
306/// Request body for `POST /api/sessions` — upload a recorded session.
307#[derive(Debug, Serialize, Deserialize)]
308pub struct UploadRequest {
309    pub session: Session,
310    pub team_id: Option<String>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub body_url: Option<String>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub linked_session_ids: Option<Vec<String>>,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub git_remote: Option<String>,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub git_branch: Option<String>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub git_commit: Option<String>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub git_repo_name: Option<String>,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub pr_number: Option<i64>,
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub pr_url: Option<String>,
327}
328
329/// Returned on successful session upload — contains the new session ID and URL.
330#[derive(Debug, Serialize, Deserialize)]
331#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
332#[cfg_attr(feature = "ts", ts(export))]
333pub struct UploadResponse {
334    pub id: String,
335    pub url: String,
336}
337
338/// Flat session summary returned by list/detail endpoints.
339/// This is NOT the full HAIL Session — it's a DB-derived summary.
340#[derive(Debug, Clone, Serialize, Deserialize)]
341#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
342#[cfg_attr(feature = "ts", ts(export))]
343pub struct SessionSummary {
344    pub id: String,
345    pub user_id: Option<String>,
346    pub nickname: Option<String>,
347    pub team_id: String,
348    pub tool: String,
349    pub agent_provider: Option<String>,
350    pub agent_model: Option<String>,
351    pub title: Option<String>,
352    pub description: Option<String>,
353    /// Comma-separated tags string
354    pub tags: Option<String>,
355    pub created_at: String,
356    pub uploaded_at: String,
357    pub message_count: i64,
358    pub task_count: i64,
359    pub event_count: i64,
360    pub duration_seconds: i64,
361    pub total_input_tokens: i64,
362    pub total_output_tokens: i64,
363    #[serde(default, skip_serializing_if = "Option::is_none")]
364    pub git_remote: Option<String>,
365    #[serde(default, skip_serializing_if = "Option::is_none")]
366    pub git_branch: Option<String>,
367    #[serde(default, skip_serializing_if = "Option::is_none")]
368    pub git_commit: Option<String>,
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub git_repo_name: Option<String>,
371    #[serde(default, skip_serializing_if = "Option::is_none")]
372    pub pr_number: Option<i64>,
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub pr_url: Option<String>,
375    #[serde(default, skip_serializing_if = "Option::is_none")]
376    pub working_directory: Option<String>,
377    #[serde(default, skip_serializing_if = "Option::is_none")]
378    pub files_modified: Option<String>,
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub files_read: Option<String>,
381    #[serde(default)]
382    pub has_errors: bool,
383    #[serde(default = "default_max_active_agents")]
384    pub max_active_agents: i64,
385}
386
387/// Paginated session listing returned by `GET /api/sessions`.
388#[derive(Debug, Serialize, Deserialize)]
389#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
390#[cfg_attr(feature = "ts", ts(export))]
391pub struct SessionListResponse {
392    pub sessions: Vec<SessionSummary>,
393    pub total: i64,
394    pub page: u32,
395    pub per_page: u32,
396}
397
398/// Query parameters for `GET /api/sessions` — pagination, filtering, sorting.
399#[derive(Debug, Deserialize)]
400#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
401#[cfg_attr(feature = "ts", ts(export))]
402pub struct SessionListQuery {
403    #[serde(default = "default_page")]
404    pub page: u32,
405    #[serde(default = "default_per_page")]
406    pub per_page: u32,
407    pub search: Option<String>,
408    pub tool: Option<String>,
409    pub team_id: Option<String>,
410    /// Sort order (default: recent)
411    pub sort: Option<SortOrder>,
412    /// Time range filter (default: all)
413    pub time_range: Option<TimeRange>,
414}
415
416impl SessionListQuery {
417    /// Returns true when this query targets the anonymous public feed and is safe to edge-cache.
418    pub fn is_public_feed_cacheable(
419        &self,
420        has_auth_header: bool,
421        has_session_cookie: bool,
422    ) -> bool {
423        !has_auth_header
424            && !has_session_cookie
425            && self.team_id.is_none()
426            && self.search.as_deref().is_none_or(|s| s.trim().is_empty())
427            && self.page <= 10
428            && self.per_page <= 50
429    }
430}
431
432#[cfg(test)]
433mod session_list_query_tests {
434    use super::*;
435
436    fn base_query() -> SessionListQuery {
437        SessionListQuery {
438            page: 1,
439            per_page: 20,
440            search: None,
441            tool: None,
442            team_id: None,
443            sort: None,
444            time_range: None,
445        }
446    }
447
448    #[test]
449    fn public_feed_cacheable_when_anonymous_default_feed() {
450        let q = base_query();
451        assert!(q.is_public_feed_cacheable(false, false));
452    }
453
454    #[test]
455    fn public_feed_not_cacheable_with_auth_or_cookie() {
456        let q = base_query();
457        assert!(!q.is_public_feed_cacheable(true, false));
458        assert!(!q.is_public_feed_cacheable(false, true));
459    }
460
461    #[test]
462    fn public_feed_not_cacheable_for_team_or_search_or_large_page() {
463        let mut q = base_query();
464        q.team_id = Some("team-1".into());
465        assert!(!q.is_public_feed_cacheable(false, false));
466
467        let mut q = base_query();
468        q.search = Some("hello".into());
469        assert!(!q.is_public_feed_cacheable(false, false));
470
471        let mut q = base_query();
472        q.page = 11;
473        assert!(!q.is_public_feed_cacheable(false, false));
474
475        let mut q = base_query();
476        q.per_page = 100;
477        assert!(!q.is_public_feed_cacheable(false, false));
478    }
479}
480
481fn default_page() -> u32 {
482    1
483}
484fn default_per_page() -> u32 {
485    20
486}
487fn default_max_active_agents() -> i64 {
488    1
489}
490
491/// Single session detail returned by `GET /api/sessions/:id`.
492#[derive(Debug, Serialize, Deserialize)]
493#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
494#[cfg_attr(feature = "ts", ts(export))]
495pub struct SessionDetail {
496    #[serde(flatten)]
497    #[cfg_attr(feature = "ts", ts(flatten))]
498    pub summary: SessionSummary,
499    pub team_name: Option<String>,
500    #[serde(default, skip_serializing_if = "Vec::is_empty")]
501    pub linked_sessions: Vec<SessionLink>,
502}
503
504/// A link between two sessions (e.g., handoff chain).
505#[derive(Debug, Clone, Serialize, Deserialize)]
506#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
507#[cfg_attr(feature = "ts", ts(export))]
508pub struct SessionLink {
509    pub session_id: String,
510    pub linked_session_id: String,
511    pub link_type: LinkType,
512    pub created_at: String,
513}
514
515// ─── Teams ──────────────────────────────────────────────────────────────────
516
517/// Request body for `POST /api/teams` — create a new team.
518#[derive(Debug, Serialize, Deserialize)]
519#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
520#[cfg_attr(feature = "ts", ts(export))]
521pub struct CreateTeamRequest {
522    pub name: String,
523    pub description: Option<String>,
524    pub is_public: Option<bool>,
525}
526
527/// Single team record returned by list and detail endpoints.
528#[derive(Debug, Serialize, Deserialize)]
529#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
530#[cfg_attr(feature = "ts", ts(export))]
531pub struct TeamResponse {
532    pub id: String,
533    pub name: String,
534    pub description: Option<String>,
535    pub is_public: bool,
536    pub created_by: String,
537    pub created_at: String,
538}
539
540/// Returned by `GET /api/teams` — teams the authenticated user belongs to.
541#[derive(Debug, Serialize, Deserialize)]
542#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
543#[cfg_attr(feature = "ts", ts(export))]
544pub struct ListTeamsResponse {
545    pub teams: Vec<TeamResponse>,
546}
547
548/// Returned by `GET /api/teams/:id` — team info with member count and recent sessions.
549#[derive(Debug, Serialize, Deserialize)]
550#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
551#[cfg_attr(feature = "ts", ts(export))]
552pub struct TeamDetailResponse {
553    #[serde(flatten)]
554    #[cfg_attr(feature = "ts", ts(flatten))]
555    pub team: TeamResponse,
556    pub member_count: i64,
557    pub sessions: Vec<SessionSummary>,
558}
559
560/// Request body for `PUT /api/teams/:id` — partial team update.
561#[derive(Debug, Serialize, Deserialize)]
562#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
563#[cfg_attr(feature = "ts", ts(export))]
564pub struct UpdateTeamRequest {
565    pub name: Option<String>,
566    pub description: Option<String>,
567    pub is_public: Option<bool>,
568}
569
570/// Query parameters for `GET /api/teams/:id/stats`.
571#[derive(Debug, Deserialize)]
572#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
573#[cfg_attr(feature = "ts", ts(export))]
574pub struct TeamStatsQuery {
575    /// Time range filter (default: all)
576    pub time_range: Option<TimeRange>,
577}
578
579/// Returned by `GET /api/teams/:id/stats` — aggregated team statistics.
580#[derive(Debug, Serialize)]
581#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
582#[cfg_attr(feature = "ts", ts(export))]
583pub struct TeamStatsResponse {
584    pub team_id: String,
585    pub time_range: TimeRange,
586    pub totals: TeamStatsTotals,
587    pub by_user: Vec<UserStats>,
588    pub by_tool: Vec<ToolStats>,
589}
590
591/// Aggregate totals across all sessions in a team.
592#[derive(Debug, Serialize)]
593#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
594#[cfg_attr(feature = "ts", ts(export))]
595pub struct TeamStatsTotals {
596    pub session_count: i64,
597    pub message_count: i64,
598    pub event_count: i64,
599    pub tool_call_count: i64,
600    pub duration_seconds: i64,
601    pub total_input_tokens: i64,
602    pub total_output_tokens: i64,
603}
604
605/// Per-user aggregated statistics within a team.
606#[derive(Debug, Serialize)]
607#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
608#[cfg_attr(feature = "ts", ts(export))]
609pub struct UserStats {
610    pub user_id: String,
611    pub nickname: String,
612    pub session_count: i64,
613    pub message_count: i64,
614    pub event_count: i64,
615    pub duration_seconds: i64,
616    pub total_input_tokens: i64,
617    pub total_output_tokens: i64,
618}
619
620/// Per-tool aggregated statistics within a team.
621#[derive(Debug, Serialize)]
622#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
623#[cfg_attr(feature = "ts", ts(export))]
624pub struct ToolStats {
625    pub tool: String,
626    pub session_count: i64,
627    pub message_count: i64,
628    pub event_count: i64,
629    pub duration_seconds: i64,
630    pub total_input_tokens: i64,
631    pub total_output_tokens: i64,
632}
633
634/// Request body for `POST /api/teams/:id/keys`.
635#[derive(Debug, Serialize, Deserialize)]
636#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
637#[cfg_attr(feature = "ts", ts(export))]
638pub struct CreateTeamInviteKeyRequest {
639    pub role: Option<TeamRole>,
640    /// Defaults to 7 days. Clamped to [1, 30].
641    pub expires_in_days: Option<u32>,
642}
643
644/// Create response for team invite key generation.
645/// `invite_key` is only returned once.
646#[derive(Debug, Serialize, Deserialize)]
647#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
648#[cfg_attr(feature = "ts", ts(export))]
649pub struct CreateTeamInviteKeyResponse {
650    pub key_id: String,
651    pub invite_key: String,
652    pub role: TeamRole,
653    pub expires_at: String,
654}
655
656/// Team invite key metadata, safe to list repeatedly.
657#[derive(Debug, Serialize, Deserialize)]
658#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
659#[cfg_attr(feature = "ts", ts(export))]
660pub struct TeamInviteKeySummary {
661    pub id: String,
662    pub role: TeamRole,
663    pub created_by_nickname: String,
664    pub created_at: String,
665    pub expires_at: String,
666    pub used_at: Option<String>,
667    pub revoked_at: Option<String>,
668}
669
670/// Returned by `GET /api/teams/:id/keys`.
671#[derive(Debug, Serialize, Deserialize)]
672#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
673#[cfg_attr(feature = "ts", ts(export))]
674pub struct ListTeamInviteKeysResponse {
675    pub keys: Vec<TeamInviteKeySummary>,
676}
677
678/// Request body for `POST /api/teams/join-with-key`.
679#[derive(Debug, Serialize, Deserialize)]
680#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
681#[cfg_attr(feature = "ts", ts(export))]
682pub struct JoinTeamWithKeyRequest {
683    pub invite_key: String,
684}
685
686/// Returned after successful key redemption.
687#[derive(Debug, Serialize, Deserialize)]
688#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
689#[cfg_attr(feature = "ts", ts(export))]
690pub struct JoinTeamWithKeyResponse {
691    pub team_id: String,
692    pub team_name: String,
693    pub role: TeamRole,
694}
695
696// ─── From impls: core stats → API types ─────────────────────────────────────
697
698impl From<opensession_core::stats::SessionAggregate> for TeamStatsTotals {
699    fn from(a: opensession_core::stats::SessionAggregate) -> Self {
700        Self {
701            session_count: saturating_i64(a.session_count),
702            message_count: saturating_i64(a.message_count),
703            event_count: saturating_i64(a.event_count),
704            tool_call_count: saturating_i64(a.tool_call_count),
705            duration_seconds: saturating_i64(a.duration_seconds),
706            total_input_tokens: saturating_i64(a.total_input_tokens),
707            total_output_tokens: saturating_i64(a.total_output_tokens),
708        }
709    }
710}
711
712impl From<(String, opensession_core::stats::SessionAggregate)> for ToolStats {
713    fn from((tool, a): (String, opensession_core::stats::SessionAggregate)) -> Self {
714        Self {
715            tool,
716            session_count: saturating_i64(a.session_count),
717            message_count: saturating_i64(a.message_count),
718            event_count: saturating_i64(a.event_count),
719            duration_seconds: saturating_i64(a.duration_seconds),
720            total_input_tokens: saturating_i64(a.total_input_tokens),
721            total_output_tokens: saturating_i64(a.total_output_tokens),
722        }
723    }
724}
725
726// ─── Invitations ─────────────────────────────────────────────────────────────
727
728/// Request body for `POST /api/teams/:id/invite` — invite a user by email or OAuth identity.
729#[derive(Debug, Serialize, Deserialize)]
730#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
731#[cfg_attr(feature = "ts", ts(export))]
732pub struct InviteRequest {
733    pub email: Option<String>,
734    /// OAuth provider name (e.g., "github", "gitlab").
735    pub oauth_provider: Option<String>,
736    /// Username on the OAuth provider (e.g., "octocat").
737    pub oauth_provider_username: Option<String>,
738    pub role: Option<TeamRole>,
739}
740
741/// Single invitation record returned by list and detail endpoints.
742#[derive(Debug, Serialize, Deserialize)]
743#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
744#[cfg_attr(feature = "ts", ts(export))]
745pub struct InvitationResponse {
746    pub id: String,
747    pub team_id: String,
748    pub team_name: String,
749    pub email: Option<String>,
750    pub oauth_provider: Option<String>,
751    pub oauth_provider_username: Option<String>,
752    pub invited_by_nickname: String,
753    pub role: TeamRole,
754    pub status: InvitationStatus,
755    pub created_at: String,
756}
757
758/// Returned by `GET /api/invitations` — pending invitations for the current user.
759#[derive(Debug, Serialize, Deserialize)]
760#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
761#[cfg_attr(feature = "ts", ts(export))]
762pub struct ListInvitationsResponse {
763    pub invitations: Vec<InvitationResponse>,
764}
765
766/// Returned by `POST /api/invitations/:id/accept` — confirms team join.
767#[derive(Debug, Serialize, Deserialize)]
768#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
769#[cfg_attr(feature = "ts", ts(export))]
770pub struct AcceptInvitationResponse {
771    pub team_id: String,
772    pub role: TeamRole,
773}
774
775// ─── Members (admin-managed) ────────────────────────────────────────────────
776
777/// Request body for `POST /api/teams/:id/members` — add a member by nickname.
778#[derive(Debug, Serialize, Deserialize)]
779#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
780#[cfg_attr(feature = "ts", ts(export))]
781pub struct AddMemberRequest {
782    pub nickname: String,
783}
784
785/// Single team member record.
786#[derive(Debug, Serialize, Deserialize)]
787#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
788#[cfg_attr(feature = "ts", ts(export))]
789pub struct MemberResponse {
790    pub user_id: String,
791    pub nickname: String,
792    pub role: TeamRole,
793    pub joined_at: String,
794}
795
796/// Returned by `GET /api/teams/:id/members`.
797#[derive(Debug, Serialize, Deserialize)]
798#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
799#[cfg_attr(feature = "ts", ts(export))]
800pub struct ListMembersResponse {
801    pub members: Vec<MemberResponse>,
802}
803
804// ─── Config Sync ─────────────────────────────────────────────────────────────
805
806/// Team-level configuration synced to CLI clients.
807#[derive(Debug, Serialize, Deserialize)]
808#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
809#[cfg_attr(feature = "ts", ts(export))]
810pub struct ConfigSyncResponse {
811    pub privacy: Option<SyncedPrivacyConfig>,
812    pub watchers: Option<SyncedWatcherConfig>,
813}
814
815/// Privacy settings synced from the team — controls what data is recorded.
816#[derive(Debug, Serialize, Deserialize)]
817#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
818#[cfg_attr(feature = "ts", ts(export))]
819pub struct SyncedPrivacyConfig {
820    pub exclude_patterns: Option<Vec<String>>,
821    pub exclude_tools: Option<Vec<String>>,
822}
823
824/// Watcher toggle settings synced from the team — which tools to monitor.
825#[derive(Debug, Serialize, Deserialize)]
826#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
827#[cfg_attr(feature = "ts", ts(export))]
828pub struct SyncedWatcherConfig {
829    pub claude_code: Option<bool>,
830    pub opencode: Option<bool>,
831    pub cursor: Option<bool>,
832}
833
834// ─── Sync ────────────────────────────────────────────────────────────────────
835
836/// Query parameters for `GET /api/sync/pull` — cursor-based session sync.
837#[derive(Debug, Deserialize)]
838pub struct SyncPullQuery {
839    pub team_id: String,
840    /// Cursor: uploaded_at of the last received session
841    pub since: Option<String>,
842    /// Max sessions per page (default 100)
843    pub limit: Option<u32>,
844}
845
846/// Returned by `GET /api/sync/pull` — paginated session data with cursor.
847#[derive(Debug, Serialize, Deserialize)]
848pub struct SyncPullResponse {
849    pub sessions: Vec<SessionSummary>,
850    /// Cursor for the next page (None = no more data)
851    pub next_cursor: Option<String>,
852    pub has_more: bool,
853}
854
855// ─── Streaming Events ────────────────────────────────────────────────────────
856
857/// Request body for `POST /api/sessions/:id/events` — append live events.
858#[derive(Debug, Deserialize)]
859#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
860#[cfg_attr(feature = "ts", ts(export))]
861pub struct StreamEventsRequest {
862    #[cfg_attr(feature = "ts", ts(type = "any"))]
863    pub agent: Option<Agent>,
864    #[cfg_attr(feature = "ts", ts(type = "any"))]
865    pub context: Option<SessionContext>,
866    #[cfg_attr(feature = "ts", ts(type = "any[]"))]
867    pub events: Vec<Event>,
868}
869
870/// Returned by `POST /api/sessions/:id/events` — number of events accepted.
871#[derive(Debug, Serialize, Deserialize)]
872#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
873#[cfg_attr(feature = "ts", ts(export))]
874pub struct StreamEventsResponse {
875    pub accepted: usize,
876}
877
878// ─── Health ──────────────────────────────────────────────────────────────────
879
880/// Returned by `GET /api/health` — server liveness check.
881#[derive(Debug, Serialize, Deserialize)]
882#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
883#[cfg_attr(feature = "ts", ts(export))]
884pub struct HealthResponse {
885    pub status: String,
886    pub version: String,
887}
888
889// ─── Service Error ───────────────────────────────────────────────────────────
890
891/// Framework-agnostic service error.
892///
893/// Each variant maps to an HTTP status code. Both the Axum server and
894/// Cloudflare Worker convert this into the appropriate response type.
895#[derive(Debug, Clone)]
896#[non_exhaustive]
897pub enum ServiceError {
898    BadRequest(String),
899    Unauthorized(String),
900    Forbidden(String),
901    NotFound(String),
902    Conflict(String),
903    Internal(String),
904}
905
906impl ServiceError {
907    /// HTTP status code as a `u16`.
908    pub fn status_code(&self) -> u16 {
909        match self {
910            Self::BadRequest(_) => 400,
911            Self::Unauthorized(_) => 401,
912            Self::Forbidden(_) => 403,
913            Self::NotFound(_) => 404,
914            Self::Conflict(_) => 409,
915            Self::Internal(_) => 500,
916        }
917    }
918
919    /// The error message.
920    pub fn message(&self) -> &str {
921        match self {
922            Self::BadRequest(m)
923            | Self::Unauthorized(m)
924            | Self::Forbidden(m)
925            | Self::NotFound(m)
926            | Self::Conflict(m)
927            | Self::Internal(m) => m,
928        }
929    }
930
931    /// Build a closure that logs a DB/IO error and returns `Internal`.
932    pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
933        move |e| Self::Internal(format!("{context}: {e}"))
934    }
935}
936
937impl std::fmt::Display for ServiceError {
938    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
939        write!(f, "{}", self.message())
940    }
941}
942
943impl std::error::Error for ServiceError {}
944
945// ─── Error (legacy JSON shape) ──────────────────────────────────────────────
946
947/// Legacy JSON error shape `{ "error": "..." }` returned by all error responses.
948#[derive(Debug, Serialize)]
949#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
950#[cfg_attr(feature = "ts", ts(export))]
951pub struct ApiError {
952    pub error: String,
953}
954
955impl From<&ServiceError> for ApiError {
956    fn from(e: &ServiceError) -> Self {
957        Self {
958            error: e.message().to_string(),
959        }
960    }
961}
962
963// ─── TypeScript generation ───────────────────────────────────────────────────
964
965#[cfg(all(test, feature = "ts"))]
966mod tests {
967    use super::*;
968    use std::io::Write;
969    use std::path::PathBuf;
970    use ts_rs::TS;
971
972    /// Run with: cargo test -p opensession-api -- export_typescript --nocapture
973    #[test]
974    fn export_typescript() {
975        let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
976            .join("../../packages/ui/src/api-types.generated.ts");
977
978        let cfg = ts_rs::Config::new().with_large_int("number");
979        let mut parts: Vec<String> = Vec::new();
980        parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
981        parts.push(
982            "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
983        );
984        parts.push(String::new());
985
986        // Collect all type declarations.
987        // Structs: `type X = {...}` → `export interface X {...}`
988        // Enums/unions: `type X = "a" | "b"` → `export type X = "a" | "b"`
989        macro_rules! collect_ts {
990            ($($t:ty),+ $(,)?) => {
991                $(
992                    let decl = <$t>::decl(&cfg);
993                    let decl = if decl.contains(" = {") {
994                        // Struct → export interface
995                        decl
996                            .replacen("type ", "export interface ", 1)
997                            .replace(" = {", " {")
998                            .trim_end_matches(';')
999                            .to_string()
1000                    } else {
1001                        // Enum/union → export type
1002                        decl
1003                            .replacen("type ", "export type ", 1)
1004                            .trim_end_matches(';')
1005                            .to_string()
1006                    };
1007                    parts.push(decl);
1008                    parts.push(String::new());
1009                )+
1010            };
1011        }
1012
1013        collect_ts!(
1014            // Shared enums
1015            TeamRole,
1016            InvitationStatus,
1017            SortOrder,
1018            TimeRange,
1019            LinkType,
1020            // Auth
1021            RegisterRequest,
1022            AuthRegisterRequest,
1023            LoginRequest,
1024            AuthTokenResponse,
1025            RefreshRequest,
1026            LogoutRequest,
1027            ChangePasswordRequest,
1028            RegisterResponse,
1029            VerifyResponse,
1030            UserSettingsResponse,
1031            OkResponse,
1032            RegenerateKeyResponse,
1033            OAuthLinkResponse,
1034            // Sessions
1035            UploadResponse,
1036            SessionSummary,
1037            SessionListResponse,
1038            SessionListQuery,
1039            SessionDetail,
1040            SessionLink,
1041            // Teams
1042            CreateTeamRequest,
1043            TeamResponse,
1044            ListTeamsResponse,
1045            TeamDetailResponse,
1046            UpdateTeamRequest,
1047            TeamStatsQuery,
1048            TeamStatsResponse,
1049            TeamStatsTotals,
1050            UserStats,
1051            ToolStats,
1052            CreateTeamInviteKeyRequest,
1053            CreateTeamInviteKeyResponse,
1054            TeamInviteKeySummary,
1055            ListTeamInviteKeysResponse,
1056            JoinTeamWithKeyRequest,
1057            JoinTeamWithKeyResponse,
1058            // Members
1059            AddMemberRequest,
1060            MemberResponse,
1061            ListMembersResponse,
1062            // Invitations
1063            InviteRequest,
1064            InvitationResponse,
1065            ListInvitationsResponse,
1066            AcceptInvitationResponse,
1067            // OAuth
1068            oauth::AuthProvidersResponse,
1069            oauth::OAuthProviderInfo,
1070            oauth::LinkedProvider,
1071            // Health
1072            HealthResponse,
1073            ApiError,
1074        );
1075
1076        let content = parts.join("\n");
1077
1078        // Write to file
1079        if let Some(parent) = out_dir.parent() {
1080            std::fs::create_dir_all(parent).ok();
1081        }
1082        let mut file = std::fs::File::create(&out_dir)
1083            .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
1084        file.write_all(content.as_bytes())
1085            .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
1086
1087        println!("Generated TypeScript types at: {}", out_dir.display());
1088    }
1089}