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",
"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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
Generated
+7
View File
@@ -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",
]
+2 -2
View File
@@ -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"
+2 -6
View File
@@ -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<String, ApiError> {
@@ -100,11 +100,7 @@ pub fn create_session_cookie(settings: &AuthSettings) -> Result<SessionCookie, A
Ok(SessionCookie {
session_id,
value: format!("{secret}.{}", expires_at.unix_timestamp_nanos()),
expires_at: expires_at
.format(&time::format_description::well_known::Rfc3339)
.map_err(|error| {
ApiError::internal(format!("failed to format session expiration: {error}"))
})?,
expires_at,
})
}
+1
View File
@@ -9,6 +9,7 @@ version.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
time.workspace = true
[dev-dependencies]
serde_yaml.workspace = true
+44 -1
View File
@@ -1,4 +1,5 @@
use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::ids::{InvitationId, PlatformApiKeyId, UserId, WorkspaceId};
@@ -48,7 +49,8 @@ pub struct User {
pub email: String,
pub display_name: String,
pub status: UserStatus,
pub created_at: String,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -82,3 +84,44 @@ pub struct PlatformApiKey {
pub created_at: 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
sqlx.workspace = true
thiserror.workspace = true
time.workspace = true
uuid.workspace = true
[dev-dependencies]
+7 -6
View File
@@ -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
+65 -3
View File
@@ -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<User, RegistryError> {
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;
}
@@ -1,4 +1,5 @@
use super::*;
use time::OffsetDateTime;
impl PostgresRegistry {
pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, 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