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 generic Git remote/ref/path source.
452    Git {
453        remote: String,
454        r#ref: String,
455        path: String,
456    },
457    /// Fetch and parse a raw file from a public GitHub repository.
458    Github {
459        owner: String,
460        repo: String,
461        r#ref: String,
462        path: String,
463    },
464    /// Parse inline file content supplied by clients (for local upload preview).
465    Inline {
466        filename: String,
467        /// Base64-encoded UTF-8 text content.
468        content_base64: String,
469    },
470}
471
472/// Candidate parser ranked by detection confidence.
473#[derive(Debug, Clone, Serialize, Deserialize)]
474#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
475#[cfg_attr(feature = "ts", ts(export))]
476pub struct ParseCandidate {
477    pub id: String,
478    pub confidence: u8,
479    pub reason: String,
480}
481
482/// Request body for `POST /api/parse/preview`.
483#[derive(Debug, Clone, Serialize, Deserialize)]
484#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
485#[cfg_attr(feature = "ts", ts(export))]
486pub struct ParsePreviewRequest {
487    pub source: ParseSource,
488    #[serde(default, skip_serializing_if = "Option::is_none")]
489    pub parser_hint: Option<String>,
490}
491
492/// Response body for `POST /api/parse/preview`.
493#[derive(Debug, Clone, Serialize, Deserialize)]
494#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
495#[cfg_attr(feature = "ts", ts(export))]
496pub struct ParsePreviewResponse {
497    pub parser_used: String,
498    #[serde(default)]
499    pub parser_candidates: Vec<ParseCandidate>,
500    #[cfg_attr(feature = "ts", ts(type = "any"))]
501    pub session: Session,
502    pub source: ParseSource,
503    #[serde(default)]
504    pub warnings: Vec<String>,
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub native_adapter: Option<String>,
507}
508
509/// Structured parser preview error response.
510#[derive(Debug, Clone, Serialize, Deserialize)]
511#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
512#[cfg_attr(feature = "ts", ts(export))]
513pub struct ParsePreviewErrorResponse {
514    pub code: String,
515    pub message: String,
516    #[serde(default, skip_serializing_if = "Vec::is_empty")]
517    pub parser_candidates: Vec<ParseCandidate>,
518}
519
520/// Local review bundle generated from a PR range.
521#[derive(Debug, Clone, Serialize, Deserialize)]
522#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
523#[cfg_attr(feature = "ts", ts(export))]
524pub struct LocalReviewBundle {
525    pub review_id: String,
526    pub generated_at: String,
527    pub pr: LocalReviewPrMeta,
528    #[serde(default)]
529    pub commits: Vec<LocalReviewCommit>,
530    #[serde(default)]
531    pub sessions: Vec<LocalReviewSession>,
532}
533
534/// PR metadata for a local review bundle.
535#[derive(Debug, Clone, Serialize, Deserialize)]
536#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
537#[cfg_attr(feature = "ts", ts(export))]
538pub struct LocalReviewPrMeta {
539    pub url: String,
540    pub owner: String,
541    pub repo: String,
542    pub number: u64,
543    pub remote: String,
544    pub base_sha: String,
545    pub head_sha: String,
546}
547
548/// Commit row in a local review bundle.
549#[derive(Debug, Clone, Serialize, Deserialize)]
550#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
551#[cfg_attr(feature = "ts", ts(export))]
552pub struct LocalReviewCommit {
553    pub sha: String,
554    pub title: String,
555    pub author_name: String,
556    pub author_email: String,
557    pub authored_at: String,
558    #[serde(default)]
559    pub session_ids: Vec<String>,
560}
561
562/// Session payload mapped into a local review bundle.
563#[derive(Debug, Clone, Serialize, Deserialize)]
564#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
565#[cfg_attr(feature = "ts", ts(export))]
566pub struct LocalReviewSession {
567    pub session_id: String,
568    pub ledger_ref: String,
569    pub hail_path: String,
570    #[serde(default)]
571    pub commit_shas: Vec<String>,
572    #[cfg_attr(feature = "ts", ts(type = "any"))]
573    pub session: Session,
574}
575
576// ─── Streaming Events ────────────────────────────────────────────────────────
577
578/// Request body for `POST /api/sessions/:id/events` — append live events.
579#[derive(Debug, Deserialize)]
580#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
581#[cfg_attr(feature = "ts", ts(export))]
582pub struct StreamEventsRequest {
583    #[cfg_attr(feature = "ts", ts(type = "any"))]
584    pub agent: Option<Agent>,
585    #[cfg_attr(feature = "ts", ts(type = "any"))]
586    pub context: Option<SessionContext>,
587    #[cfg_attr(feature = "ts", ts(type = "any[]"))]
588    pub events: Vec<Event>,
589}
590
591/// Returned by `POST /api/sessions/:id/events` — number of events accepted.
592#[derive(Debug, Serialize, Deserialize)]
593#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
594#[cfg_attr(feature = "ts", ts(export))]
595pub struct StreamEventsResponse {
596    pub accepted: usize,
597}
598
599// ─── Health ──────────────────────────────────────────────────────────────────
600
601/// Returned by `GET /api/health` — server liveness check.
602#[derive(Debug, Serialize, Deserialize)]
603#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
604#[cfg_attr(feature = "ts", ts(export))]
605pub struct HealthResponse {
606    pub status: String,
607    pub version: String,
608}
609
610/// Returned by `GET /api/capabilities` — runtime feature availability.
611#[derive(Debug, Serialize, Deserialize)]
612#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
613#[cfg_attr(feature = "ts", ts(export))]
614pub struct CapabilitiesResponse {
615    pub auth_enabled: bool,
616    pub parse_preview_enabled: bool,
617    pub register_targets: Vec<String>,
618    pub share_modes: Vec<String>,
619}
620
621pub const DEFAULT_REGISTER_TARGETS: &[&str] = &["local", "git"];
622pub const DEFAULT_SHARE_MODES: &[&str] = &["web", "git", "json"];
623
624impl CapabilitiesResponse {
625    /// Build runtime capability payload with shared defaults.
626    pub fn for_runtime(auth_enabled: bool, parse_preview_enabled: bool) -> Self {
627        Self {
628            auth_enabled,
629            parse_preview_enabled,
630            register_targets: DEFAULT_REGISTER_TARGETS
631                .iter()
632                .map(|target| (*target).to_string())
633                .collect(),
634            share_modes: DEFAULT_SHARE_MODES
635                .iter()
636                .map(|mode| (*mode).to_string())
637                .collect(),
638        }
639    }
640}
641
642// ─── Service Error ───────────────────────────────────────────────────────────
643
644/// Framework-agnostic service error.
645///
646/// Each variant maps to an HTTP status code. Both the Axum server and
647/// Cloudflare Worker convert this into the appropriate response type.
648#[derive(Debug, Clone)]
649#[non_exhaustive]
650pub enum ServiceError {
651    BadRequest(String),
652    Unauthorized(String),
653    Forbidden(String),
654    NotFound(String),
655    Conflict(String),
656    Internal(String),
657}
658
659impl ServiceError {
660    /// HTTP status code as a `u16`.
661    pub fn status_code(&self) -> u16 {
662        match self {
663            Self::BadRequest(_) => 400,
664            Self::Unauthorized(_) => 401,
665            Self::Forbidden(_) => 403,
666            Self::NotFound(_) => 404,
667            Self::Conflict(_) => 409,
668            Self::Internal(_) => 500,
669        }
670    }
671
672    /// Stable machine-readable error code.
673    pub fn code(&self) -> &'static str {
674        match self {
675            Self::BadRequest(_) => "bad_request",
676            Self::Unauthorized(_) => "unauthorized",
677            Self::Forbidden(_) => "forbidden",
678            Self::NotFound(_) => "not_found",
679            Self::Conflict(_) => "conflict",
680            Self::Internal(_) => "internal",
681        }
682    }
683
684    /// The error message.
685    pub fn message(&self) -> &str {
686        match self {
687            Self::BadRequest(m)
688            | Self::Unauthorized(m)
689            | Self::Forbidden(m)
690            | Self::NotFound(m)
691            | Self::Conflict(m)
692            | Self::Internal(m) => m,
693        }
694    }
695
696    /// Build a closure that logs a DB/IO error and returns `Internal`.
697    pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
698        move |e| Self::Internal(format!("{context}: {e}"))
699    }
700}
701
702impl std::fmt::Display for ServiceError {
703    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
704        write!(f, "{}", self.message())
705    }
706}
707
708impl std::error::Error for ServiceError {}
709
710// ─── Error ───────────────────────────────────────────────────────────────────
711
712/// API error payload.
713#[derive(Debug, Serialize)]
714#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
715#[cfg_attr(feature = "ts", ts(export))]
716pub struct ApiError {
717    pub code: String,
718    pub message: String,
719}
720
721impl From<&ServiceError> for ApiError {
722    fn from(e: &ServiceError) -> Self {
723        Self {
724            code: e.code().to_string(),
725            message: e.message().to_string(),
726        }
727    }
728}
729
730// ─── TypeScript generation ───────────────────────────────────────────────────
731
732#[cfg(test)]
733mod schema_tests {
734    use super::*;
735
736    #[test]
737    fn parse_preview_request_round_trip_git() {
738        let req = ParsePreviewRequest {
739            source: ParseSource::Git {
740                remote: "https://github.com/hwisu/opensession".to_string(),
741                r#ref: "main".to_string(),
742                path: "sessions/demo.hail.jsonl".to_string(),
743            },
744            parser_hint: Some("hail".to_string()),
745        };
746
747        let json = serde_json::to_string(&req).expect("request should serialize");
748        let decoded: ParsePreviewRequest =
749            serde_json::from_str(&json).expect("request should deserialize");
750
751        match decoded.source {
752            ParseSource::Git {
753                remote,
754                r#ref,
755                path,
756            } => {
757                assert_eq!(remote, "https://github.com/hwisu/opensession");
758                assert_eq!(r#ref, "main");
759                assert_eq!(path, "sessions/demo.hail.jsonl");
760            }
761            _ => panic!("expected git parse source"),
762        }
763        assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
764    }
765
766    #[test]
767    fn parse_preview_request_round_trip_github_compat() {
768        let req = ParsePreviewRequest {
769            source: ParseSource::Github {
770                owner: "hwisu".to_string(),
771                repo: "opensession".to_string(),
772                r#ref: "main".to_string(),
773                path: "sessions/demo.hail.jsonl".to_string(),
774            },
775            parser_hint: Some("hail".to_string()),
776        };
777
778        let json = serde_json::to_string(&req).expect("request should serialize");
779        let decoded: ParsePreviewRequest =
780            serde_json::from_str(&json).expect("request should deserialize");
781
782        match decoded.source {
783            ParseSource::Github {
784                owner,
785                repo,
786                r#ref,
787                path,
788            } => {
789                assert_eq!(owner, "hwisu");
790                assert_eq!(repo, "opensession");
791                assert_eq!(r#ref, "main");
792                assert_eq!(path, "sessions/demo.hail.jsonl");
793            }
794            _ => panic!("expected github parse source"),
795        }
796        assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
797    }
798
799    #[test]
800    fn parse_preview_error_response_round_trip_with_candidates() {
801        let payload = ParsePreviewErrorResponse {
802            code: "parser_selection_required".to_string(),
803            message: "choose parser".to_string(),
804            parser_candidates: vec![ParseCandidate {
805                id: "codex".to_string(),
806                confidence: 89,
807                reason: "event markers".to_string(),
808            }],
809        };
810
811        let json = serde_json::to_string(&payload).expect("error payload should serialize");
812        let decoded: ParsePreviewErrorResponse =
813            serde_json::from_str(&json).expect("error payload should deserialize");
814
815        assert_eq!(decoded.code, "parser_selection_required");
816        assert_eq!(decoded.parser_candidates.len(), 1);
817        assert_eq!(decoded.parser_candidates[0].id, "codex");
818    }
819
820    #[test]
821    fn local_review_bundle_round_trip() {
822        let mut sample_session = Session::new(
823            "s-review-1".to_string(),
824            Agent {
825                provider: "openai".to_string(),
826                model: "gpt-5".to_string(),
827                tool: "codex".to_string(),
828                tool_version: None,
829            },
830        );
831        sample_session.recompute_stats();
832
833        let payload = LocalReviewBundle {
834            review_id: "gh-org-repo-pr1-abc1234".to_string(),
835            generated_at: "2026-02-24T00:00:00Z".to_string(),
836            pr: LocalReviewPrMeta {
837                url: "https://github.com/org/repo/pull/1".to_string(),
838                owner: "org".to_string(),
839                repo: "repo".to_string(),
840                number: 1,
841                remote: "origin".to_string(),
842                base_sha: "a".repeat(40),
843                head_sha: "b".repeat(40),
844            },
845            commits: vec![LocalReviewCommit {
846                sha: "c".repeat(40),
847                title: "feat: add review flow".to_string(),
848                author_name: "Alice".to_string(),
849                author_email: "alice@example.com".to_string(),
850                authored_at: "2026-02-24T00:00:00Z".to_string(),
851                session_ids: vec!["s-review-1".to_string()],
852            }],
853            sessions: vec![LocalReviewSession {
854                session_id: "s-review-1".to_string(),
855                ledger_ref: "refs/remotes/origin/opensession/branches/bWFpbg".to_string(),
856                hail_path: "v1/se/s-review-1.hail.jsonl".to_string(),
857                commit_shas: vec!["c".repeat(40)],
858                session: sample_session,
859            }],
860        };
861
862        let json = serde_json::to_string(&payload).expect("review bundle should serialize");
863        let decoded: LocalReviewBundle =
864            serde_json::from_str(&json).expect("review bundle should deserialize");
865
866        assert_eq!(decoded.review_id, "gh-org-repo-pr1-abc1234");
867        assert_eq!(decoded.pr.number, 1);
868        assert_eq!(decoded.commits.len(), 1);
869        assert_eq!(decoded.sessions.len(), 1);
870        assert_eq!(decoded.sessions[0].session_id, "s-review-1");
871    }
872
873    #[test]
874    fn capabilities_response_round_trip_includes_new_fields() {
875        let caps = CapabilitiesResponse::for_runtime(true, true);
876
877        let json = serde_json::to_string(&caps).expect("capabilities should serialize");
878        let decoded: CapabilitiesResponse =
879            serde_json::from_str(&json).expect("capabilities should deserialize");
880
881        assert!(decoded.auth_enabled);
882        assert!(decoded.parse_preview_enabled);
883        assert_eq!(decoded.register_targets, vec!["local", "git"]);
884        assert_eq!(decoded.share_modes, vec!["web", "git", "json"]);
885    }
886
887    #[test]
888    fn capabilities_defaults_are_stable() {
889        assert_eq!(DEFAULT_REGISTER_TARGETS, &["local", "git"]);
890        assert_eq!(DEFAULT_SHARE_MODES, &["web", "git", "json"]);
891    }
892}
893
894#[cfg(all(test, feature = "ts"))]
895mod tests {
896    use super::*;
897    use std::io::Write;
898    use std::path::PathBuf;
899    use ts_rs::TS;
900
901    /// Run with: cargo test -p opensession-api -- export_typescript --nocapture
902    #[test]
903    fn export_typescript() {
904        let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
905            .join("../../packages/ui/src/api-types.generated.ts");
906
907        let cfg = ts_rs::Config::new().with_large_int("number");
908        let mut parts: Vec<String> = Vec::new();
909        parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
910        parts.push(
911            "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
912        );
913        parts.push(String::new());
914
915        // Collect all type declarations.
916        // Structs: `type X = {...}` → `export interface X {...}`
917        // Enums/unions: `type X = "a" | "b"` → `export type X = "a" | "b"`
918        macro_rules! collect_ts {
919            ($($t:ty),+ $(,)?) => {
920                $(
921                    let decl = <$t>::decl(&cfg);
922                    let is_struct_decl = decl.contains(" = {") && !decl.contains("} |");
923                    let decl = if is_struct_decl {
924                        // Struct → export interface
925                        decl
926                            .replacen("type ", "export interface ", 1)
927                            .replace(" = {", " {")
928                            .trim_end_matches(';')
929                            .to_string()
930                    } else {
931                        // Enum/union → export type
932                        decl
933                            .replacen("type ", "export type ", 1)
934                            .trim_end_matches(';')
935                            .to_string()
936                    };
937                    parts.push(decl);
938                    parts.push(String::new());
939                )+
940            };
941        }
942
943        collect_ts!(
944            // Shared enums
945            SortOrder,
946            TimeRange,
947            LinkType,
948            // Auth
949            AuthRegisterRequest,
950            LoginRequest,
951            AuthTokenResponse,
952            RefreshRequest,
953            LogoutRequest,
954            ChangePasswordRequest,
955            VerifyResponse,
956            UserSettingsResponse,
957            OkResponse,
958            IssueApiKeyResponse,
959            OAuthLinkResponse,
960            // Sessions
961            UploadResponse,
962            SessionSummary,
963            SessionListResponse,
964            SessionListQuery,
965            SessionDetail,
966            SessionLink,
967            ParseSource,
968            ParseCandidate,
969            ParsePreviewRequest,
970            ParsePreviewResponse,
971            ParsePreviewErrorResponse,
972            LocalReviewBundle,
973            LocalReviewPrMeta,
974            LocalReviewCommit,
975            LocalReviewSession,
976            // OAuth
977            oauth::AuthProvidersResponse,
978            oauth::OAuthProviderInfo,
979            oauth::LinkedProvider,
980            // Health
981            HealthResponse,
982            CapabilitiesResponse,
983            ApiError,
984        );
985
986        let content = parts.join("\n");
987
988        // Write to file
989        if let Some(parent) = out_dir.parent() {
990            std::fs::create_dir_all(parent).ok();
991        }
992        let mut file = std::fs::File::create(&out_dir)
993            .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
994        file.write_all(content.as_bytes())
995            .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
996
997        println!("Generated TypeScript types at: {}", out_dir.display());
998    }
999}