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/// Sort order for session listings.
28#[derive(Debug, Clone, Serialize, Deserialize, Default, 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 SortOrder {
33    #[default]
34    Recent,
35    Popular,
36    Longest,
37}
38
39impl SortOrder {
40    pub fn as_str(&self) -> &str {
41        match self {
42            Self::Recent => "recent",
43            Self::Popular => "popular",
44            Self::Longest => "longest",
45        }
46    }
47}
48
49impl std::fmt::Display for SortOrder {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.write_str(self.as_str())
52    }
53}
54
55/// Time range filter for queries.
56#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
57#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
58#[cfg_attr(feature = "ts", ts(export))]
59pub enum TimeRange {
60    #[serde(rename = "24h")]
61    Hours24,
62    #[serde(rename = "7d")]
63    Days7,
64    #[serde(rename = "30d")]
65    Days30,
66    #[default]
67    #[serde(rename = "all")]
68    All,
69}
70
71impl TimeRange {
72    pub fn as_str(&self) -> &str {
73        match self {
74            Self::Hours24 => "24h",
75            Self::Days7 => "7d",
76            Self::Days30 => "30d",
77            Self::All => "all",
78        }
79    }
80}
81
82impl std::fmt::Display for TimeRange {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        f.write_str(self.as_str())
85    }
86}
87
88/// Type of link between two sessions.
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "snake_case")]
91#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
92#[cfg_attr(feature = "ts", ts(export))]
93pub enum LinkType {
94    Handoff,
95    Related,
96    Parent,
97    Child,
98}
99
100impl LinkType {
101    pub fn as_str(&self) -> &str {
102        match self {
103            Self::Handoff => "handoff",
104            Self::Related => "related",
105            Self::Parent => "parent",
106            Self::Child => "child",
107        }
108    }
109}
110
111impl std::fmt::Display for LinkType {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        f.write_str(self.as_str())
114    }
115}
116
117// ─── Utilities ───────────────────────────────────────────────────────────────
118
119/// Safely convert `u64` to `i64`, saturating at `i64::MAX` instead of wrapping.
120pub fn saturating_i64(v: u64) -> i64 {
121    i64::try_from(v).unwrap_or(i64::MAX)
122}
123
124// ─── Auth ────────────────────────────────────────────────────────────────────
125
126/// Email + password registration.
127#[derive(Debug, Serialize, Deserialize)]
128#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
129#[cfg_attr(feature = "ts", ts(export))]
130pub struct AuthRegisterRequest {
131    pub email: String,
132    pub password: String,
133    pub nickname: String,
134}
135
136/// Email + password login.
137#[derive(Debug, Serialize, Deserialize)]
138#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
139#[cfg_attr(feature = "ts", ts(export))]
140pub struct LoginRequest {
141    pub email: String,
142    pub password: String,
143}
144
145/// Returned on successful login / register / refresh.
146#[derive(Debug, Serialize, Deserialize)]
147#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
148#[cfg_attr(feature = "ts", ts(export))]
149pub struct AuthTokenResponse {
150    pub access_token: String,
151    pub refresh_token: String,
152    pub expires_in: u64,
153    pub user_id: String,
154    pub nickname: String,
155}
156
157/// Refresh token request.
158#[derive(Debug, Serialize, Deserialize)]
159#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
160#[cfg_attr(feature = "ts", ts(export))]
161pub struct RefreshRequest {
162    pub refresh_token: String,
163}
164
165/// Logout request (invalidate refresh token).
166#[derive(Debug, Serialize, Deserialize)]
167#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
168#[cfg_attr(feature = "ts", ts(export))]
169pub struct LogoutRequest {
170    pub refresh_token: String,
171}
172
173/// Change password request.
174#[derive(Debug, Serialize, Deserialize)]
175#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
176#[cfg_attr(feature = "ts", ts(export))]
177pub struct ChangePasswordRequest {
178    pub current_password: String,
179    pub new_password: String,
180}
181
182/// Returned by `POST /api/auth/verify` — confirms token validity.
183#[derive(Debug, Serialize, Deserialize)]
184#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
185#[cfg_attr(feature = "ts", ts(export))]
186pub struct VerifyResponse {
187    pub user_id: String,
188    pub nickname: String,
189}
190
191/// Full user profile returned by `GET /api/auth/me`.
192#[derive(Debug, Serialize, Deserialize)]
193#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
194#[cfg_attr(feature = "ts", ts(export))]
195pub struct UserSettingsResponse {
196    pub user_id: String,
197    pub nickname: String,
198    pub created_at: String,
199    pub email: Option<String>,
200    pub avatar_url: Option<String>,
201    /// Linked OAuth providers.
202    #[serde(default)]
203    pub oauth_providers: Vec<oauth::LinkedProvider>,
204}
205
206/// Generic success response for operations that don't return data.
207#[derive(Debug, Serialize, Deserialize)]
208#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
209#[cfg_attr(feature = "ts", ts(export))]
210pub struct OkResponse {
211    pub ok: bool,
212}
213
214/// Response for API key issuance. The key is visible only at issuance time.
215#[derive(Debug, Serialize, Deserialize)]
216#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
217#[cfg_attr(feature = "ts", ts(export))]
218pub struct IssueApiKeyResponse {
219    pub api_key: String,
220}
221
222/// Response for OAuth link initiation (redirect URL).
223#[derive(Debug, Serialize)]
224#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
225#[cfg_attr(feature = "ts", ts(export))]
226pub struct OAuthLinkResponse {
227    pub url: String,
228}
229
230// ─── Sessions ────────────────────────────────────────────────────────────────
231
232/// Request body for `POST /api/sessions` — upload a recorded session.
233#[derive(Debug, Serialize, Deserialize)]
234pub struct UploadRequest {
235    pub session: Session,
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub body_url: Option<String>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub linked_session_ids: Option<Vec<String>>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub git_remote: Option<String>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub git_branch: Option<String>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub git_commit: Option<String>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub git_repo_name: Option<String>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub pr_number: Option<i64>,
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub pr_url: Option<String>,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub score_plugin: Option<String>,
254}
255
256/// Returned on successful session upload — contains the new session ID and URL.
257#[derive(Debug, Serialize, Deserialize)]
258#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
259#[cfg_attr(feature = "ts", ts(export))]
260pub struct UploadResponse {
261    pub id: String,
262    pub url: String,
263    #[serde(default)]
264    pub session_score: i64,
265    #[serde(default = "default_score_plugin")]
266    pub score_plugin: String,
267}
268
269/// Flat session summary returned by list/detail endpoints.
270/// This is NOT the full HAIL Session — it's a DB-derived summary.
271#[derive(Debug, Clone, Serialize, Deserialize)]
272#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
273#[cfg_attr(feature = "ts", ts(export))]
274pub struct SessionSummary {
275    pub id: String,
276    pub user_id: Option<String>,
277    pub nickname: Option<String>,
278    pub tool: String,
279    pub agent_provider: Option<String>,
280    pub agent_model: Option<String>,
281    pub title: Option<String>,
282    pub description: Option<String>,
283    /// Comma-separated tags string
284    pub tags: Option<String>,
285    pub created_at: String,
286    pub uploaded_at: String,
287    pub message_count: i64,
288    pub task_count: i64,
289    pub event_count: i64,
290    pub duration_seconds: i64,
291    pub total_input_tokens: i64,
292    pub total_output_tokens: i64,
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub git_remote: Option<String>,
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub git_branch: Option<String>,
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub git_commit: Option<String>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub git_repo_name: Option<String>,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub pr_number: Option<i64>,
303    #[serde(default, skip_serializing_if = "Option::is_none")]
304    pub pr_url: Option<String>,
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub working_directory: Option<String>,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub files_modified: Option<String>,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub files_read: Option<String>,
311    #[serde(default)]
312    pub has_errors: bool,
313    #[serde(default = "default_max_active_agents")]
314    pub max_active_agents: i64,
315    #[serde(default)]
316    pub session_score: i64,
317    #[serde(default = "default_score_plugin")]
318    pub score_plugin: String,
319}
320
321/// Paginated session listing returned by `GET /api/sessions`.
322#[derive(Debug, Serialize, Deserialize)]
323#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
324#[cfg_attr(feature = "ts", ts(export))]
325pub struct SessionListResponse {
326    pub sessions: Vec<SessionSummary>,
327    pub total: i64,
328    pub page: u32,
329    pub per_page: u32,
330}
331
332/// Query parameters for `GET /api/sessions` — pagination, filtering, sorting.
333#[derive(Debug, Deserialize)]
334#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
335#[cfg_attr(feature = "ts", ts(export))]
336pub struct SessionListQuery {
337    #[serde(default = "default_page")]
338    pub page: u32,
339    #[serde(default = "default_per_page")]
340    pub per_page: u32,
341    pub search: Option<String>,
342    pub tool: Option<String>,
343    /// Sort order (default: recent)
344    pub sort: Option<SortOrder>,
345    /// Time range filter (default: all)
346    pub time_range: Option<TimeRange>,
347}
348
349impl SessionListQuery {
350    /// Returns true when this query targets the anonymous public feed and is safe to edge-cache.
351    pub fn is_public_feed_cacheable(
352        &self,
353        has_auth_header: bool,
354        has_session_cookie: bool,
355    ) -> bool {
356        !has_auth_header
357            && !has_session_cookie
358            && self.search.as_deref().is_none_or(|s| s.trim().is_empty())
359            && self.page <= 10
360            && self.per_page <= 50
361    }
362}
363
364#[cfg(test)]
365mod session_list_query_tests {
366    use super::*;
367
368    fn base_query() -> SessionListQuery {
369        SessionListQuery {
370            page: 1,
371            per_page: 20,
372            search: None,
373            tool: None,
374            sort: None,
375            time_range: None,
376        }
377    }
378
379    #[test]
380    fn public_feed_cacheable_when_anonymous_default_feed() {
381        let q = base_query();
382        assert!(q.is_public_feed_cacheable(false, false));
383    }
384
385    #[test]
386    fn public_feed_not_cacheable_with_auth_or_cookie() {
387        let q = base_query();
388        assert!(!q.is_public_feed_cacheable(true, false));
389        assert!(!q.is_public_feed_cacheable(false, true));
390    }
391
392    #[test]
393    fn public_feed_not_cacheable_for_search_or_large_page() {
394        let mut q = base_query();
395        q.search = Some("hello".into());
396        assert!(!q.is_public_feed_cacheable(false, false));
397
398        let mut q = base_query();
399        q.page = 11;
400        assert!(!q.is_public_feed_cacheable(false, false));
401
402        let mut q = base_query();
403        q.per_page = 100;
404        assert!(!q.is_public_feed_cacheable(false, false));
405    }
406}
407
408fn default_page() -> u32 {
409    1
410}
411fn default_per_page() -> u32 {
412    20
413}
414fn default_max_active_agents() -> i64 {
415    1
416}
417
418fn default_score_plugin() -> String {
419    opensession_core::scoring::DEFAULT_SCORE_PLUGIN.to_string()
420}
421
422/// Single session detail returned by `GET /api/sessions/:id`.
423#[derive(Debug, Serialize, Deserialize)]
424#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
425#[cfg_attr(feature = "ts", ts(export))]
426pub struct SessionDetail {
427    #[serde(flatten)]
428    #[cfg_attr(feature = "ts", ts(flatten))]
429    pub summary: SessionSummary,
430    #[serde(default, skip_serializing_if = "Vec::is_empty")]
431    pub linked_sessions: Vec<SessionLink>,
432}
433
434/// A link between two sessions (e.g., handoff chain).
435#[derive(Debug, Clone, Serialize, Deserialize)]
436#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
437#[cfg_attr(feature = "ts", ts(export))]
438pub struct SessionLink {
439    pub session_id: String,
440    pub linked_session_id: String,
441    pub link_type: LinkType,
442    pub created_at: String,
443}
444
445/// Source descriptor for parser preview requests.
446#[derive(Debug, Clone, Serialize, Deserialize)]
447#[serde(tag = "kind", rename_all = "snake_case")]
448#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
449#[cfg_attr(feature = "ts", ts(export))]
450pub enum ParseSource {
451    /// Fetch and parse a raw file from a public GitHub repository.
452    Github {
453        owner: String,
454        repo: String,
455        r#ref: String,
456        path: String,
457    },
458    /// Parse inline file content supplied by clients (for local upload preview).
459    Inline {
460        filename: String,
461        /// Base64-encoded UTF-8 text content.
462        content_base64: String,
463    },
464}
465
466/// Candidate parser ranked by detection confidence.
467#[derive(Debug, Clone, Serialize, Deserialize)]
468#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
469#[cfg_attr(feature = "ts", ts(export))]
470pub struct ParseCandidate {
471    pub id: String,
472    pub confidence: u8,
473    pub reason: String,
474}
475
476/// Request body for `POST /api/ingest/preview`.
477#[derive(Debug, Clone, Serialize, Deserialize)]
478#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
479#[cfg_attr(feature = "ts", ts(export))]
480pub struct ParsePreviewRequest {
481    pub source: ParseSource,
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub parser_hint: Option<String>,
484}
485
486/// Response body for `POST /api/ingest/preview`.
487#[derive(Debug, Clone, Serialize, Deserialize)]
488#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
489#[cfg_attr(feature = "ts", ts(export))]
490pub struct ParsePreviewResponse {
491    pub parser_used: String,
492    #[serde(default)]
493    pub parser_candidates: Vec<ParseCandidate>,
494    #[cfg_attr(feature = "ts", ts(type = "any"))]
495    pub session: Session,
496    pub source: ParseSource,
497    #[serde(default)]
498    pub warnings: Vec<String>,
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub native_adapter: Option<String>,
501}
502
503/// Structured parser preview error response.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
506#[cfg_attr(feature = "ts", ts(export))]
507pub struct ParsePreviewErrorResponse {
508    pub code: String,
509    pub message: String,
510    #[serde(default, skip_serializing_if = "Vec::is_empty")]
511    pub parser_candidates: Vec<ParseCandidate>,
512}
513
514// ─── Streaming Events ────────────────────────────────────────────────────────
515
516/// Request body for `POST /api/sessions/:id/events` — append live events.
517#[derive(Debug, Deserialize)]
518#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
519#[cfg_attr(feature = "ts", ts(export))]
520pub struct StreamEventsRequest {
521    #[cfg_attr(feature = "ts", ts(type = "any"))]
522    pub agent: Option<Agent>,
523    #[cfg_attr(feature = "ts", ts(type = "any"))]
524    pub context: Option<SessionContext>,
525    #[cfg_attr(feature = "ts", ts(type = "any[]"))]
526    pub events: Vec<Event>,
527}
528
529/// Returned by `POST /api/sessions/:id/events` — number of events accepted.
530#[derive(Debug, Serialize, Deserialize)]
531#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
532#[cfg_attr(feature = "ts", ts(export))]
533pub struct StreamEventsResponse {
534    pub accepted: usize,
535}
536
537// ─── Health ──────────────────────────────────────────────────────────────────
538
539/// Returned by `GET /api/health` — server liveness check.
540#[derive(Debug, Serialize, Deserialize)]
541#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
542#[cfg_attr(feature = "ts", ts(export))]
543pub struct HealthResponse {
544    pub status: String,
545    pub version: String,
546}
547
548/// Returned by `GET /api/capabilities` — runtime feature availability.
549#[derive(Debug, Serialize, Deserialize)]
550#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
551#[cfg_attr(feature = "ts", ts(export))]
552pub struct CapabilitiesResponse {
553    pub auth_enabled: bool,
554    pub upload_enabled: bool,
555    pub ingest_preview_enabled: bool,
556    pub gh_share_enabled: bool,
557}
558
559// ─── Service Error ───────────────────────────────────────────────────────────
560
561/// Framework-agnostic service error.
562///
563/// Each variant maps to an HTTP status code. Both the Axum server and
564/// Cloudflare Worker convert this into the appropriate response type.
565#[derive(Debug, Clone)]
566#[non_exhaustive]
567pub enum ServiceError {
568    BadRequest(String),
569    Unauthorized(String),
570    Forbidden(String),
571    NotFound(String),
572    Conflict(String),
573    Internal(String),
574}
575
576impl ServiceError {
577    /// HTTP status code as a `u16`.
578    pub fn status_code(&self) -> u16 {
579        match self {
580            Self::BadRequest(_) => 400,
581            Self::Unauthorized(_) => 401,
582            Self::Forbidden(_) => 403,
583            Self::NotFound(_) => 404,
584            Self::Conflict(_) => 409,
585            Self::Internal(_) => 500,
586        }
587    }
588
589    /// Stable machine-readable error code.
590    pub fn code(&self) -> &'static str {
591        match self {
592            Self::BadRequest(_) => "bad_request",
593            Self::Unauthorized(_) => "unauthorized",
594            Self::Forbidden(_) => "forbidden",
595            Self::NotFound(_) => "not_found",
596            Self::Conflict(_) => "conflict",
597            Self::Internal(_) => "internal",
598        }
599    }
600
601    /// The error message.
602    pub fn message(&self) -> &str {
603        match self {
604            Self::BadRequest(m)
605            | Self::Unauthorized(m)
606            | Self::Forbidden(m)
607            | Self::NotFound(m)
608            | Self::Conflict(m)
609            | Self::Internal(m) => m,
610        }
611    }
612
613    /// Build a closure that logs a DB/IO error and returns `Internal`.
614    pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
615        move |e| Self::Internal(format!("{context}: {e}"))
616    }
617}
618
619impl std::fmt::Display for ServiceError {
620    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
621        write!(f, "{}", self.message())
622    }
623}
624
625impl std::error::Error for ServiceError {}
626
627// ─── Error ───────────────────────────────────────────────────────────────────
628
629/// API error payload.
630#[derive(Debug, Serialize)]
631#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
632#[cfg_attr(feature = "ts", ts(export))]
633pub struct ApiError {
634    pub code: String,
635    pub message: String,
636}
637
638impl From<&ServiceError> for ApiError {
639    fn from(e: &ServiceError) -> Self {
640        Self {
641            code: e.code().to_string(),
642            message: e.message().to_string(),
643        }
644    }
645}
646
647// ─── TypeScript generation ───────────────────────────────────────────────────
648
649#[cfg(test)]
650mod schema_tests {
651    use super::*;
652
653    #[test]
654    fn parse_preview_request_round_trip() {
655        let req = ParsePreviewRequest {
656            source: ParseSource::Github {
657                owner: "hwisu".to_string(),
658                repo: "opensession".to_string(),
659                r#ref: "main".to_string(),
660                path: "sessions/demo.hail.jsonl".to_string(),
661            },
662            parser_hint: Some("hail".to_string()),
663        };
664
665        let json = serde_json::to_string(&req).expect("request should serialize");
666        let decoded: ParsePreviewRequest =
667            serde_json::from_str(&json).expect("request should deserialize");
668
669        match decoded.source {
670            ParseSource::Github {
671                owner,
672                repo,
673                r#ref,
674                path,
675            } => {
676                assert_eq!(owner, "hwisu");
677                assert_eq!(repo, "opensession");
678                assert_eq!(r#ref, "main");
679                assert_eq!(path, "sessions/demo.hail.jsonl");
680            }
681            _ => panic!("expected github parse source"),
682        }
683        assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
684    }
685
686    #[test]
687    fn parse_preview_error_response_round_trip_with_candidates() {
688        let payload = ParsePreviewErrorResponse {
689            code: "parser_selection_required".to_string(),
690            message: "choose parser".to_string(),
691            parser_candidates: vec![ParseCandidate {
692                id: "codex".to_string(),
693                confidence: 89,
694                reason: "event markers".to_string(),
695            }],
696        };
697
698        let json = serde_json::to_string(&payload).expect("error payload should serialize");
699        let decoded: ParsePreviewErrorResponse =
700            serde_json::from_str(&json).expect("error payload should deserialize");
701
702        assert_eq!(decoded.code, "parser_selection_required");
703        assert_eq!(decoded.parser_candidates.len(), 1);
704        assert_eq!(decoded.parser_candidates[0].id, "codex");
705    }
706
707    #[test]
708    fn capabilities_response_round_trip_includes_new_fields() {
709        let caps = CapabilitiesResponse {
710            auth_enabled: true,
711            upload_enabled: true,
712            ingest_preview_enabled: true,
713            gh_share_enabled: false,
714        };
715
716        let json = serde_json::to_string(&caps).expect("capabilities should serialize");
717        let decoded: CapabilitiesResponse =
718            serde_json::from_str(&json).expect("capabilities should deserialize");
719
720        assert!(decoded.auth_enabled);
721        assert!(decoded.upload_enabled);
722        assert!(decoded.ingest_preview_enabled);
723        assert!(!decoded.gh_share_enabled);
724    }
725}
726
727#[cfg(all(test, feature = "ts"))]
728mod tests {
729    use super::*;
730    use std::io::Write;
731    use std::path::PathBuf;
732    use ts_rs::TS;
733
734    /// Run with: cargo test -p opensession-api -- export_typescript --nocapture
735    #[test]
736    fn export_typescript() {
737        let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
738            .join("../../packages/ui/src/api-types.generated.ts");
739
740        let cfg = ts_rs::Config::new().with_large_int("number");
741        let mut parts: Vec<String> = Vec::new();
742        parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
743        parts.push(
744            "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
745        );
746        parts.push(String::new());
747
748        // Collect all type declarations.
749        // Structs: `type X = {...}` → `export interface X {...}`
750        // Enums/unions: `type X = "a" | "b"` → `export type X = "a" | "b"`
751        macro_rules! collect_ts {
752            ($($t:ty),+ $(,)?) => {
753                $(
754                    let decl = <$t>::decl(&cfg);
755                    let is_struct_decl = decl.contains(" = {") && !decl.contains("} |");
756                    let decl = if is_struct_decl {
757                        // Struct → export interface
758                        decl
759                            .replacen("type ", "export interface ", 1)
760                            .replace(" = {", " {")
761                            .trim_end_matches(';')
762                            .to_string()
763                    } else {
764                        // Enum/union → export type
765                        decl
766                            .replacen("type ", "export type ", 1)
767                            .trim_end_matches(';')
768                            .to_string()
769                    };
770                    parts.push(decl);
771                    parts.push(String::new());
772                )+
773            };
774        }
775
776        collect_ts!(
777            // Shared enums
778            SortOrder,
779            TimeRange,
780            LinkType,
781            // Auth
782            AuthRegisterRequest,
783            LoginRequest,
784            AuthTokenResponse,
785            RefreshRequest,
786            LogoutRequest,
787            ChangePasswordRequest,
788            VerifyResponse,
789            UserSettingsResponse,
790            OkResponse,
791            IssueApiKeyResponse,
792            OAuthLinkResponse,
793            // Sessions
794            UploadResponse,
795            SessionSummary,
796            SessionListResponse,
797            SessionListQuery,
798            SessionDetail,
799            SessionLink,
800            ParseSource,
801            ParseCandidate,
802            ParsePreviewRequest,
803            ParsePreviewResponse,
804            ParsePreviewErrorResponse,
805            // OAuth
806            oauth::AuthProvidersResponse,
807            oauth::OAuthProviderInfo,
808            oauth::LinkedProvider,
809            // Health
810            HealthResponse,
811            CapabilitiesResponse,
812            ApiError,
813        );
814
815        let content = parts.join("\n");
816
817        // Write to file
818        if let Some(parent) = out_dir.parent() {
819            std::fs::create_dir_all(parent).ok();
820        }
821        let mut file = std::fs::File::create(&out_dir)
822            .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
823        file.write_all(content.as_bytes())
824            .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
825
826        println!("Generated TypeScript types at: {}", out_dir.display());
827    }
828}