core: type user and auth session timestamps

This commit is contained in:
a.tolmachev
2026-04-12 22:11:43 +00:00
parent 624224f089
commit e03d4da925
14 changed files with 156 additions and 44 deletions
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -25,8 +25,8 @@
}, },
{ {
"ordinal": 4, "ordinal": 4,
"name": "created_at!", "name": "created_at!: OffsetDateTime",
"type_info": "Text" "type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@@ -41,8 +41,8 @@
false, false,
false, false,
false, false,
null false
] ]
}, },
"hash": "0cd29169ff0987c8e32a6b2fb6cec695ab1d68b38da154567aab5f897fe3bd24" "hash": "249082378e98a7f137338cb1289cb6ea0a48c1ae74d614d63023e6dc0687f47c"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -30,8 +30,8 @@
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "created_at!", "name": "created_at!: OffsetDateTime",
"type_info": "Text" "type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@@ -45,8 +45,8 @@
false, false,
true, true,
false, false,
null false
] ]
}, },
"hash": "5c767e8840c289a2afdde4b13d8c6bd13f51b118a1f5395513ad4783b3b2e1fd" "hash": "38911a904c6d284f5fb80cf2ae9f569de7c0ffd3b8ca73e6c01f0842ace4ad25"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -30,8 +30,8 @@
}, },
{ {
"ordinal": 5, "ordinal": 5,
"name": "created_at!", "name": "created_at!: OffsetDateTime",
"type_info": "Text" "type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@@ -45,8 +45,8 @@
false, false,
true, true,
false, false,
null false
] ]
}, },
"hash": "da6cb061da338d4ed4f0c0da995251f5dd1af2b7667e3fa01994e568c962ea77" "hash": "3d647bffe6eaf3b95be589ae2fe006b4aec8f55fa070f9f2ff3859dc7cb96d06"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -40,8 +40,8 @@
}, },
{ {
"ordinal": 7, "ordinal": 7,
"name": "user_created_at!", "name": "user_created_at!: OffsetDateTime",
"type_info": "Text" "type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@@ -57,8 +57,8 @@
false, false,
false, false,
false, false,
null false
] ]
}, },
"hash": "d485e119dd288962586c35b7585c74c6c3518f3d3995ea21a22cc35e092da147" "hash": "ee22d5c528a095d0223aa0cb8a30b6ebce6498d375efc7b7a6227c5de1d24823"
} }
@@ -1,6 +1,6 @@
{ {
"db_name": "PostgreSQL", "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": { "describe": {
"columns": [ "columns": [
{ {
@@ -35,8 +35,8 @@
}, },
{ {
"ordinal": 6, "ordinal": 6,
"name": "created_at!", "name": "created_at!: OffsetDateTime",
"type_info": "Text" "type_info": "Timestamptz"
} }
], ],
"parameters": { "parameters": {
@@ -52,8 +52,8 @@
false, false,
false, false,
false, false,
null false
] ]
}, },
"hash": "88116bca616419bee79adc6aa5d864379a0236889b0a66e7b9b45197565b3787" "hash": "f7b5e65ecddfe52af4d3be98fc69680bf9f0d131785c310936bdf603d74c118d"
} }
Generated
+7
View File
@@ -437,6 +437,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"thiserror", "thiserror",
"time",
] ]
[[package]] [[package]]
@@ -474,6 +475,7 @@ dependencies = [
"serde_json", "serde_json",
"sqlx", "sqlx",
"thiserror", "thiserror",
"time",
"tokio", "tokio",
"uuid", "uuid",
] ]
@@ -588,6 +590,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [ dependencies = [
"powerfmt", "powerfmt",
"serde_core",
] ]
[[package]] [[package]]
@@ -2450,6 +2453,7 @@ dependencies = [
"sha2", "sha2",
"smallvec", "smallvec",
"thiserror", "thiserror",
"time",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tracing", "tracing",
@@ -2533,6 +2537,7 @@ dependencies = [
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror", "thiserror",
"time",
"tracing", "tracing",
"whoami", "whoami",
] ]
@@ -2570,6 +2575,7 @@ dependencies = [
"sqlx-core", "sqlx-core",
"stringprep", "stringprep",
"thiserror", "thiserror",
"time",
"tracing", "tracing",
"whoami", "whoami",
] ]
@@ -2594,6 +2600,7 @@ dependencies = [
"serde_urlencoded", "serde_urlencoded",
"sqlx-core", "sqlx-core",
"thiserror", "thiserror",
"time",
"tracing", "tracing",
"url", "url",
] ]
+2 -2
View File
@@ -40,9 +40,9 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
serde_yaml = "0.9" serde_yaml = "0.9"
sha2 = "0.10" 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" 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"] } tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tonic = { version = "0.14", features = ["transport"] } tonic = { version = "0.14", features = ["transport"] }
tonic-prost = "0.14" tonic-prost = "0.14"
+2 -6
View File
@@ -50,7 +50,7 @@ pub struct AuthenticatedSession {
pub struct SessionCookie { pub struct SessionCookie {
pub session_id: UserSessionId, pub session_id: UserSessionId,
pub value: String, pub value: String,
pub expires_at: String, pub expires_at: OffsetDateTime,
} }
pub fn hash_password(password: &str, pepper: &str) -> Result<String, ApiError> { pub fn hash_password(password: &str, pepper: &str) -> Result<String, ApiError> {
@@ -100,11 +100,7 @@ pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, A
Ok(SessionCookie { Ok(SessionCookie {
session_id, session_id,
value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()), value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()),
expires_at: expires_at expires_at,
.format(&time::format_description::well_known::Rfc3339)
.map_err(|error| {
ApiError::internal(format!("failed to format session expiration: {error}"))
})?,
}) })
} }
+1
View File
@@ -9,6 +9,7 @@ version.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
time.workspace = true
[dev-dependencies] [dev-dependencies]
serde_yaml.workspace = true serde_yaml.workspace = true
+44 -1
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::ids::{InvitationId, PlatformApiKeyId, UserId, WorkspaceId}; use crate::ids::{InvitationId, PlatformApiKeyId, UserId, WorkspaceId};
@@ -48,7 +49,8 @@ pub struct User {
pub email: String, pub email: String,
pub display_name: String, pub display_name: String,
pub status: UserStatus, pub status: UserStatus,
pub created_at: String, #[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -82,3 +84,44 @@ pub struct PlatformApiKey {
pub created_at: String, pub created_at: String,
pub last_used_at: Option<String>, pub last_used_at: Option<String>,
} }
#[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()
);
}
}
+1
View File
@@ -13,6 +13,7 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
sqlx.workspace = true sqlx.workspace = true
thiserror.workspace = true thiserror.workspace = true
time.workspace = true
uuid.workspace = true uuid.workspace = true
[dev-dependencies] [dev-dependencies]
+7 -6
View File
@@ -1,4 +1,5 @@
use super::*; use super::*;
use time::OffsetDateTime;
impl PostgresRegistry { impl PostgresRegistry {
pub async fn upsert_bootstrap_user( pub async fn upsert_bootstrap_user(
@@ -73,7 +74,7 @@ impl PostgresRegistry {
display_name, display_name,
password_hash as \"password_hash!\", password_hash as \"password_hash!\",
status, 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 from users
where email = $1 where email = $1
limit 1", limit 1",
@@ -108,7 +109,7 @@ impl PostgresRegistry {
display_name, display_name,
password_hash as \"password_hash!\", password_hash as \"password_hash!\",
status, 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 from users
where id = $1 where id = $1
limit 1", limit 1",
@@ -148,7 +149,7 @@ impl PostgresRegistry {
email, email,
display_name, display_name,
status, 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(), user_id.as_str(),
email, email,
display_name, display_name,
@@ -196,7 +197,7 @@ impl PostgresRegistry {
user_id: &UserId, user_id: &UserId,
current_workspace_id: Option<&WorkspaceId>, current_workspace_id: Option<&WorkspaceId>,
secret_hash: &str, secret_hash: &str,
expires_at: &str, expires_at: &OffsetDateTime,
) -> Result<(), RegistryError> { ) -> Result<(), RegistryError> {
sqlx::query( sqlx::query(
"insert into user_sessions ( "insert into user_sessions (
@@ -223,7 +224,7 @@ impl PostgresRegistry {
.bind(user_id.as_str()) .bind(user_id.as_str())
.bind(current_workspace_id.map(|id| id.as_str())) .bind(current_workspace_id.map(|id| id.as_str()))
.bind(secret_hash) .bind(secret_hash)
.bind(expires_at) .bind(*expires_at)
.execute(&self.pool) .execute(&self.pool)
.await?; .await?;
@@ -243,7 +244,7 @@ impl PostgresRegistry {
u.email, u.email,
u.display_name, u.display_name,
u.status, 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 from user_sessions s
join users u on u.id = s.user_id join users u on u.id = s.user_id
where s.id = $1 where s.id = $1
+65 -3
View File
@@ -25,6 +25,7 @@ use sqlx::{
}; };
use std::{collections::BTreeMap, env, time::Duration}; use std::{collections::BTreeMap, env, time::Duration};
use thiserror::Error; use thiserror::Error;
use time::OffsetDateTime;
use crate::{ use crate::{
error::RegistryError, error::RegistryError,
@@ -489,7 +490,7 @@ fn build_user(
email: String, email: String,
display_name: String, display_name: String,
status: String, status: String,
created_at: String, created_at: OffsetDateTime,
) -> Result<User, RegistryError> { ) -> Result<User, RegistryError> {
Ok(User { Ok(User {
id: UserId::new(id), id: UserId::new(id),
@@ -1131,12 +1132,13 @@ mod tests {
MembershipRole, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, MembershipRole, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId,
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples,
SecretId, StreamSession, StreamSessionId, StreamStatus, Target, ToolDescription, SecretId, StreamSession, StreamSessionId, StreamStatus, Target, ToolDescription,
ToolExample, User, UserId, Workspace, WorkspaceId, ToolExample, User, UserId, UserSessionId, Workspace, WorkspaceId,
}; };
use crank_mapping::{MappingRule, MappingSet}; use crank_mapping::{MappingRule, MappingSet};
use crank_schema::{Schema, SchemaKind}; use crank_schema::{Schema, SchemaKind};
use serde_json::json; use serde_json::json;
use sqlx::{Executor, PgPool, postgres::PgPoolOptions}; use sqlx::{Executor, PgPool, postgres::PgPoolOptions};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crate::{ use crate::{
PostgresRegistry, RegistryError, PostgresRegistry, RegistryError,
@@ -1156,6 +1158,10 @@ mod tests {
WorkspaceId::new("ws_default") WorkspaceId::new("ws_default")
} }
fn timestamp(value: &str) -> OffsetDateTime {
OffsetDateTime::parse(value, &Rfc3339).unwrap()
}
#[tokio::test] #[tokio::test]
async fn stores_versions_and_published_operations() { async fn stores_versions_and_published_operations() {
let database = TestDatabase::new().await; let database = TestDatabase::new().await;
@@ -1480,7 +1486,7 @@ mod tests {
email: "owner@example.com".to_owned(), email: "owner@example.com".to_owned(),
display_name: "Owner".to_owned(), display_name: "Owner".to_owned(),
status: crank_core::UserStatus::Active, status: crank_core::UserStatus::Active,
created_at: "2026-03-25T11:00:00Z".to_owned(), created_at: timestamp("2026-03-25T11:00:00Z"),
}; };
registry registry
@@ -1590,6 +1596,62 @@ mod tests {
assert_eq!(updated.display_name, "Updated Owner"); assert_eq!(updated.display_name, "Updated Owner");
assert!(has_access); assert!(has_access);
assert!(!lacks_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; database.cleanup().await;
} }
@@ -1,4 +1,5 @@
use super::*; use super::*;
use time::OffsetDateTime;
impl PostgresRegistry { impl PostgresRegistry {
pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, RegistryError> { pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, RegistryError> {
@@ -88,7 +89,7 @@ impl PostgresRegistry {
u.email, u.email,
u.display_name, u.display_name,
u.status, 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 from memberships m
join users u on u.id = m.user_id join users u on u.id = m.user_id
where m.workspace_id = $1 where m.workspace_id = $1