Skip to main content

opensession_api/
lib.rs

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