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/// Canonical desktop IPC contract version shared between Rust and TS clients.
370pub const DESKTOP_IPC_CONTRACT_VERSION: &str = "desktop-ipc-v1";
371
372/// Query parameters for `GET /api/sessions` — pagination, filtering, sorting.
373#[derive(Debug, Deserialize)]
374#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
375#[cfg_attr(feature = "ts", ts(export))]
376pub struct SessionListQuery {
377    #[serde(default = "default_page")]
378    pub page: u32,
379    #[serde(default = "default_per_page")]
380    pub per_page: u32,
381    pub search: Option<String>,
382    pub tool: Option<String>,
383    pub git_repo_name: Option<String>,
384    /// Sort order (default: recent)
385    pub sort: Option<SortOrder>,
386    /// Time range filter (default: all)
387    pub time_range: Option<TimeRange>,
388}
389
390/// Desktop session list query payload passed through Tauri invoke.
391#[derive(Debug, Clone, Default, Serialize, Deserialize)]
392#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
393#[cfg_attr(feature = "ts", ts(export))]
394pub struct DesktopSessionListQuery {
395    pub page: Option<String>,
396    pub per_page: Option<String>,
397    pub search: Option<String>,
398    pub tool: Option<String>,
399    pub git_repo_name: Option<String>,
400    pub sort: Option<String>,
401    pub time_range: Option<String>,
402}
403
404/// Repo list response used by server/worker/desktop adapters.
405#[derive(Debug, Clone, Serialize, Deserialize)]
406#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
407#[cfg_attr(feature = "ts", ts(export))]
408pub struct SessionRepoListResponse {
409    pub repos: Vec<String>,
410}
411
412/// Desktop handoff build request payload.
413#[derive(Debug, Clone, Serialize, Deserialize)]
414#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
415#[cfg_attr(feature = "ts", ts(export))]
416pub struct DesktopHandoffBuildRequest {
417    pub session_id: String,
418    pub pin_latest: bool,
419}
420
421/// Desktop handoff build response payload.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
424#[cfg_attr(feature = "ts", ts(export))]
425pub struct DesktopHandoffBuildResponse {
426    pub artifact_uri: String,
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub pinned_alias: Option<String>,
429}
430
431/// Desktop bridge contract/version handshake response.
432#[derive(Debug, Clone, Serialize, Deserialize)]
433#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
434#[cfg_attr(feature = "ts", ts(export))]
435pub struct DesktopContractVersionResponse {
436    pub version: String,
437}
438
439/// Structured desktop bridge error payload.
440#[derive(Debug, Clone, Serialize, Deserialize)]
441#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
442#[cfg_attr(feature = "ts", ts(export))]
443pub struct DesktopApiError {
444    pub code: String,
445    pub status: u16,
446    pub message: String,
447    #[serde(default, skip_serializing_if = "Option::is_none")]
448    #[cfg_attr(feature = "ts", ts(type = "Record<string, any> | null"))]
449    pub details: Option<serde_json::Value>,
450}
451
452impl SessionListQuery {
453    /// Returns true when this query targets the anonymous public feed and is safe to edge-cache.
454    pub fn is_public_feed_cacheable(
455        &self,
456        has_auth_header: bool,
457        has_session_cookie: bool,
458    ) -> bool {
459        !has_auth_header
460            && !has_session_cookie
461            && self.search.as_deref().is_none_or(|s| s.trim().is_empty())
462            && self
463                .git_repo_name
464                .as_deref()
465                .is_none_or(|repo| repo.trim().is_empty())
466            && self.page <= 10
467            && self.per_page <= 50
468    }
469}
470
471#[cfg(test)]
472mod session_list_query_tests {
473    use super::*;
474
475    fn base_query() -> SessionListQuery {
476        SessionListQuery {
477            page: 1,
478            per_page: 20,
479            search: None,
480            tool: None,
481            git_repo_name: None,
482            sort: None,
483            time_range: None,
484        }
485    }
486
487    #[test]
488    fn public_feed_cacheable_when_anonymous_default_feed() {
489        let q = base_query();
490        assert!(q.is_public_feed_cacheable(false, false));
491    }
492
493    #[test]
494    fn public_feed_not_cacheable_with_auth_or_cookie() {
495        let q = base_query();
496        assert!(!q.is_public_feed_cacheable(true, false));
497        assert!(!q.is_public_feed_cacheable(false, true));
498    }
499
500    #[test]
501    fn public_feed_not_cacheable_for_search_or_large_page() {
502        let mut q = base_query();
503        q.search = Some("hello".into());
504        assert!(!q.is_public_feed_cacheable(false, false));
505
506        let mut q = base_query();
507        q.git_repo_name = Some("org/repo".into());
508        assert!(!q.is_public_feed_cacheable(false, false));
509
510        let mut q = base_query();
511        q.page = 11;
512        assert!(!q.is_public_feed_cacheable(false, false));
513
514        let mut q = base_query();
515        q.per_page = 100;
516        assert!(!q.is_public_feed_cacheable(false, false));
517    }
518}
519
520fn default_page() -> u32 {
521    1
522}
523fn default_per_page() -> u32 {
524    20
525}
526fn default_max_active_agents() -> i64 {
527    1
528}
529
530fn default_score_plugin() -> String {
531    opensession_core::scoring::DEFAULT_SCORE_PLUGIN.to_string()
532}
533
534/// Single session detail returned by `GET /api/sessions/:id`.
535#[derive(Debug, Serialize, Deserialize)]
536#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
537#[cfg_attr(feature = "ts", ts(export))]
538pub struct SessionDetail {
539    #[serde(flatten)]
540    #[cfg_attr(feature = "ts", ts(flatten))]
541    pub summary: SessionSummary,
542    #[serde(default, skip_serializing_if = "Vec::is_empty")]
543    pub linked_sessions: Vec<SessionLink>,
544}
545
546/// A link between two sessions (e.g., handoff chain).
547#[derive(Debug, Clone, Serialize, Deserialize)]
548#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
549#[cfg_attr(feature = "ts", ts(export))]
550pub struct SessionLink {
551    pub session_id: String,
552    pub linked_session_id: String,
553    pub link_type: LinkType,
554    pub created_at: String,
555}
556
557/// Source descriptor for parser preview requests.
558#[derive(Debug, Clone, Serialize, Deserialize)]
559#[serde(tag = "kind", rename_all = "snake_case")]
560#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
561#[cfg_attr(feature = "ts", ts(export))]
562pub enum ParseSource {
563    /// Fetch and parse a raw file from a generic Git remote/ref/path source.
564    Git {
565        remote: String,
566        r#ref: String,
567        path: String,
568    },
569    /// Fetch and parse a raw file from a public GitHub repository.
570    Github {
571        owner: String,
572        repo: String,
573        r#ref: String,
574        path: String,
575    },
576    /// Parse inline file content supplied by clients (for local upload preview).
577    Inline {
578        filename: String,
579        /// Base64-encoded UTF-8 text content.
580        content_base64: String,
581    },
582}
583
584/// Candidate parser ranked by detection confidence.
585#[derive(Debug, Clone, Serialize, Deserialize)]
586#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
587#[cfg_attr(feature = "ts", ts(export))]
588pub struct ParseCandidate {
589    pub id: String,
590    pub confidence: u8,
591    pub reason: String,
592}
593
594/// Request body for `POST /api/parse/preview`.
595#[derive(Debug, Clone, Serialize, Deserialize)]
596#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
597#[cfg_attr(feature = "ts", ts(export))]
598pub struct ParsePreviewRequest {
599    pub source: ParseSource,
600    #[serde(default, skip_serializing_if = "Option::is_none")]
601    pub parser_hint: Option<String>,
602}
603
604/// Response body for `POST /api/parse/preview`.
605#[derive(Debug, Clone, Serialize, Deserialize)]
606#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
607#[cfg_attr(feature = "ts", ts(export))]
608pub struct ParsePreviewResponse {
609    pub parser_used: String,
610    #[serde(default)]
611    pub parser_candidates: Vec<ParseCandidate>,
612    #[cfg_attr(feature = "ts", ts(type = "any"))]
613    pub session: Session,
614    pub source: ParseSource,
615    #[serde(default)]
616    pub warnings: Vec<String>,
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub native_adapter: Option<String>,
619}
620
621/// Structured parser preview error response.
622#[derive(Debug, Clone, Serialize, Deserialize)]
623#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
624#[cfg_attr(feature = "ts", ts(export))]
625pub struct ParsePreviewErrorResponse {
626    pub code: String,
627    pub message: String,
628    #[serde(default, skip_serializing_if = "Vec::is_empty")]
629    pub parser_candidates: Vec<ParseCandidate>,
630}
631
632/// Local review bundle generated from a PR range.
633#[derive(Debug, Clone, Serialize, Deserialize)]
634#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
635#[cfg_attr(feature = "ts", ts(export))]
636pub struct LocalReviewBundle {
637    pub review_id: String,
638    pub generated_at: String,
639    pub pr: LocalReviewPrMeta,
640    #[serde(default)]
641    pub commits: Vec<LocalReviewCommit>,
642    #[serde(default)]
643    pub sessions: Vec<LocalReviewSession>,
644}
645
646/// PR metadata for a local review bundle.
647#[derive(Debug, Clone, Serialize, Deserialize)]
648#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
649#[cfg_attr(feature = "ts", ts(export))]
650pub struct LocalReviewPrMeta {
651    pub url: String,
652    pub owner: String,
653    pub repo: String,
654    pub number: u64,
655    pub remote: String,
656    pub base_sha: String,
657    pub head_sha: String,
658}
659
660/// Commit row in a local review bundle.
661#[derive(Debug, Clone, Serialize, Deserialize)]
662#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
663#[cfg_attr(feature = "ts", ts(export))]
664pub struct LocalReviewCommit {
665    pub sha: String,
666    pub title: String,
667    pub author_name: String,
668    pub author_email: String,
669    pub authored_at: String,
670    #[serde(default)]
671    pub session_ids: Vec<String>,
672}
673
674/// Session payload mapped into a local review bundle.
675#[derive(Debug, Clone, Serialize, Deserialize)]
676#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
677#[cfg_attr(feature = "ts", ts(export))]
678pub struct LocalReviewSession {
679    pub session_id: String,
680    pub ledger_ref: String,
681    pub hail_path: String,
682    #[serde(default)]
683    pub commit_shas: Vec<String>,
684    #[cfg_attr(feature = "ts", ts(type = "any"))]
685    pub session: Session,
686}
687
688// ─── Streaming Events ────────────────────────────────────────────────────────
689
690/// Request body for `POST /api/sessions/:id/events` — append live events.
691#[derive(Debug, Deserialize)]
692#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
693#[cfg_attr(feature = "ts", ts(export))]
694pub struct StreamEventsRequest {
695    #[cfg_attr(feature = "ts", ts(type = "any"))]
696    pub agent: Option<Agent>,
697    #[cfg_attr(feature = "ts", ts(type = "any"))]
698    pub context: Option<SessionContext>,
699    #[cfg_attr(feature = "ts", ts(type = "any[]"))]
700    pub events: Vec<Event>,
701}
702
703/// Returned by `POST /api/sessions/:id/events` — number of events accepted.
704#[derive(Debug, Serialize, Deserialize)]
705#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
706#[cfg_attr(feature = "ts", ts(export))]
707pub struct StreamEventsResponse {
708    pub accepted: usize,
709}
710
711// ─── Health ──────────────────────────────────────────────────────────────────
712
713/// Returned by `GET /api/health` — server liveness check.
714#[derive(Debug, Serialize, Deserialize)]
715#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
716#[cfg_attr(feature = "ts", ts(export))]
717pub struct HealthResponse {
718    pub status: String,
719    pub version: String,
720}
721
722/// Returned by `GET /api/capabilities` — runtime feature availability.
723#[derive(Debug, Serialize, Deserialize)]
724#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
725#[cfg_attr(feature = "ts", ts(export))]
726pub struct CapabilitiesResponse {
727    pub auth_enabled: bool,
728    pub parse_preview_enabled: bool,
729    pub register_targets: Vec<String>,
730    pub share_modes: Vec<String>,
731}
732
733pub const DEFAULT_REGISTER_TARGETS: &[&str] = &["local", "git"];
734pub const DEFAULT_SHARE_MODES: &[&str] = &["web", "git", "json"];
735
736impl CapabilitiesResponse {
737    /// Build runtime capability payload with shared defaults.
738    pub fn for_runtime(auth_enabled: bool, parse_preview_enabled: bool) -> Self {
739        Self {
740            auth_enabled,
741            parse_preview_enabled,
742            register_targets: DEFAULT_REGISTER_TARGETS
743                .iter()
744                .map(|target| (*target).to_string())
745                .collect(),
746            share_modes: DEFAULT_SHARE_MODES
747                .iter()
748                .map(|mode| (*mode).to_string())
749                .collect(),
750        }
751    }
752}
753
754// ─── Service Error ───────────────────────────────────────────────────────────
755
756/// Framework-agnostic service error.
757///
758/// Each variant maps to an HTTP status code. Both the Axum server and
759/// Cloudflare Worker convert this into the appropriate response type.
760#[derive(Debug, Clone)]
761#[non_exhaustive]
762pub enum ServiceError {
763    BadRequest(String),
764    Unauthorized(String),
765    Forbidden(String),
766    NotFound(String),
767    Conflict(String),
768    Internal(String),
769}
770
771impl ServiceError {
772    /// HTTP status code as a `u16`.
773    pub fn status_code(&self) -> u16 {
774        match self {
775            Self::BadRequest(_) => 400,
776            Self::Unauthorized(_) => 401,
777            Self::Forbidden(_) => 403,
778            Self::NotFound(_) => 404,
779            Self::Conflict(_) => 409,
780            Self::Internal(_) => 500,
781        }
782    }
783
784    /// Stable machine-readable error code.
785    pub fn code(&self) -> &'static str {
786        match self {
787            Self::BadRequest(_) => "bad_request",
788            Self::Unauthorized(_) => "unauthorized",
789            Self::Forbidden(_) => "forbidden",
790            Self::NotFound(_) => "not_found",
791            Self::Conflict(_) => "conflict",
792            Self::Internal(_) => "internal",
793        }
794    }
795
796    /// The error message.
797    pub fn message(&self) -> &str {
798        match self {
799            Self::BadRequest(m)
800            | Self::Unauthorized(m)
801            | Self::Forbidden(m)
802            | Self::NotFound(m)
803            | Self::Conflict(m)
804            | Self::Internal(m) => m,
805        }
806    }
807
808    /// Build a closure that logs a DB/IO error and returns `Internal`.
809    pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
810        move |e| Self::Internal(format!("{context}: {e}"))
811    }
812}
813
814impl std::fmt::Display for ServiceError {
815    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
816        write!(f, "{}", self.message())
817    }
818}
819
820impl std::error::Error for ServiceError {}
821
822// ─── Error ───────────────────────────────────────────────────────────────────
823
824/// API error payload.
825#[derive(Debug, Serialize)]
826#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
827#[cfg_attr(feature = "ts", ts(export))]
828pub struct ApiError {
829    pub code: String,
830    pub message: String,
831}
832
833impl From<&ServiceError> for ApiError {
834    fn from(e: &ServiceError) -> Self {
835        Self {
836            code: e.code().to_string(),
837            message: e.message().to_string(),
838        }
839    }
840}
841
842// ─── TypeScript generation ───────────────────────────────────────────────────
843
844#[cfg(test)]
845mod schema_tests {
846    use super::*;
847
848    #[test]
849    fn parse_preview_request_round_trip_git() {
850        let req = ParsePreviewRequest {
851            source: ParseSource::Git {
852                remote: "https://github.com/hwisu/opensession".to_string(),
853                r#ref: "main".to_string(),
854                path: "sessions/demo.hail.jsonl".to_string(),
855            },
856            parser_hint: Some("hail".to_string()),
857        };
858
859        let json = serde_json::to_string(&req).expect("request should serialize");
860        let decoded: ParsePreviewRequest =
861            serde_json::from_str(&json).expect("request should deserialize");
862
863        match decoded.source {
864            ParseSource::Git {
865                remote,
866                r#ref,
867                path,
868            } => {
869                assert_eq!(remote, "https://github.com/hwisu/opensession");
870                assert_eq!(r#ref, "main");
871                assert_eq!(path, "sessions/demo.hail.jsonl");
872            }
873            _ => panic!("expected git parse source"),
874        }
875        assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
876    }
877
878    #[test]
879    fn parse_preview_request_round_trip_github_compat() {
880        let req = ParsePreviewRequest {
881            source: ParseSource::Github {
882                owner: "hwisu".to_string(),
883                repo: "opensession".to_string(),
884                r#ref: "main".to_string(),
885                path: "sessions/demo.hail.jsonl".to_string(),
886            },
887            parser_hint: Some("hail".to_string()),
888        };
889
890        let json = serde_json::to_string(&req).expect("request should serialize");
891        let decoded: ParsePreviewRequest =
892            serde_json::from_str(&json).expect("request should deserialize");
893
894        match decoded.source {
895            ParseSource::Github {
896                owner,
897                repo,
898                r#ref,
899                path,
900            } => {
901                assert_eq!(owner, "hwisu");
902                assert_eq!(repo, "opensession");
903                assert_eq!(r#ref, "main");
904                assert_eq!(path, "sessions/demo.hail.jsonl");
905            }
906            _ => panic!("expected github parse source"),
907        }
908        assert_eq!(decoded.parser_hint.as_deref(), Some("hail"));
909    }
910
911    #[test]
912    fn parse_preview_error_response_round_trip_with_candidates() {
913        let payload = ParsePreviewErrorResponse {
914            code: "parser_selection_required".to_string(),
915            message: "choose parser".to_string(),
916            parser_candidates: vec![ParseCandidate {
917                id: "codex".to_string(),
918                confidence: 89,
919                reason: "event markers".to_string(),
920            }],
921        };
922
923        let json = serde_json::to_string(&payload).expect("error payload should serialize");
924        let decoded: ParsePreviewErrorResponse =
925            serde_json::from_str(&json).expect("error payload should deserialize");
926
927        assert_eq!(decoded.code, "parser_selection_required");
928        assert_eq!(decoded.parser_candidates.len(), 1);
929        assert_eq!(decoded.parser_candidates[0].id, "codex");
930    }
931
932    #[test]
933    fn local_review_bundle_round_trip() {
934        let mut sample_session = Session::new(
935            "s-review-1".to_string(),
936            Agent {
937                provider: "openai".to_string(),
938                model: "gpt-5".to_string(),
939                tool: "codex".to_string(),
940                tool_version: None,
941            },
942        );
943        sample_session.recompute_stats();
944
945        let payload = LocalReviewBundle {
946            review_id: "gh-org-repo-pr1-abc1234".to_string(),
947            generated_at: "2026-02-24T00:00:00Z".to_string(),
948            pr: LocalReviewPrMeta {
949                url: "https://github.com/org/repo/pull/1".to_string(),
950                owner: "org".to_string(),
951                repo: "repo".to_string(),
952                number: 1,
953                remote: "origin".to_string(),
954                base_sha: "a".repeat(40),
955                head_sha: "b".repeat(40),
956            },
957            commits: vec![LocalReviewCommit {
958                sha: "c".repeat(40),
959                title: "feat: add review flow".to_string(),
960                author_name: "Alice".to_string(),
961                author_email: "alice@example.com".to_string(),
962                authored_at: "2026-02-24T00:00:00Z".to_string(),
963                session_ids: vec!["s-review-1".to_string()],
964            }],
965            sessions: vec![LocalReviewSession {
966                session_id: "s-review-1".to_string(),
967                ledger_ref: "refs/remotes/origin/opensession/branches/bWFpbg".to_string(),
968                hail_path: "v1/se/s-review-1.hail.jsonl".to_string(),
969                commit_shas: vec!["c".repeat(40)],
970                session: sample_session,
971            }],
972        };
973
974        let json = serde_json::to_string(&payload).expect("review bundle should serialize");
975        let decoded: LocalReviewBundle =
976            serde_json::from_str(&json).expect("review bundle should deserialize");
977
978        assert_eq!(decoded.review_id, "gh-org-repo-pr1-abc1234");
979        assert_eq!(decoded.pr.number, 1);
980        assert_eq!(decoded.commits.len(), 1);
981        assert_eq!(decoded.sessions.len(), 1);
982        assert_eq!(decoded.sessions[0].session_id, "s-review-1");
983    }
984
985    #[test]
986    fn capabilities_response_round_trip_includes_new_fields() {
987        let caps = CapabilitiesResponse::for_runtime(true, true);
988
989        let json = serde_json::to_string(&caps).expect("capabilities should serialize");
990        let decoded: CapabilitiesResponse =
991            serde_json::from_str(&json).expect("capabilities should deserialize");
992
993        assert!(decoded.auth_enabled);
994        assert!(decoded.parse_preview_enabled);
995        assert_eq!(decoded.register_targets, vec!["local", "git"]);
996        assert_eq!(decoded.share_modes, vec!["web", "git", "json"]);
997    }
998
999    #[test]
1000    fn capabilities_defaults_are_stable() {
1001        assert_eq!(DEFAULT_REGISTER_TARGETS, &["local", "git"]);
1002        assert_eq!(DEFAULT_SHARE_MODES, &["web", "git", "json"]);
1003    }
1004}
1005
1006#[cfg(all(test, feature = "ts"))]
1007mod tests {
1008    use super::*;
1009    use std::io::Write;
1010    use std::path::PathBuf;
1011    use ts_rs::TS;
1012
1013    /// Run with: cargo test -p opensession-api -- export_typescript --nocapture
1014    #[test]
1015    fn export_typescript() {
1016        let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1017            .join("../../packages/ui/src/api-types.generated.ts");
1018
1019        let cfg = ts_rs::Config::new().with_large_int("number");
1020        let mut parts: Vec<String> = Vec::new();
1021        parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
1022        parts.push(
1023            "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
1024        );
1025        parts.push(String::new());
1026
1027        // Collect all type declarations.
1028        // Structs: `type X = {...}` → `export interface X {...}`
1029        // Enums/unions: `type X = "a" | "b"` → `export type X = "a" | "b"`
1030        macro_rules! collect_ts {
1031            ($($t:ty),+ $(,)?) => {
1032                $(
1033                    let decl = <$t>::decl(&cfg);
1034                    let is_struct_decl = decl.contains(" = {") && !decl.contains("} |");
1035                    let decl = if is_struct_decl {
1036                        // Struct → export interface
1037                        decl
1038                            .replacen("type ", "export interface ", 1)
1039                            .replace(" = {", " {")
1040                            .trim_end_matches(';')
1041                            .to_string()
1042                    } else {
1043                        // Enum/union → export type
1044                        decl
1045                            .replacen("type ", "export type ", 1)
1046                            .trim_end_matches(';')
1047                            .to_string()
1048                    };
1049                    parts.push(decl);
1050                    parts.push(String::new());
1051                )+
1052            };
1053        }
1054
1055        collect_ts!(
1056            // Shared enums
1057            SortOrder,
1058            TimeRange,
1059            LinkType,
1060            // Auth
1061            AuthRegisterRequest,
1062            LoginRequest,
1063            AuthTokenResponse,
1064            RefreshRequest,
1065            LogoutRequest,
1066            ChangePasswordRequest,
1067            VerifyResponse,
1068            UserSettingsResponse,
1069            OkResponse,
1070            IssueApiKeyResponse,
1071            GitCredentialSummary,
1072            ListGitCredentialsResponse,
1073            CreateGitCredentialRequest,
1074            OAuthLinkResponse,
1075            // Sessions
1076            UploadResponse,
1077            SessionSummary,
1078            SessionListResponse,
1079            SessionListQuery,
1080            DesktopSessionListQuery,
1081            SessionRepoListResponse,
1082            DesktopHandoffBuildRequest,
1083            DesktopHandoffBuildResponse,
1084            DesktopContractVersionResponse,
1085            DesktopApiError,
1086            SessionDetail,
1087            SessionLink,
1088            ParseSource,
1089            ParseCandidate,
1090            ParsePreviewRequest,
1091            ParsePreviewResponse,
1092            ParsePreviewErrorResponse,
1093            LocalReviewBundle,
1094            LocalReviewPrMeta,
1095            LocalReviewCommit,
1096            LocalReviewSession,
1097            // OAuth
1098            oauth::AuthProvidersResponse,
1099            oauth::OAuthProviderInfo,
1100            oauth::LinkedProvider,
1101            // Health
1102            HealthResponse,
1103            CapabilitiesResponse,
1104            ApiError,
1105        );
1106
1107        let content = parts.join("\n");
1108
1109        // Write to file
1110        if let Some(parent) = out_dir.parent() {
1111            std::fs::create_dir_all(parent).ok();
1112        }
1113        let mut file = std::fs::File::create(&out_dir)
1114            .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
1115        file.write_all(content.as_bytes())
1116            .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
1117
1118        println!("Generated TypeScript types at: {}", out_dir.display());
1119    }
1120}