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/// Legacy register (nickname-only). Kept for backward compatibility with CLI.
127#[derive(Debug, Deserialize)]
128#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
129#[cfg_attr(feature = "ts", ts(export))]
130pub struct RegisterRequest {
131    pub nickname: String,
132}
133
134/// Email + password registration.
135#[derive(Debug, Serialize, Deserialize)]
136#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
137#[cfg_attr(feature = "ts", ts(export))]
138pub struct AuthRegisterRequest {
139    pub email: String,
140    pub password: String,
141    pub nickname: String,
142}
143
144/// Email + password login.
145#[derive(Debug, Serialize, Deserialize)]
146#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
147#[cfg_attr(feature = "ts", ts(export))]
148pub struct LoginRequest {
149    pub email: String,
150    pub password: String,
151}
152
153/// Returned on successful login / register / refresh.
154#[derive(Debug, Serialize, Deserialize)]
155#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
156#[cfg_attr(feature = "ts", ts(export))]
157pub struct AuthTokenResponse {
158    pub access_token: String,
159    pub refresh_token: String,
160    pub expires_in: u64,
161    pub user_id: String,
162    pub nickname: String,
163}
164
165/// Refresh token request.
166#[derive(Debug, Serialize, Deserialize)]
167#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
168#[cfg_attr(feature = "ts", ts(export))]
169pub struct RefreshRequest {
170    pub refresh_token: String,
171}
172
173/// Logout request (invalidate refresh token).
174#[derive(Debug, Serialize, Deserialize)]
175#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
176#[cfg_attr(feature = "ts", ts(export))]
177pub struct LogoutRequest {
178    pub refresh_token: String,
179}
180
181/// Change password request.
182#[derive(Debug, Serialize, Deserialize)]
183#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
184#[cfg_attr(feature = "ts", ts(export))]
185pub struct ChangePasswordRequest {
186    pub current_password: String,
187    pub new_password: String,
188}
189
190/// Returned on successful legacy register (nickname-only, CLI-compatible).
191#[derive(Debug, Serialize)]
192#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
193#[cfg_attr(feature = "ts", ts(export))]
194pub struct RegisterResponse {
195    pub user_id: String,
196    pub nickname: String,
197    pub api_key: String,
198}
199
200/// Returned by `POST /api/auth/verify` — confirms token validity.
201#[derive(Debug, Serialize, Deserialize)]
202#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
203#[cfg_attr(feature = "ts", ts(export))]
204pub struct VerifyResponse {
205    pub user_id: String,
206    pub nickname: String,
207}
208
209/// Full user profile returned by `GET /api/auth/me`.
210#[derive(Debug, Serialize, Deserialize)]
211#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
212#[cfg_attr(feature = "ts", ts(export))]
213pub struct UserSettingsResponse {
214    pub user_id: String,
215    pub nickname: String,
216    pub api_key: String,
217    pub created_at: String,
218    pub email: Option<String>,
219    pub avatar_url: Option<String>,
220    /// Linked OAuth providers (generic — replaces github_username)
221    #[serde(default)]
222    pub oauth_providers: Vec<oauth::LinkedProvider>,
223    /// Legacy: GitHub username (populated from oauth_providers for backward compat)
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub github_username: Option<String>,
226}
227
228/// Generic success response for operations that don't return data.
229#[derive(Debug, Serialize, Deserialize)]
230#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
231#[cfg_attr(feature = "ts", ts(export))]
232pub struct OkResponse {
233    pub ok: bool,
234}
235
236/// Response for API key regeneration.
237#[derive(Debug, Serialize, Deserialize)]
238#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
239#[cfg_attr(feature = "ts", ts(export))]
240pub struct RegenerateKeyResponse {
241    pub api_key: String,
242}
243
244/// Response for OAuth link initiation (redirect URL).
245#[derive(Debug, Serialize)]
246#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
247#[cfg_attr(feature = "ts", ts(export))]
248pub struct OAuthLinkResponse {
249    pub url: String,
250}
251
252// ─── Sessions ────────────────────────────────────────────────────────────────
253
254/// Request body for `POST /api/sessions` — upload a recorded session.
255#[derive(Debug, Serialize, Deserialize)]
256pub struct UploadRequest {
257    pub session: Session,
258    #[serde(default, skip_serializing_if = "Option::is_none")]
259    pub body_url: Option<String>,
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub linked_session_ids: Option<Vec<String>>,
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub git_remote: Option<String>,
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub git_branch: Option<String>,
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub git_commit: Option<String>,
268    #[serde(default, skip_serializing_if = "Option::is_none")]
269    pub git_repo_name: Option<String>,
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub pr_number: Option<i64>,
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub pr_url: Option<String>,
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub score_plugin: Option<String>,
276}
277
278/// Returned on successful session upload — contains the new session ID and URL.
279#[derive(Debug, Serialize, Deserialize)]
280#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
281#[cfg_attr(feature = "ts", ts(export))]
282pub struct UploadResponse {
283    pub id: String,
284    pub url: String,
285    #[serde(default)]
286    pub session_score: i64,
287    #[serde(default = "default_score_plugin")]
288    pub score_plugin: String,
289}
290
291/// Flat session summary returned by list/detail endpoints.
292/// This is NOT the full HAIL Session — it's a DB-derived summary.
293#[derive(Debug, Clone, Serialize, Deserialize)]
294#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
295#[cfg_attr(feature = "ts", ts(export))]
296pub struct SessionSummary {
297    pub id: String,
298    pub user_id: Option<String>,
299    pub nickname: Option<String>,
300    pub tool: String,
301    pub agent_provider: Option<String>,
302    pub agent_model: Option<String>,
303    pub title: Option<String>,
304    pub description: Option<String>,
305    /// Comma-separated tags string
306    pub tags: Option<String>,
307    pub created_at: String,
308    pub uploaded_at: String,
309    pub message_count: i64,
310    pub task_count: i64,
311    pub event_count: i64,
312    pub duration_seconds: i64,
313    pub total_input_tokens: i64,
314    pub total_output_tokens: i64,
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub git_remote: Option<String>,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub git_branch: Option<String>,
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub git_commit: Option<String>,
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub git_repo_name: Option<String>,
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub pr_number: Option<i64>,
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub pr_url: Option<String>,
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub working_directory: Option<String>,
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub files_modified: Option<String>,
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub files_read: Option<String>,
333    #[serde(default)]
334    pub has_errors: bool,
335    #[serde(default = "default_max_active_agents")]
336    pub max_active_agents: i64,
337    #[serde(default)]
338    pub session_score: i64,
339    #[serde(default = "default_score_plugin")]
340    pub score_plugin: String,
341}
342
343/// Paginated session listing returned by `GET /api/sessions`.
344#[derive(Debug, Serialize, Deserialize)]
345#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
346#[cfg_attr(feature = "ts", ts(export))]
347pub struct SessionListResponse {
348    pub sessions: Vec<SessionSummary>,
349    pub total: i64,
350    pub page: u32,
351    pub per_page: u32,
352}
353
354/// Query parameters for `GET /api/sessions` — pagination, filtering, sorting.
355#[derive(Debug, Deserialize)]
356#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
357#[cfg_attr(feature = "ts", ts(export))]
358pub struct SessionListQuery {
359    #[serde(default = "default_page")]
360    pub page: u32,
361    #[serde(default = "default_per_page")]
362    pub per_page: u32,
363    pub search: Option<String>,
364    pub tool: Option<String>,
365    /// Sort order (default: recent)
366    pub sort: Option<SortOrder>,
367    /// Time range filter (default: all)
368    pub time_range: Option<TimeRange>,
369}
370
371impl SessionListQuery {
372    /// Returns true when this query targets the anonymous public feed and is safe to edge-cache.
373    pub fn is_public_feed_cacheable(
374        &self,
375        has_auth_header: bool,
376        has_session_cookie: bool,
377    ) -> bool {
378        !has_auth_header
379            && !has_session_cookie
380            && self.search.as_deref().is_none_or(|s| s.trim().is_empty())
381            && self.page <= 10
382            && self.per_page <= 50
383    }
384}
385
386#[cfg(test)]
387mod session_list_query_tests {
388    use super::*;
389
390    fn base_query() -> SessionListQuery {
391        SessionListQuery {
392            page: 1,
393            per_page: 20,
394            search: None,
395            tool: None,
396            sort: None,
397            time_range: None,
398        }
399    }
400
401    #[test]
402    fn public_feed_cacheable_when_anonymous_default_feed() {
403        let q = base_query();
404        assert!(q.is_public_feed_cacheable(false, false));
405    }
406
407    #[test]
408    fn public_feed_not_cacheable_with_auth_or_cookie() {
409        let q = base_query();
410        assert!(!q.is_public_feed_cacheable(true, false));
411        assert!(!q.is_public_feed_cacheable(false, true));
412    }
413
414    #[test]
415    fn public_feed_not_cacheable_for_search_or_large_page() {
416        let mut q = base_query();
417        q.search = Some("hello".into());
418        assert!(!q.is_public_feed_cacheable(false, false));
419
420        let mut q = base_query();
421        q.page = 11;
422        assert!(!q.is_public_feed_cacheable(false, false));
423
424        let mut q = base_query();
425        q.per_page = 100;
426        assert!(!q.is_public_feed_cacheable(false, false));
427    }
428}
429
430fn default_page() -> u32 {
431    1
432}
433fn default_per_page() -> u32 {
434    20
435}
436fn default_max_active_agents() -> i64 {
437    1
438}
439
440fn default_score_plugin() -> String {
441    opensession_core::scoring::DEFAULT_SCORE_PLUGIN.to_string()
442}
443
444/// Single session detail returned by `GET /api/sessions/:id`.
445#[derive(Debug, Serialize, Deserialize)]
446#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
447#[cfg_attr(feature = "ts", ts(export))]
448pub struct SessionDetail {
449    #[serde(flatten)]
450    #[cfg_attr(feature = "ts", ts(flatten))]
451    pub summary: SessionSummary,
452    #[serde(default, skip_serializing_if = "Vec::is_empty")]
453    pub linked_sessions: Vec<SessionLink>,
454}
455
456/// A link between two sessions (e.g., handoff chain).
457#[derive(Debug, Clone, Serialize, Deserialize)]
458#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
459#[cfg_attr(feature = "ts", ts(export))]
460pub struct SessionLink {
461    pub session_id: String,
462    pub linked_session_id: String,
463    pub link_type: LinkType,
464    pub created_at: String,
465}
466
467// ─── Streaming Events ────────────────────────────────────────────────────────
468
469/// Request body for `POST /api/sessions/:id/events` — append live events.
470#[derive(Debug, Deserialize)]
471#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
472#[cfg_attr(feature = "ts", ts(export))]
473pub struct StreamEventsRequest {
474    #[cfg_attr(feature = "ts", ts(type = "any"))]
475    pub agent: Option<Agent>,
476    #[cfg_attr(feature = "ts", ts(type = "any"))]
477    pub context: Option<SessionContext>,
478    #[cfg_attr(feature = "ts", ts(type = "any[]"))]
479    pub events: Vec<Event>,
480}
481
482/// Returned by `POST /api/sessions/:id/events` — number of events accepted.
483#[derive(Debug, Serialize, Deserialize)]
484#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
485#[cfg_attr(feature = "ts", ts(export))]
486pub struct StreamEventsResponse {
487    pub accepted: usize,
488}
489
490// ─── Health ──────────────────────────────────────────────────────────────────
491
492/// Returned by `GET /api/health` — server liveness check.
493#[derive(Debug, Serialize, Deserialize)]
494#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
495#[cfg_attr(feature = "ts", ts(export))]
496pub struct HealthResponse {
497    pub status: String,
498    pub version: String,
499}
500
501/// Returned by `GET /api/capabilities` — runtime feature availability.
502#[derive(Debug, Serialize, Deserialize)]
503#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
504#[cfg_attr(feature = "ts", ts(export))]
505pub struct CapabilitiesResponse {
506    pub auth_enabled: bool,
507    pub upload_enabled: bool,
508}
509
510// ─── Service Error ───────────────────────────────────────────────────────────
511
512/// Framework-agnostic service error.
513///
514/// Each variant maps to an HTTP status code. Both the Axum server and
515/// Cloudflare Worker convert this into the appropriate response type.
516#[derive(Debug, Clone)]
517#[non_exhaustive]
518pub enum ServiceError {
519    BadRequest(String),
520    Unauthorized(String),
521    Forbidden(String),
522    NotFound(String),
523    Conflict(String),
524    Internal(String),
525}
526
527impl ServiceError {
528    /// HTTP status code as a `u16`.
529    pub fn status_code(&self) -> u16 {
530        match self {
531            Self::BadRequest(_) => 400,
532            Self::Unauthorized(_) => 401,
533            Self::Forbidden(_) => 403,
534            Self::NotFound(_) => 404,
535            Self::Conflict(_) => 409,
536            Self::Internal(_) => 500,
537        }
538    }
539
540    /// The error message.
541    pub fn message(&self) -> &str {
542        match self {
543            Self::BadRequest(m)
544            | Self::Unauthorized(m)
545            | Self::Forbidden(m)
546            | Self::NotFound(m)
547            | Self::Conflict(m)
548            | Self::Internal(m) => m,
549        }
550    }
551
552    /// Build a closure that logs a DB/IO error and returns `Internal`.
553    pub fn from_db<E: std::fmt::Display>(context: &str) -> impl FnOnce(E) -> Self + '_ {
554        move |e| Self::Internal(format!("{context}: {e}"))
555    }
556}
557
558impl std::fmt::Display for ServiceError {
559    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
560        write!(f, "{}", self.message())
561    }
562}
563
564impl std::error::Error for ServiceError {}
565
566// ─── Error (legacy JSON shape) ──────────────────────────────────────────────
567
568/// Legacy JSON error shape `{ "error": "..." }` returned by all error responses.
569#[derive(Debug, Serialize)]
570#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
571#[cfg_attr(feature = "ts", ts(export))]
572pub struct ApiError {
573    pub error: String,
574}
575
576impl From<&ServiceError> for ApiError {
577    fn from(e: &ServiceError) -> Self {
578        Self {
579            error: e.message().to_string(),
580        }
581    }
582}
583
584// ─── TypeScript generation ───────────────────────────────────────────────────
585
586#[cfg(all(test, feature = "ts"))]
587mod tests {
588    use super::*;
589    use std::io::Write;
590    use std::path::PathBuf;
591    use ts_rs::TS;
592
593    /// Run with: cargo test -p opensession-api -- export_typescript --nocapture
594    #[test]
595    fn export_typescript() {
596        let out_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
597            .join("../../packages/ui/src/api-types.generated.ts");
598
599        let cfg = ts_rs::Config::new().with_large_int("number");
600        let mut parts: Vec<String> = Vec::new();
601        parts.push("// AUTO-GENERATED by opensession-api — DO NOT EDIT".to_string());
602        parts.push(
603            "// Regenerate with: cargo test -p opensession-api -- export_typescript".to_string(),
604        );
605        parts.push(String::new());
606
607        // Collect all type declarations.
608        // Structs: `type X = {...}` → `export interface X {...}`
609        // Enums/unions: `type X = "a" | "b"` → `export type X = "a" | "b"`
610        macro_rules! collect_ts {
611            ($($t:ty),+ $(,)?) => {
612                $(
613                    let decl = <$t>::decl(&cfg);
614                    let decl = if decl.contains(" = {") {
615                        // Struct → export interface
616                        decl
617                            .replacen("type ", "export interface ", 1)
618                            .replace(" = {", " {")
619                            .trim_end_matches(';')
620                            .to_string()
621                    } else {
622                        // Enum/union → export type
623                        decl
624                            .replacen("type ", "export type ", 1)
625                            .trim_end_matches(';')
626                            .to_string()
627                    };
628                    parts.push(decl);
629                    parts.push(String::new());
630                )+
631            };
632        }
633
634        collect_ts!(
635            // Shared enums
636            SortOrder,
637            TimeRange,
638            LinkType,
639            // Auth
640            RegisterRequest,
641            AuthRegisterRequest,
642            LoginRequest,
643            AuthTokenResponse,
644            RefreshRequest,
645            LogoutRequest,
646            ChangePasswordRequest,
647            RegisterResponse,
648            VerifyResponse,
649            UserSettingsResponse,
650            OkResponse,
651            RegenerateKeyResponse,
652            OAuthLinkResponse,
653            // Sessions
654            UploadResponse,
655            SessionSummary,
656            SessionListResponse,
657            SessionListQuery,
658            SessionDetail,
659            SessionLink,
660            // OAuth
661            oauth::AuthProvidersResponse,
662            oauth::OAuthProviderInfo,
663            oauth::LinkedProvider,
664            // Health
665            HealthResponse,
666            CapabilitiesResponse,
667            ApiError,
668        );
669
670        let content = parts.join("\n");
671
672        // Write to file
673        if let Some(parent) = out_dir.parent() {
674            std::fs::create_dir_all(parent).ok();
675        }
676        let mut file = std::fs::File::create(&out_dir)
677            .unwrap_or_else(|e| panic!("Failed to create {}: {}", out_dir.display(), e));
678        file.write_all(content.as_bytes())
679            .unwrap_or_else(|e| panic!("Failed to write {}: {}", out_dir.display(), e));
680
681        println!("Generated TypeScript types at: {}", out_dir.display());
682    }
683}