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/// Public metadata for a user-managed git credential.
223#[derive(Debug, Serialize, Deserialize)]
224#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
225#[cfg_attr(feature = "ts", ts(export))]
226pub struct GitCredentialSummary {
227    pub id: String,
228    pub label: String,
229    pub host: String,
230    pub path_prefix: String,
231    pub header_name: String,
232    pub created_at: String,
233    pub updated_at: String,
234    pub last_used_at: Option<String>,
235}
236
237/// Response for `GET /api/auth/git-credentials`.
238#[derive(Debug, Serialize, Deserialize)]
239#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
240#[cfg_attr(feature = "ts", ts(export))]
241pub struct ListGitCredentialsResponse {
242    #[serde(default)]
243    pub credentials: Vec<GitCredentialSummary>,
244}
245
246/// Request for `POST /api/auth/git-credentials`.
247#[derive(Debug, Serialize, Deserialize)]
248#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
249#[cfg_attr(feature = "ts", ts(export))]
250pub struct CreateGitCredentialRequest {
251    pub label: String,
252    pub host: String,
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub path_prefix: Option<String>,
255    pub header_name: String,
256    pub header_value: String,
257}
258
259/// Response for OAuth link initiation (redirect URL).
260#[derive(Debug, Serialize)]
261#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
262#[cfg_attr(feature = "ts", ts(export))]
263pub struct OAuthLinkResponse {
264    pub url: String,
265}
266
267// ─── Sessions ────────────────────────────────────────────────────────────────
268
269/// Request body for `POST /api/sessions` — upload a recorded session.
270#[derive(Debug, Serialize, Deserialize)]
271pub struct UploadRequest {
272    pub session: Session,
273    #[serde(default, skip_serializing_if = "Option::is_none")]
274    pub body_url: Option<String>,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub linked_session_ids: Option<Vec<String>>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub git_remote: Option<String>,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub git_branch: Option<String>,
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub git_commit: Option<String>,
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub git_repo_name: Option<String>,
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub pr_number: Option<i64>,
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub pr_url: Option<String>,
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub score_plugin: Option<String>,
291}
292
293/// Returned on successful session upload — contains the new session ID and URL.
294#[derive(Debug, Serialize, Deserialize)]
295#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
296#[cfg_attr(feature = "ts", ts(export))]
297pub struct UploadResponse {
298    pub id: String,
299    pub url: String,
300    #[serde(default)]
301    pub session_score: i64,
302    #[serde(default = "default_score_plugin")]
303    pub score_plugin: String,
304}
305
306/// Flat session summary returned by list/detail endpoints.
307/// This is NOT the full HAIL Session — it's a DB-derived summary.
308#[derive(Debug, Clone, Serialize, Deserialize)]
309#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
310#[cfg_attr(feature = "ts", ts(export))]
311pub struct SessionSummary {
312    pub id: String,
313    pub user_id: Option<String>,
314    pub nickname: Option<String>,
315    pub tool: String,
316    pub agent_provider: Option<String>,
317    pub agent_model: Option<String>,
318    pub title: Option<String>,
319    pub description: Option<String>,
320    /// Comma-separated tags string
321    pub tags: Option<String>,
322    pub created_at: String,
323    pub uploaded_at: String,
324    pub message_count: i64,
325    pub task_count: i64,
326    pub event_count: i64,
327    pub duration_seconds: i64,
328    pub total_input_tokens: i64,
329    pub total_output_tokens: i64,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub git_remote: Option<String>,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub git_branch: Option<String>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub git_commit: Option<String>,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub git_repo_name: Option<String>,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub pr_number: Option<i64>,
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub pr_url: Option<String>,
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub working_directory: Option<String>,
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub files_modified: Option<String>,
346    #[serde(default, skip_serializing_if = "Option::is_none")]
347    pub files_read: Option<String>,
348    #[serde(default)]
349    pub has_errors: bool,
350    #[serde(default = "default_max_active_agents")]
351    pub max_active_agents: i64,
352    #[serde(default)]
353    pub session_score: i64,
354    #[serde(default = "default_score_plugin")]
355    pub score_plugin: String,
356}
357
358/// Paginated session listing returned by `GET /api/sessions`.
359#[derive(Debug, Serialize, Deserialize)]
360#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
361#[cfg_attr(feature = "ts", ts(export))]
362pub struct SessionListResponse {
363    pub sessions: Vec<SessionSummary>,
364    pub total: i64,
365    pub page: u32,
366    pub per_page: u32,
367}
368
369/// Query parameters for `GET /api/sessions` — pagination, filtering, sorting.
370#[derive(Debug, Deserialize)]
371#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
372#[cfg_attr(feature = "ts", ts(export))]
373pub struct SessionListQuery {
374    #[serde(default = "default_page")]
375    pub page: u32,
376    #[serde(default = "default_per_page")]
377    pub per_page: u32,
378    pub search: Option<String>,
379    pub tool: Option<String>,
380    /// Sort order (default: recent)
381    pub sort: Option<SortOrder>,
382    /// Time range filter (default: all)
383    pub time_range: Option<TimeRange>,
384}
385
386impl SessionListQuery {
387    /// Returns true when this query targets the anonymous public feed and is safe to edge-cache.
388    pub fn is_public_feed_cacheable(
389        &self,
390        has_auth_header: bool,
391        has_session_cookie: bool,
392    ) -> bool {
393        !has_auth_header
394            && !has_session_cookie
395            && self.search.as_deref().is_none_or(|s| s.trim().is_empty())
396            && self.page <= 10
397            && self.per_page <= 50
398    }
399}
400
401#[cfg(test)]
402mod session_list_query_tests {
403    use super::*;
404
405    fn base_query() -> SessionListQuery {
406        SessionListQuery {
407            page: 1,
408            per_page: 20,
409            search: None,
410            tool: None,
411            sort: None,
412            time_range: None,
413        }
414    }
415
416    #[test]
417    fn public_feed_cacheable_when_anonymous_default_feed() {
418        let q = base_query();
419        assert!(q.is_public_feed_cacheable(false, false));
420    }
421
422    #[test]
423    fn public_feed_not_cacheable_with_auth_or_cookie() {
424        let q = base_query();
425        assert!(!q.is_public_feed_cacheable(true, false));
426        assert!(!q.is_public_feed_cacheable(false, true));
427    }
428
429    #[test]
430    fn public_feed_not_cacheable_for_search_or_large_page() {
431        let mut q = base_query();
432        q.search = Some("hello".into());
433        assert!(!q.is_public_feed_cacheable(false, false));
434
435        let mut q = base_query();
436        q.page = 11;
437        assert!(!q.is_public_feed_cacheable(false, false));
438
439        let mut q = base_query();
440        q.per_page = 100;
441        assert!(!q.is_public_feed_cacheable(false, false));
442    }
443}
444
445fn default_page() -> u32 {
446    1
447}
448fn default_per_page() -> u32 {
449    20
450}
451fn default_max_active_agents() -> i64 {
452    1
453}
454
455fn default_score_plugin() -> String {
456    opensession_core::scoring::DEFAULT_SCORE_PLUGIN.to_string()
457}
458
459/// Single session detail returned by `GET /api/sessions/:id`.
460#[derive(Debug, Serialize, Deserialize)]
461#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
462#[cfg_attr(feature = "ts", ts(export))]
463pub struct SessionDetail {
464    #[serde(flatten)]
465    #[cfg_attr(feature = "ts", ts(flatten))]
466    pub summary: SessionSummary,
467    #[serde(default, skip_serializing_if = "Vec::is_empty")]
468    pub linked_sessions: Vec<SessionLink>,
469}
470
471/// A link between two sessions (e.g., handoff chain).
472#[derive(Debug, Clone, Serialize, Deserialize)]
473#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
474#[cfg_attr(feature = "ts", ts(export))]
475pub struct SessionLink {
476    pub session_id: String,
477    pub linked_session_id: String,
478    pub link_type: LinkType,
479    pub created_at: String,
480}
481
482/// Source descriptor for parser preview requests.
483#[derive(Debug, Clone, Serialize, Deserialize)]
484#[serde(tag = "kind", rename_all = "snake_case")]
485#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
486#[cfg_attr(feature = "ts", ts(export))]
487pub enum ParseSource {
488    /// Fetch and parse a raw file from a generic Git remote/ref/path source.
489    Git {
490        remote: String,
491        r#ref: String,
492        path: String,
493    },
494    /// Fetch and parse a raw file from a public GitHub repository.
495    Github {
496        owner: String,
497        repo: String,
498        r#ref: String,
499        path: String,
500    },
501    /// Parse inline file content supplied by clients (for local upload preview).
502    Inline {
503        filename: String,
504        /// Base64-encoded UTF-8 text content.
505        content_base64: String,
506    },
507}
508
509/// Candidate parser ranked by detection confidence.
510#[derive(Debug, Clone, Serialize, Deserialize)]
511#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
512#[cfg_attr(feature = "ts", ts(export))]
513pub struct ParseCandidate {
514    pub id: String,
515    pub confidence: u8,
516    pub reason: String,
517}
518
519/// Request body for `POST /api/parse/preview`.
520#[derive(Debug, Clone, Serialize, Deserialize)]
521#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
522#[cfg_attr(feature = "ts", ts(export))]
523pub struct ParsePreviewRequest {
524    pub source: ParseSource,
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub parser_hint: Option<String>,
527}
528
529/// Response body for `POST /api/parse/preview`.
530#[derive(Debug, Clone, Serialize, Deserialize)]
531#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
532#[cfg_attr(feature = "ts", ts(export))]
533pub struct ParsePreviewResponse {
534    pub parser_used: String,
535    #[serde(default)]
536    pub parser_candidates: Vec<ParseCandidate>,
537    #[cfg_attr(feature = "ts", ts(type = "any"))]
538    pub session: Session,
539    pub source: ParseSource,
540    #[serde(default)]
541    pub warnings: Vec<String>,
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub native_adapter: Option<String>,
544}
545
546/// Structured parser preview error response.
547#[derive(Debug, Clone, Serialize, Deserialize)]
548#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
549#[cfg_attr(feature = "ts", ts(export))]
550pub struct ParsePreviewErrorResponse {
551    pub code: String,
552    pub message: String,
553    #[serde(default, skip_serializing_if = "Vec::is_empty")]
554    pub parser_candidates: Vec<ParseCandidate>,
555}
556
557/// Local review bundle generated from a PR range.
558#[derive(Debug, Clone, Serialize, Deserialize)]
559#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
560#[cfg_attr(feature = "ts", ts(export))]
561pub struct LocalReviewBundle {
562    pub review_id: String,
563    pub generated_at: String,
564    pub pr: LocalReviewPrMeta,
565    #[serde(default)]
566    pub commits: Vec<LocalReviewCommit>,
567    #[serde(default)]
568    pub sessions: Vec<LocalReviewSession>,
569}
570
571/// PR metadata for a local review bundle.
572#[derive(Debug, Clone, Serialize, Deserialize)]
573#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
574#[cfg_attr(feature = "ts", ts(export))]
575pub struct LocalReviewPrMeta {
576    pub url: String,
577    pub owner: String,
578    pub repo: String,
579    pub number: u64,
580    pub remote: String,
581    pub base_sha: String,
582    pub head_sha: String,
583}
584
585/// Commit row in a local review bundle.
586#[derive(Debug, Clone, Serialize, Deserialize)]
587#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
588#[cfg_attr(feature = "ts", ts(export))]
589pub struct LocalReviewCommit {
590    pub sha: String,
591    pub title: String,
592    pub author_name: String,
593    pub author_email: String,
594    pub authored_at: String,
595    #[serde(default)]
596    pub session_ids: Vec<String>,
597}
598
599/// Session payload mapped into a local review bundle.
600#[derive(Debug, Clone, Serialize, Deserialize)]
601#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
602#[cfg_attr(feature = "ts", ts(export))]
603pub struct LocalReviewSession {
604    pub session_id: String,
605    pub ledger_ref: String,
606    pub hail_path: String,
607    #[serde(default)]
608    pub commit_shas: Vec<String>,
609    #[cfg_attr(feature = "ts", ts(type = "any"))]
610    pub session: Session,
611}
612
613// ─── Streaming Events ────────────────────────────────────────────────────────
614
615/// Request body for `POST /api/sessions/:id/events` — append live events.
616#[derive(Debug, Deserialize)]
617#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
618#[cfg_attr(feature = "ts", ts(export))]
619pub struct StreamEventsRequest {
620    #[cfg_attr(feature = "ts", ts(type = "any"))]
621    pub agent: Option<Agent>,
622    #[cfg_attr(feature = "ts", ts(type = "any"))]
623    pub context: Option<SessionContext>,
624    #[cfg_attr(feature = "ts", ts(type = "any[]"))]
625    pub events: Vec<Event>,
626}
627
628/// Returned by `POST /api/sessions/:id/events` — number of events accepted.
629#[derive(Debug, Serialize, Deserialize)]
630#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
631#[cfg_attr(feature = "ts", ts(export))]
632pub struct StreamEventsResponse {
633    pub accepted: usize,
634}
635
636// ─── Health ──────────────────────────────────────────────────────────────────
637
638/// Returned by `GET /api/health` — server liveness check.
639#[derive(Debug, Serialize, Deserialize)]
640#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
641#[cfg_attr(feature = "ts", ts(export))]
642pub struct HealthResponse {
643    pub status: String,
644    pub version: String,
645}
646
647/// Returned by `GET /api/capabilities` — runtime feature availability.
648#[derive(Debug, Serialize, Deserialize)]
649#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
650#[cfg_attr(feature = "ts", ts(export))]
651pub struct CapabilitiesResponse {
652    pub auth_enabled: bool,
653    pub parse_preview_enabled: bool,
654    pub register_targets: Vec<String>,
655    pub share_modes: Vec<String>,
656}
657
658pub const DEFAULT_REGISTER_TARGETS: &[&str] = &["local", "git"];
659pub const DEFAULT_SHARE_MODES: &[&str] = &["web", "git", "json"];
660
661impl CapabilitiesResponse {
662    /// Build runtime capability payload with shared defaults.
663    pub fn for_runtime(auth_enabled: bool, parse_preview_enabled: bool) -> Self {
664        Self {
665            auth_enabled,
666            parse_preview_enabled,
667            register_targets: DEFAULT_REGISTER_TARGETS
668                .iter()
669                .map(|target| (*target).to_string())
670                .collect(),
671            share_modes: DEFAULT_SHARE_MODES
672                .iter()
673                .map(|mode| (*mode).to_string())
674                .collect(),
675        }
676    }
677}
678
679// ─── Service Error ───────────────────────────────────────────────────────────
680
681/// Framework-agnostic service error.
682///
683/// Each variant maps to an HTTP status code. Both the Axum server and
684/// Cloudflare Worker convert this into the appropriate response type.
685#[derive(Debug, Clone)]
686#[non_exhaustive]
687pub enum ServiceError {
688    BadRequest(String),
689    Unauthorized(String),
690    Forbidden(String),
691    NotFound(String),
692    Conflict(String),
693    Internal(String),
694}
695
696impl ServiceError {
697    /// HTTP status code as a `u16`.
698    pub fn status_code(&self) -> u16 {
699        match self {
700            Self::BadRequest(_) => 400,
701            Self::Unauthorized(_) => 401,
702            Self::Forbidden(_) => 403,
703            Self::NotFound(_) => 404,
704            Self::Conflict(_) => 409,
705            Self::Internal(_) => 500,
706        }
707    }
708
709    /// Stable machine-readable error code.
710    pub fn code(&self) -> &'static str {
711        match self {
712            Self::BadRequest(_) => "bad_request",
713            Self::Unauthorized(_) => "unauthorized",
714            Self::Forbidden(_) => "forbidden",
715            Self::NotFound(_) => "not_found",
716            Self::Conflict(_) => "conflict",
717            Self::Internal(_) => "internal",
718        }
719    }
720
721    /// The error message.
722    pub fn message(&self) -> &str {
723        match self {
724            Self::BadRequest(m)
725            | Self::Unauthorized(m)
726            | Self::Forbidden(m)
727            | Self::NotFound(m)
728            | Self::Conflict(m)
729            | Self::Internal(m) => m,
730        }
731    }
732
733    /// Build a closure that logs a DB/IO error and returns `Internal`.
734    pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
735        move |e| Self::Internal(format!("{context}: {e}"))
736    }
737}
738
739impl std::fmt::Display for ServiceError {
740    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
741        write!(f, "{}", self.message())
742    }
743}
744
745impl std::error::Error for ServiceError {}
746
747// ─── Error ───────────────────────────────────────────────────────────────────
748
749/// API error payload.
750#[derive(Debug, Serialize)]
751#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
752#[cfg_attr(feature = "ts", ts(export))]
753pub struct ApiError {
754    pub code: String,
755    pub message: String,
756}
757
758impl From<&ServiceError> for ApiError {
759    fn from(e: &ServiceError) -> Self {
760        Self {
761            code: e.code().to_string(),
762            message: e.message().to_string(),
763        }
764    }
765}
766
767// ─── TypeScript generation ───────────────────────────────────────────────────
768
769#[cfg(test)]
770mod schema_tests {
771    use super::*;
772
773    #[test]
774    fn parse_preview_request_round_trip_git() {
775        let req = ParsePreviewRequest {
776            source: ParseSource::Git {
777                remote: "https://github.com/hwisu/opensession".to_string(),
778                r#ref: "main".to_string(),
779                path: "sessions/demo.hail.jsonl".to_string(),
780            },
781            parser_hint: Some("hail".to_string()),
782        };
783
784        let json = serde_json::to_string(&req).expect("request should serialize");
785        let decoded: ParsePreviewRequest =
786            serde_json::from_str(&json).expect("request should deserialize");
787
788        match decoded.source {
789            ParseSource::Git {
790                remote,
791                r#ref,
792                path,
793            } => {
794                assert_eq!(remote, "https://github.com/hwisu/opensession");
795                assert_eq!(r#ref, "main");
796                assert_eq!(path, "sessions/demo.hail.jsonl");
797            }
798            _ => panic!("expected git parse source"),
799        }
800        assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
801    }
802
803    #[test]
804    fn parse_preview_request_round_trip_github_compat() {
805        let req = ParsePreviewRequest {
806            source: ParseSource::Github {
807                owner: "hwisu".to_string(),
808                repo: "opensession".to_string(),
809                r#ref: "main".to_string(),
810                path: "sessions/demo.hail.jsonl".to_string(),
811            },
812            parser_hint: Some("hail".to_string()),
813        };
814
815        let json = serde_json::to_string(&req).expect("request should serialize");
816        let decoded: ParsePreviewRequest =
817            serde_json::from_str(&json).expect("request should deserialize");
818
819        match decoded.source {
820            ParseSource::Github {
821                owner,
822                repo,
823                r#ref,
824                path,
825            } => {
826                assert_eq!(owner, "hwisu");
827                assert_eq!(repo, "opensession");
828                assert_eq!(r#ref, "main");
829                assert_eq!(path, "sessions/demo.hail.jsonl");
830            }
831            _ => panic!("expected github parse source"),
832        }
833        assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
834    }
835
836    #[test]
837    fn parse_preview_error_response_round_trip_with_candidates() {
838        let payload = ParsePreviewErrorResponse {
839            code: "parser_selection_required".to_string(),
840            message: "choose parser".to_string(),
841            parser_candidates: vec![ParseCandidate {
842                id: "codex".to_string(),
843                confidence: 89,
844                reason: "event markers".to_string(),
845            }],
846        };
847
848        let json = serde_json::to_string(&payload).expect("error payload should serialize");
849        let decoded: ParsePreviewErrorResponse =
850            serde_json::from_str(&json).expect("error payload should deserialize");
851
852        assert_eq!(decoded.code, "parser_selection_required");
853        assert_eq!(decoded.parser_candidates.len(), 1);
854        assert_eq!(decoded.parser_candidates[0].id, "codex");
855    }
856
857    #[test]
858    fn local_review_bundle_round_trip() {
859        let mut sample_session = Session::new(
860            "s-review-1".to_string(),
861            Agent {
862                provider: "openai".to_string(),
863                model: "gpt-5".to_string(),
864                tool: "codex".to_string(),
865                tool_version: None,
866            },
867        );
868        sample_session.recompute_stats();
869
870        let payload = LocalReviewBundle {
871            review_id: "gh-org-repo-pr1-abc1234".to_string(),
872            generated_at: "2026-02-24T00:00:00Z".to_string(),
873            pr: LocalReviewPrMeta {
874                url: "https://github.com/org/repo/pull/1".to_string(),
875                owner: "org".to_string(),
876                repo: "repo".to_string(),
877                number: 1,
878                remote: "origin".to_string(),
879                base_sha: "a".repeat(40),
880                head_sha: "b".repeat(40),
881            },
882            commits: vec![LocalReviewCommit {
883                sha: "c".repeat(40),
884                title: "feat: add review flow".to_string(),
885                author_name: "Alice".to_string(),
886                author_email: "alice@example.com".to_string(),
887                authored_at: "2026-02-24T00:00:00Z".to_string(),
888                session_ids: vec!["s-review-1".to_string()],
889            }],
890            sessions: vec![LocalReviewSession {
891                session_id: "s-review-1".to_string(),
892                ledger_ref: "refs/remotes/origin/opensession/branches/bWFpbg".to_string(),
893                hail_path: "v1/se/s-review-1.hail.jsonl".to_string(),
894                commit_shas: vec!["c".repeat(40)],
895                session: sample_session,
896            }],
897        };
898
899        let json = serde_json::to_string(&payload).expect("review bundle should serialize");
900        let decoded: LocalReviewBundle =
901            serde_json::from_str(&json).expect("review bundle should deserialize");
902
903        assert_eq!(decoded.review_id, "gh-org-repo-pr1-abc1234");
904        assert_eq!(decoded.pr.number, 1);
905        assert_eq!(decoded.commits.len(), 1);
906        assert_eq!(decoded.sessions.len(), 1);
907        assert_eq!(decoded.sessions[0].session_id, "s-review-1");
908    }
909
910    #[test]
911    fn capabilities_response_round_trip_includes_new_fields() {
912        let caps = CapabilitiesResponse::for_runtime(true, true);
913
914        let json = serde_json::to_string(&caps).expect("capabilities should serialize");
915        let decoded: CapabilitiesResponse =
916            serde_json::from_str(&json).expect("capabilities should deserialize");
917
918        assert!(decoded.auth_enabled);
919        assert!(decoded.parse_preview_enabled);
920        assert_eq!(decoded.register_targets, vec!["local", "git"]);
921        assert_eq!(decoded.share_modes, vec!["web", "git", "json"]);
922    }
923
924    #[test]
925    fn capabilities_defaults_are_stable() {
926        assert_eq!(DEFAULT_REGISTER_TARGETS, &["local", "git"]);
927        assert_eq!(DEFAULT_SHARE_MODES, &["web", "git", "json"]);
928    }
929}
930
931#[cfg(all(test, feature = "ts"))]
932mod tests {
933    use super::*;
934    use std::io::Write;
935    use std::path::PathBuf;
936    use ts_rs::TS;
937
938    /// Run with: cargo test -p opensession-api -- export_typescript --nocapture
939    #[test]
940    fn export_typescript() {
941        let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
942            .join("../../packages/ui/src/api-types.generated.ts");
943
944        let cfg = ts_rs::Config::new().with_large_int("number");
945        let mut parts: Vec<String> = Vec::new();
946        parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
947        parts.push(
948            "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
949        );
950        parts.push(String::new());
951
952        // Collect all type declarations.
953        // Structs: `type X = {...}` → `export interface X {...}`
954        // Enums/unions: `type X = "a" | "b"` → `export type X = "a" | "b"`
955        macro_rules! collect_ts {
956            ($($t:ty),+ $(,)?) => {
957                $(
958                    let decl = <$t>::decl(&cfg);
959                    let is_struct_decl = decl.contains(" = {") && !decl.contains("} |");
960                    let decl = if is_struct_decl {
961                        // Struct → export interface
962                        decl
963                            .replacen("type ", "export interface ", 1)
964                            .replace(" = {", " {")
965                            .trim_end_matches(';')
966                            .to_string()
967                    } else {
968                        // Enum/union → export type
969                        decl
970                            .replacen("type ", "export type ", 1)
971                            .trim_end_matches(';')
972                            .to_string()
973                    };
974                    parts.push(decl);
975                    parts.push(String::new());
976                )+
977            };
978        }
979
980        collect_ts!(
981            // Shared enums
982            SortOrder,
983            TimeRange,
984            LinkType,
985            // Auth
986            AuthRegisterRequest,
987            LoginRequest,
988            AuthTokenResponse,
989            RefreshRequest,
990            LogoutRequest,
991            ChangePasswordRequest,
992            VerifyResponse,
993            UserSettingsResponse,
994            OkResponse,
995            IssueApiKeyResponse,
996            GitCredentialSummary,
997            ListGitCredentialsResponse,
998            CreateGitCredentialRequest,
999            OAuthLinkResponse,
1000            // Sessions
1001            UploadResponse,
1002            SessionSummary,
1003            SessionListResponse,
1004            SessionListQuery,
1005            SessionDetail,
1006            SessionLink,
1007            ParseSource,
1008            ParseCandidate,
1009            ParsePreviewRequest,
1010            ParsePreviewResponse,
1011            ParsePreviewErrorResponse,
1012            LocalReviewBundle,
1013            LocalReviewPrMeta,
1014            LocalReviewCommit,
1015            LocalReviewSession,
1016            // OAuth
1017            oauth::AuthProvidersResponse,
1018            oauth::OAuthProviderInfo,
1019            oauth::LinkedProvider,
1020            // Health
1021            HealthResponse,
1022            CapabilitiesResponse,
1023            ApiError,
1024        );
1025
1026        let content = parts.join("\n");
1027
1028        // Write to file
1029        if let Some(parent) = out_dir.parent() {
1030            std::fs::create_dir_all(parent).ok();
1031        }
1032        let mut file = std::fs::File::create(&out_dir)
1033            .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
1034        file.write_all(content.as_bytes())
1035            .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
1036
1037        println!("Generated TypeScript types at: {}", out_dir.display());
1038    }
1039}