diff --git a/.sqlx/query-0cd29169ff0987c8e32a6b2fb6cec695ab1d68b38da154567aab5f897fe3bd24.json b/.sqlx/query-249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c.json similarity index 78% rename from .sqlx/query-0cd29169ff0987c8e32a6b2fb6cec695ab1d68b38da154567aab5f897fe3bd24.json rename to .sqlx/query-249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c.json index c44432a..e929b28 100644 --- a/.sqlx/query-0cd29169ff0987c8e32a6b2fb6cec695ab1d68b38da154567aab5f897fe3bd24.json +++ b/.sqlx/query-249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "update users\n set email = $2,\n display_name = $3\n where id = $1\n returning\n id,\n email,\n display_name,\n status,\n to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\"", + "query": "update users\n set email = $2,\n display_name = $3\n where id = $1\n returning\n id,\n email,\n display_name,\n status,\n created_at as \"created_at!: OffsetDateTime\"", "describe": { "columns": [ { @@ -25,8 +25,8 @@ }, { "ordinal": 4, - "name": "created_at!", - "type_info": "Text" + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" } ], "parameters": { @@ -41,8 +41,8 @@ false, false, false, - null + false ] }, - "hash": "0cd29169ff0987c8e32a6b2fb6cec695ab1d68b38da154567aab5f897fe3bd24" + "hash": "249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c" } diff --git a/.sqlx/query-5c767e8840c289a2afdde4b13d8c6bd13f51b118a1f5395513ad4783b3b2e1fd.json b/.sqlx/query-38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25.json similarity index 69% rename from .sqlx/query-5c767e8840c289a2afdde4b13d8c6bd13f51b118a1f5395513ad4783b3b2e1fd.json rename to .sqlx/query-38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25.json index 5f11bf2..f459be6 100644 --- a/.sqlx/query-5c767e8840c289a2afdde4b13d8c6bd13f51b118a1f5395513ad4783b3b2e1fd.json +++ b/.sqlx/query-38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select\n id,\n email,\n display_name,\n password_hash as \"password_hash!\",\n status,\n to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\"\n from users\n where id = $1\n limit 1", + "query": "select\n id,\n email,\n display_name,\n password_hash as \"password_hash!\",\n status,\n created_at as \"created_at!: OffsetDateTime\"\n from users\n where email = $1\n limit 1", "describe": { "columns": [ { @@ -30,8 +30,8 @@ }, { "ordinal": 5, - "name": "created_at!", - "type_info": "Text" + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" } ], "parameters": { @@ -45,8 +45,8 @@ false, true, false, - null + false ] }, - "hash": "5c767e8840c289a2afdde4b13d8c6bd13f51b118a1f5395513ad4783b3b2e1fd" + "hash": "38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25" } diff --git a/.sqlx/query-da6cb061da338d4ed4f0c0da995251f5dd1af2b7667e3fa01994e568c962ea77.json b/.sqlx/query-3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06.json similarity index 69% rename from .sqlx/query-da6cb061da338d4ed4f0c0da995251f5dd1af2b7667e3fa01994e568c962ea77.json rename to .sqlx/query-3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06.json index 027cd5c..f5faed0 100644 --- a/.sqlx/query-da6cb061da338d4ed4f0c0da995251f5dd1af2b7667e3fa01994e568c962ea77.json +++ b/.sqlx/query-3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select\n id,\n email,\n display_name,\n password_hash as \"password_hash!\",\n status,\n to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\"\n from users\n where email = $1\n limit 1", + "query": "select\n id,\n email,\n display_name,\n password_hash as \"password_hash!\",\n status,\n created_at as \"created_at!: OffsetDateTime\"\n from users\n where id = $1\n limit 1", "describe": { "columns": [ { @@ -30,8 +30,8 @@ }, { "ordinal": 5, - "name": "created_at!", - "type_info": "Text" + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" } ], "parameters": { @@ -45,8 +45,8 @@ false, true, false, - null + false ] }, - "hash": "da6cb061da338d4ed4f0c0da995251f5dd1af2b7667e3fa01994e568c962ea77" + "hash": "3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06" } diff --git a/.sqlx/query-d485e119dd288962586c35b7585c74c6c3518f3d3995ea21a22cc35e092da147.json b/.sqlx/query-ee22d5c528a095d0223aa0cb8a30b6ebce6498d375efc7b7a6227c5de1d24823.json similarity index 75% rename from .sqlx/query-d485e119dd288962586c35b7585c74c6c3518f3d3995ea21a22cc35e092da147.json rename to .sqlx/query-ee22d5c528a095d0223aa0cb8a30b6ebce6498d375efc7b7a6227c5de1d24823.json index 2d870be..59eaf84 100644 --- a/.sqlx/query-d485e119dd288962586c35b7585c74c6c3518f3d3995ea21a22cc35e092da147.json +++ b/.sqlx/query-ee22d5c528a095d0223aa0cb8a30b6ebce6498d375efc7b7a6227c5de1d24823.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select\n m.workspace_id,\n m.user_id,\n m.role,\n to_char(m.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\",\n u.email,\n u.display_name,\n u.status,\n to_char(u.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"user_created_at!\"\n from memberships m\n join users u on u.id = m.user_id\n where m.workspace_id = $1\n order by u.email asc", + "query": "select\n m.workspace_id,\n m.user_id,\n m.role,\n to_char(m.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\",\n u.email,\n u.display_name,\n u.status,\n u.created_at as \"user_created_at!: OffsetDateTime\"\n from memberships m\n join users u on u.id = m.user_id\n where m.workspace_id = $1\n order by u.email asc", "describe": { "columns": [ { @@ -40,8 +40,8 @@ }, { "ordinal": 7, - "name": "user_created_at!", - "type_info": "Text" + "name": "user_created_at!: OffsetDateTime", + "type_info": "Timestamptz" } ], "parameters": { @@ -57,8 +57,8 @@ false, false, false, - null + false ] }, - "hash": "d485e119dd288962586c35b7585c74c6c3518f3d3995ea21a22cc35e092da147" + "hash": "ee22d5c528a095d0223aa0cb8a30b6ebce6498d375efc7b7a6227c5de1d24823" } diff --git a/.sqlx/query-88116bca616419bee79adc6aa5d864379a0236889b0a66e7b9b45197565b3787.json b/.sqlx/query-f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d.json similarity index 67% rename from .sqlx/query-88116bca616419bee79adc6aa5d864379a0236889b0a66e7b9b45197565b3787.json rename to .sqlx/query-f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d.json index 6bb1035..2ea224a 100644 --- a/.sqlx/query-88116bca616419bee79adc6aa5d864379a0236889b0a66e7b9b45197565b3787.json +++ b/.sqlx/query-f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select\n s.id,\n s.user_id,\n s.current_workspace_id,\n u.email,\n u.display_name,\n u.status,\n to_char(u.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\"\n from user_sessions s\n join users u on u.id = s.user_id\n where s.id = $1\n and s.secret_hash = $2\n and s.status = 'active'\n and s.expires_at > now()\n limit 1", + "query": "select\n s.id,\n s.user_id,\n s.current_workspace_id,\n u.email,\n u.display_name,\n u.status,\n u.created_at as \"created_at!: OffsetDateTime\"\n from user_sessions s\n join users u on u.id = s.user_id\n where s.id = $1\n and s.secret_hash = $2\n and s.status = 'active'\n and s.expires_at > now()\n limit 1", "describe": { "columns": [ { @@ -35,8 +35,8 @@ }, { "ordinal": 6, - "name": "created_at!", - "type_info": "Text" + "name": "created_at!: OffsetDateTime", + "type_info": "Timestamptz" } ], "parameters": { @@ -52,8 +52,8 @@ false, false, false, - null + false ] }, - "hash": "88116bca616419bee79adc6aa5d864379a0236889b0a66e7b9b45197565b3787" + "hash": "f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d" } diff --git a/Cargo.lock b/Cargo.lock index 807bdd5..2ad2bd6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -437,6 +437,7 @@ dependencies = [ "serde_json", "serde_yaml", "thiserror", + "time", ] [[package]] @@ -474,6 +475,7 @@ dependencies = [ "serde_json", "sqlx", "thiserror", + "time", "tokio", "uuid", ] @@ -588,6 +590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", + "serde_core", ] [[package]] @@ -2450,6 +2453,7 @@ dependencies = [ "sha2", "smallvec", "thiserror", + "time", "tokio", "tokio-stream", "tracing", @@ -2533,6 +2537,7 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", + "time", "tracing", "whoami", ] @@ -2570,6 +2575,7 @@ dependencies = [ "sqlx-core", "stringprep", "thiserror", + "time", "tracing", "whoami", ] @@ -2594,6 +2600,7 @@ dependencies = [ "serde_urlencoded", "sqlx-core", "thiserror", + "time", "tracing", "url", ] diff --git a/Cargo.toml b/Cargo.toml index f8c953c..5a5d660 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,9 +40,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" sha2 = "0.10" -sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "macros", "json"] } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "macros", "json", "time"] } thiserror = "2" -time = { version = "0.3", features = ["formatting", "parsing"] } +time = { version = "0.3", features = ["formatting", "parsing", "serde"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] } tonic = { version = "0.14", features = ["transport"] } tonic-prost = "0.14" diff --git a/apps/admin-api/src/auth.rs b/apps/admin-api/src/auth.rs index b3f5076..34b0c75 100644 --- a/apps/admin-api/src/auth.rs +++ b/apps/admin-api/src/auth.rs @@ -50,7 +50,7 @@ pub struct AuthenticatedSession { pub struct SessionCookie { pub session_id: UserSessionId, pub value: String, - pub expires_at: String, + pub expires_at: OffsetDateTime, } pub fn hash_password(password: &str, pepper: &str) -> Result { @@ -100,11 +100,7 @@ pub fn create_session_cookie(settings: &AuthSettings) -> Result, } + +#[cfg(test)] +mod tests { + use serde_json::json; + use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + + use super::{User, UserStatus}; + use crate::ids::UserId; + + #[test] + fn user_serializes_created_at_as_rfc3339() { + let user = User { + id: UserId::new("user_01"), + email: "owner@example.com".to_owned(), + display_name: "Owner".to_owned(), + status: UserStatus::Active, + created_at: OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap(), + }; + + let value = serde_json::to_value(&user).unwrap(); + + assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z")); + } + + #[test] + fn user_deserializes_created_at_from_rfc3339() { + let user: User = serde_json::from_value(json!({ + "id": "user_01", + "email": "owner@example.com", + "display_name": "Owner", + "status": "active", + "created_at": "2026-03-25T12:00:00Z" + })) + .unwrap(); + + assert_eq!( + user.created_at, + OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap() + ); + } +} diff --git a/crates/crank-registry/Cargo.toml b/crates/crank-registry/Cargo.toml index 2e94460..5acea86 100644 --- a/crates/crank-registry/Cargo.toml +++ b/crates/crank-registry/Cargo.toml @@ -13,6 +13,7 @@ serde.workspace = true serde_json.workspace = true sqlx.workspace = true thiserror.workspace = true +time.workspace = true uuid.workspace = true [dev-dependencies] diff --git a/crates/crank-registry/src/postgres/auth.rs b/crates/crank-registry/src/postgres/auth.rs index 6130908..d3cee52 100644 --- a/crates/crank-registry/src/postgres/auth.rs +++ b/crates/crank-registry/src/postgres/auth.rs @@ -1,4 +1,5 @@ use super::*; +use time::OffsetDateTime; impl PostgresRegistry { pub async fn upsert_bootstrap_user( @@ -73,7 +74,7 @@ impl PostgresRegistry { display_name, password_hash as \"password_hash!\", status, - to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\" + created_at as \"created_at!: OffsetDateTime\" from users where email = $1 limit 1", @@ -108,7 +109,7 @@ impl PostgresRegistry { display_name, password_hash as \"password_hash!\", status, - to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\" + created_at as \"created_at!: OffsetDateTime\" from users where id = $1 limit 1", @@ -148,7 +149,7 @@ impl PostgresRegistry { email, display_name, status, - to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\"", + created_at as \"created_at!: OffsetDateTime\"", user_id.as_str(), email, display_name, @@ -196,7 +197,7 @@ impl PostgresRegistry { user_id: &UserId, current_workspace_id: Option<&WorkspaceId>, secret_hash: &str, - expires_at: &str, + expires_at: &OffsetDateTime, ) -> Result<(), RegistryError> { sqlx::query( "insert into user_sessions ( @@ -223,7 +224,7 @@ impl PostgresRegistry { .bind(user_id.as_str()) .bind(current_workspace_id.map(|id| id.as_str())) .bind(secret_hash) - .bind(expires_at) + .bind(*expires_at) .execute(&self.pool) .await?; @@ -243,7 +244,7 @@ impl PostgresRegistry { u.email, u.display_name, u.status, - to_char(u.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\" + u.created_at as \"created_at!: OffsetDateTime\" from user_sessions s join users u on u.id = s.user_id where s.id = $1 diff --git a/crates/crank-registry/src/postgres/mod.rs b/crates/crank-registry/src/postgres/mod.rs index d83cff1..0975fdf 100644 --- a/crates/crank-registry/src/postgres/mod.rs +++ b/crates/crank-registry/src/postgres/mod.rs @@ -25,6 +25,7 @@ use sqlx::{ }; use std::{collections::BTreeMap, env, time::Duration}; use thiserror::Error; +use time::OffsetDateTime; use crate::{ error::RegistryError, @@ -489,7 +490,7 @@ fn build_user( email: String, display_name: String, status: String, - created_at: String, + created_at: OffsetDateTime, ) -> Result { Ok(User { id: UserId::new(id), @@ -1131,12 +1132,13 @@ mod tests { MembershipRole, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, SecretId, StreamSession, StreamSessionId, StreamStatus, Target, ToolDescription, - ToolExample, User, UserId, Workspace, WorkspaceId, + ToolExample, User, UserId, UserSessionId, Workspace, WorkspaceId, }; use crank_mapping::{MappingRule, MappingSet}; use crank_schema::{Schema, SchemaKind}; use serde_json::json; use sqlx::{Executor, PgPool, postgres::PgPoolOptions}; + use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use crate::{ PostgresRegistry, RegistryError, @@ -1156,6 +1158,10 @@ mod tests { WorkspaceId::new("ws_default") } + fn timestamp(value: &str) -> OffsetDateTime { + OffsetDateTime::parse(value, &Rfc3339).unwrap() + } + #[tokio::test] async fn stores_versions_and_published_operations() { let database = TestDatabase::new().await; @@ -1480,7 +1486,7 @@ mod tests { email: "owner@example.com".to_owned(), display_name: "Owner".to_owned(), status: crank_core::UserStatus::Active, - created_at: "2026-03-25T11:00:00Z".to_owned(), + created_at: timestamp("2026-03-25T11:00:00Z"), }; registry @@ -1590,6 +1596,62 @@ mod tests { assert_eq!(updated.display_name, "Updated Owner"); assert!(has_access); assert!(!lacks_access); + assert!(updated.created_at.unix_timestamp() > 0); + + database.cleanup().await; + } + + #[tokio::test] + async fn creates_and_loads_user_sessions_with_typed_expiration() { + let database = TestDatabase::new().await; + let registry = database.registry().await; + let workspace = Workspace { + id: WorkspaceId::new("ws_session_01"), + slug: "session".to_owned(), + display_name: "Session Workspace".to_owned(), + status: crank_core::WorkspaceStatus::Active, + settings: json!({}), + created_at: "2026-03-25T12:00:00Z".to_owned(), + updated_at: "2026-03-25T12:00:00Z".to_owned(), + }; + let user_id = registry + .upsert_bootstrap_user("session@example.com", "Owner", "hashed-password") + .await + .unwrap(); + let session_id = UserSessionId::new("sess_01"); + let expires_at = timestamp("2030-04-06T12:05:00Z"); + + registry + .create_workspace(CreateWorkspaceRequest { + workspace: &workspace, + }) + .await + .unwrap(); + registry + .ensure_membership(&workspace.id, &user_id, MembershipRole::Owner) + .await + .unwrap(); + registry + .create_user_session( + &session_id, + &user_id, + Some(&workspace.id), + "secret-hash-01", + &expires_at, + ) + .await + .unwrap(); + + let session = registry + .get_user_session(&session_id, "secret-hash-01") + .await + .unwrap() + .unwrap(); + + assert_eq!(session.session_id, session_id); + assert_eq!(session.current_workspace_id, Some(workspace.id.clone())); + assert_eq!(session.user.id, user_id); + assert!(session.user.created_at.unix_timestamp() > 0); database.cleanup().await; } diff --git a/crates/crank-registry/src/postgres/workspace.rs b/crates/crank-registry/src/postgres/workspace.rs index 07f926d..0e98937 100644 --- a/crates/crank-registry/src/postgres/workspace.rs +++ b/crates/crank-registry/src/postgres/workspace.rs @@ -1,4 +1,5 @@ use super::*; +use time::OffsetDateTime; impl PostgresRegistry { pub async fn list_workspaces(&self) -> Result, RegistryError> { @@ -88,7 +89,7 @@ impl PostgresRegistry { u.email, u.display_name, u.status, - to_char(u.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"user_created_at!\" + u.created_at as \"user_created_at!: OffsetDateTime\" from memberships m join users u on u.id = m.user_id where m.workspace_id = $1