core: type user and auth session timestamps
This commit is contained in:
+5
-5
@@ -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"
|
||||
}
|
||||
+5
-5
@@ -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"
|
||||
}
|
||||
+5
-5
@@ -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"
|
||||
}
|
||||
+5
-5
@@ -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"
|
||||
}
|
||||
+5
-5
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user