From c736b4f5a8b4521e62c7e9a626c0755c28cbabda Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 13 Apr 2026 05:59:11 +0000 Subject: [PATCH] core: type workspace and secret timestamps --- ...040415a5f6eef589e9a264b9b1fd687353b7.json} | 16 ++--- ...ea62bdfae0ba66100589ead3f986244b4cbb.json} | 16 ++--- ...7f9c01c9e5cbc1eea5511005d572a0776edc.json} | 12 ++-- ...6ad2ffe665efc5f11252136bee340a3a4a89.json} | 22 +++---- ...9ce42857433dd11580659481c562f9d9ab3f.json} | 16 ++--- ...a2785c0a612ff739e341e28d0831b7170f6b.json} | 22 +++---- ...a2f07bc89aec46cdbe1e856700fa57674f41.json} | 16 ++--- ...5022c7ddac2ffe2a1969b2c6a2ed72c3730a.json} | 10 +-- apps/admin-api/src/service.rs | 35 +++++------ apps/mcp-server/src/app.rs | 6 +- crates/crank-core/src/access.rs | 9 ++- crates/crank-core/src/secret.rs | 62 +++++++++++++++++-- crates/crank-core/src/workspace.rs | 57 ++++++++++++++++- crates/crank-registry/src/model.rs | 6 +- crates/crank-registry/src/postgres/mod.rs | 20 +++--- crates/crank-registry/src/postgres/secret.rs | 26 ++++---- .../crank-registry/src/postgres/workspace.rs | 31 +++++----- 17 files changed, 243 insertions(+), 139 deletions(-) rename .sqlx/{query-925b63209407fe86b78bf5458b1a17253db129a7a13a6c7bc4db4f439244009c.json => query-56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7.json} (63%) rename .sqlx/{query-4206303b16bfbe2abc436d00ad5da3358246a78c0bb283321c9441e7eea68467.json => query-92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb.json} (60%) rename .sqlx/{query-ee22d5c528a095d0223aa0cb8a30b6ebce6498d375efc7b7a6227c5de1d24823.json => query-c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc.json} (59%) rename .sqlx/{query-4fcd20a68487da32614c2d7ad6152e35c0251e45ed744b3c4883e7ab8dd64747.json => query-d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89.json} (62%) rename .sqlx/{query-0701c93547a515170803136ed093c06df78d08e83c9f2eee241204820fd20f5c.json => query-d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f.json} (62%) rename .sqlx/{query-4b6bd8192eea43df8d50504e86e9517becbccf7462aa3008c356f7ef93cd73b4.json => query-dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b.json} (61%) rename .sqlx/{query-b69d55c4b0352cac007496828e2d08369749bac52d2076c3ba679c4aaa466fc9.json => query-e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41.json} (62%) rename .sqlx/{query-d50ada14acca676613ea3312e92c73a85fd24606e88c9b07fc33535690b540d1.json => query-fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a.json} (63%) diff --git a/.sqlx/query-925b63209407fe86b78bf5458b1a17253db129a7a13a6c7bc4db4f439244009c.json b/.sqlx/query-56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7.json similarity index 63% rename from .sqlx/query-925b63209407fe86b78bf5458b1a17253db129a7a13a6c7bc4db4f439244009c.json rename to .sqlx/query-56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7.json index 480271e..7317c3c 100644 --- a/.sqlx/query-925b63209407fe86b78bf5458b1a17253db129a7a13a6c7bc4db4f439244009c.json +++ b/.sqlx/query-56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select\n id,\n slug,\n display_name,\n status,\n settings_json,\n to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\",\n to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"updated_at!\"\n from workspaces\n where id = $1", + "query": "select\n id,\n slug,\n display_name,\n status,\n settings_json,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\"\n from workspaces\n where id = $1", "describe": { "columns": [ { @@ -30,13 +30,13 @@ }, { "ordinal": 5, - "name": "created_at!", - "type_info": "Text" + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" }, { "ordinal": 6, - "name": "updated_at!", - "type_info": "Text" + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" } ], "parameters": { @@ -50,9 +50,9 @@ false, false, false, - null, - null + false, + false ] }, - "hash": "925b63209407fe86b78bf5458b1a17253db129a7a13a6c7bc4db4f439244009c" + "hash": "56cf7fc3b89517095c52a30c091a040415a5f6eef589e9a264b9b1fd687353b7" } diff --git a/.sqlx/query-4206303b16bfbe2abc436d00ad5da3358246a78c0bb283321c9441e7eea68467.json b/.sqlx/query-92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb.json similarity index 60% rename from .sqlx/query-4206303b16bfbe2abc436d00ad5da3358246a78c0bb283321c9441e7eea68467.json rename to .sqlx/query-92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb.json index 316c170..415db6d 100644 --- a/.sqlx/query-4206303b16bfbe2abc436d00ad5da3358246a78c0bb283321c9441e7eea68467.json +++ b/.sqlx/query-92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select\n w.id,\n w.slug,\n w.display_name,\n w.status,\n w.settings_json,\n to_char(w.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\",\n to_char(w.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"updated_at!\",\n m.role\n from memberships m\n join workspaces w on w.id = m.workspace_id\n where m.user_id = $1\n order by w.slug asc", + "query": "select\n w.id,\n w.slug,\n w.display_name,\n w.status,\n w.settings_json,\n w.created_at as \"created_at!: time::OffsetDateTime\",\n w.updated_at as \"updated_at!: time::OffsetDateTime\",\n m.role\n from memberships m\n join workspaces w on w.id = m.workspace_id\n where m.user_id = $1\n order by w.slug asc", "describe": { "columns": [ { @@ -30,13 +30,13 @@ }, { "ordinal": 5, - "name": "created_at!", - "type_info": "Text" + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" }, { "ordinal": 6, - "name": "updated_at!", - "type_info": "Text" + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" }, { "ordinal": 7, @@ -55,10 +55,10 @@ false, false, false, - null, - null, + false, + false, false ] }, - "hash": "4206303b16bfbe2abc436d00ad5da3358246a78c0bb283321c9441e7eea68467" + "hash": "92bd2268efce8ba515b1d502287dea62bdfae0ba66100589ead3f986244b4cbb" } diff --git a/.sqlx/query-ee22d5c528a095d0223aa0cb8a30b6ebce6498d375efc7b7a6227c5de1d24823.json b/.sqlx/query-c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc.json similarity index 59% rename from .sqlx/query-ee22d5c528a095d0223aa0cb8a30b6ebce6498d375efc7b7a6227c5de1d24823.json rename to .sqlx/query-c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc.json index 59eaf84..f88bd2c 100644 --- a/.sqlx/query-ee22d5c528a095d0223aa0cb8a30b6ebce6498d375efc7b7a6227c5de1d24823.json +++ b/.sqlx/query-c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc.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 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", + "query": "select\n m.workspace_id,\n m.user_id,\n m.role,\n m.created_at as \"created_at!: time::OffsetDateTime\",\n u.email,\n u.display_name,\n u.status,\n u.created_at as \"user_created_at!: time::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": [ { @@ -20,8 +20,8 @@ }, { "ordinal": 3, - "name": "created_at!", - "type_info": "Text" + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" }, { "ordinal": 4, @@ -40,7 +40,7 @@ }, { "ordinal": 7, - "name": "user_created_at!: OffsetDateTime", + "name": "user_created_at!: time::OffsetDateTime", "type_info": "Timestamptz" } ], @@ -53,12 +53,12 @@ false, false, false, - null, + false, false, false, false, false ] }, - "hash": "ee22d5c528a095d0223aa0cb8a30b6ebce6498d375efc7b7a6227c5de1d24823" + "hash": "c3002576245c677598f401b5db517f9c01c9e5cbc1eea5511005d572a0776edc" } diff --git a/.sqlx/query-4fcd20a68487da32614c2d7ad6152e35c0251e45ed744b3c4883e7ab8dd64747.json b/.sqlx/query-d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89.json similarity index 62% rename from .sqlx/query-4fcd20a68487da32614c2d7ad6152e35c0251e45ed744b3c4883e7ab8dd64747.json rename to .sqlx/query-d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89.json index 8d0ff27..65e4484 100644 --- a/.sqlx/query-4fcd20a68487da32614c2d7ad6152e35c0251e45ed744b3c4883e7ab8dd64747.json +++ b/.sqlx/query-d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select\n id,\n workspace_id,\n name,\n kind,\n status,\n current_version,\n to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\",\n to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"updated_at!\",\n to_char(last_used_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_used_at\n from secrets\n where workspace_id = $1 and id = $2", + "query": "select\n id,\n workspace_id,\n name,\n kind,\n status,\n current_version,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\",\n last_used_at as \"last_used_at: time::OffsetDateTime\"\n from secrets\n where workspace_id = $1 and id = $2", "describe": { "columns": [ { @@ -35,18 +35,18 @@ }, { "ordinal": 6, - "name": "created_at!", - "type_info": "Text" + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" }, { "ordinal": 7, - "name": "updated_at!", - "type_info": "Text" + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" }, { "ordinal": 8, - "name": "last_used_at", - "type_info": "Text" + "name": "last_used_at: time::OffsetDateTime", + "type_info": "Timestamptz" } ], "parameters": { @@ -62,10 +62,10 @@ false, false, false, - null, - null, - null + false, + false, + true ] }, - "hash": "4fcd20a68487da32614c2d7ad6152e35c0251e45ed744b3c4883e7ab8dd64747" + "hash": "d5b882fd05f2db2e766207064bd46ad2ffe665efc5f11252136bee340a3a4a89" } diff --git a/.sqlx/query-0701c93547a515170803136ed093c06df78d08e83c9f2eee241204820fd20f5c.json b/.sqlx/query-d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f.json similarity index 62% rename from .sqlx/query-0701c93547a515170803136ed093c06df78d08e83c9f2eee241204820fd20f5c.json rename to .sqlx/query-d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f.json index b6b95cc..3893be5 100644 --- a/.sqlx/query-0701c93547a515170803136ed093c06df78d08e83c9f2eee241204820fd20f5c.json +++ b/.sqlx/query-d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select\n id,\n slug,\n display_name,\n status,\n settings_json,\n to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\",\n to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"updated_at!\"\n from workspaces\n order by slug asc", + "query": "select\n id,\n slug,\n display_name,\n status,\n settings_json,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\"\n from workspaces\n order by slug asc", "describe": { "columns": [ { @@ -30,13 +30,13 @@ }, { "ordinal": 5, - "name": "created_at!", - "type_info": "Text" + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" }, { "ordinal": 6, - "name": "updated_at!", - "type_info": "Text" + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" } ], "parameters": { @@ -48,9 +48,9 @@ false, false, false, - null, - null + false, + false ] }, - "hash": "0701c93547a515170803136ed093c06df78d08e83c9f2eee241204820fd20f5c" + "hash": "d6cd575c05dabaa09177985375c59ce42857433dd11580659481c562f9d9ab3f" } diff --git a/.sqlx/query-4b6bd8192eea43df8d50504e86e9517becbccf7462aa3008c356f7ef93cd73b4.json b/.sqlx/query-dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b.json similarity index 61% rename from .sqlx/query-4b6bd8192eea43df8d50504e86e9517becbccf7462aa3008c356f7ef93cd73b4.json rename to .sqlx/query-dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b.json index e9d2c59..e27ad9f 100644 --- a/.sqlx/query-4b6bd8192eea43df8d50504e86e9517becbccf7462aa3008c356f7ef93cd73b4.json +++ b/.sqlx/query-dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select\n id,\n workspace_id,\n name,\n kind,\n status,\n current_version,\n to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\",\n to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"updated_at!\",\n to_char(last_used_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_used_at\n from secrets\n where workspace_id = $1\n order by name asc", + "query": "select\n id,\n workspace_id,\n name,\n kind,\n status,\n current_version,\n created_at as \"created_at!: time::OffsetDateTime\",\n updated_at as \"updated_at!: time::OffsetDateTime\",\n last_used_at as \"last_used_at: time::OffsetDateTime\"\n from secrets\n where workspace_id = $1\n order by name asc", "describe": { "columns": [ { @@ -35,18 +35,18 @@ }, { "ordinal": 6, - "name": "created_at!", - "type_info": "Text" + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" }, { "ordinal": 7, - "name": "updated_at!", - "type_info": "Text" + "name": "updated_at!: time::OffsetDateTime", + "type_info": "Timestamptz" }, { "ordinal": 8, - "name": "last_used_at", - "type_info": "Text" + "name": "last_used_at: time::OffsetDateTime", + "type_info": "Timestamptz" } ], "parameters": { @@ -61,10 +61,10 @@ false, false, false, - null, - null, - null + false, + false, + true ] }, - "hash": "4b6bd8192eea43df8d50504e86e9517becbccf7462aa3008c356f7ef93cd73b4" + "hash": "dcde0691b13dbab00364f4142b95a2785c0a612ff739e341e28d0831b7170f6b" } diff --git a/.sqlx/query-b69d55c4b0352cac007496828e2d08369749bac52d2076c3ba679c4aaa466fc9.json b/.sqlx/query-e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41.json similarity index 62% rename from .sqlx/query-b69d55c4b0352cac007496828e2d08369749bac52d2076c3ba679c4aaa466fc9.json rename to .sqlx/query-e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41.json index cf8c9da..03e3aa1 100644 --- a/.sqlx/query-b69d55c4b0352cac007496828e2d08369749bac52d2076c3ba679c4aaa466fc9.json +++ b/.sqlx/query-e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select\n id,\n workspace_id,\n email,\n role,\n status,\n token_hash,\n to_char(expires_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"expires_at!\",\n to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\"\n from invitation_tokens\n where workspace_id = $1\n order by created_at desc", + "query": "select\n id,\n workspace_id,\n email,\n role,\n status,\n token_hash,\n expires_at as \"expires_at!: time::OffsetDateTime\",\n created_at as \"created_at!: time::OffsetDateTime\"\n from invitation_tokens\n where workspace_id = $1\n order by created_at desc", "describe": { "columns": [ { @@ -35,13 +35,13 @@ }, { "ordinal": 6, - "name": "expires_at!", - "type_info": "Text" + "name": "expires_at!: time::OffsetDateTime", + "type_info": "Timestamptz" }, { "ordinal": 7, - "name": "created_at!", - "type_info": "Text" + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" } ], "parameters": { @@ -56,9 +56,9 @@ false, false, false, - null, - null + false, + false ] }, - "hash": "b69d55c4b0352cac007496828e2d08369749bac52d2076c3ba679c4aaa466fc9" + "hash": "e9fc9fdf8ef885ab247d903dbde0a2f07bc89aec46cdbe1e856700fa57674f41" } diff --git a/.sqlx/query-d50ada14acca676613ea3312e92c73a85fd24606e88c9b07fc33535690b540d1.json b/.sqlx/query-fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a.json similarity index 63% rename from .sqlx/query-d50ada14acca676613ea3312e92c73a85fd24606e88c9b07fc33535690b540d1.json rename to .sqlx/query-fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a.json index a9356cb..ed3c067 100644 --- a/.sqlx/query-d50ada14acca676613ea3312e92c73a85fd24606e88c9b07fc33535690b540d1.json +++ b/.sqlx/query-fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "select\n sv.secret_id,\n sv.version,\n sv.ciphertext,\n sv.key_version,\n to_char(sv.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\",\n sv.created_by\n from secrets s\n join secret_versions sv\n on sv.secret_id = s.id and sv.version = s.current_version\n where s.workspace_id = $1 and s.id = $2", + "query": "select\n sv.secret_id,\n sv.version,\n sv.ciphertext,\n sv.key_version,\n sv.created_at as \"created_at!: time::OffsetDateTime\",\n sv.created_by\n from secrets s\n join secret_versions sv\n on sv.secret_id = s.id and sv.version = s.current_version\n where s.workspace_id = $1 and s.id = $2", "describe": { "columns": [ { @@ -25,8 +25,8 @@ }, { "ordinal": 4, - "name": "created_at!", - "type_info": "Text" + "name": "created_at!: time::OffsetDateTime", + "type_info": "Timestamptz" }, { "ordinal": 5, @@ -45,9 +45,9 @@ false, false, false, - null, + false, true ] }, - "hash": "d50ada14acca676613ea3312e92c73a85fd24606e88c9b07fc33535690b540d1" + "hash": "fb0bec06d2a3dbbf455947346eea5022c7ddac2ffe2a1969b2c6a2ed72c3730a" } diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index 02a4680..c91efbf 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -870,14 +870,14 @@ impl AdminService { user_id: &crank_core::UserId, payload: WorkspacePayload, ) -> Result { - let now = now_string()?; + let now = OffsetDateTime::now_utc(); let workspace = Workspace { id: WorkspaceId::new(new_prefixed_id("ws")), slug: payload.slug, display_name: payload.display_name, status: WorkspaceStatus::Active, settings: payload.settings, - created_at: now.clone(), + created_at: now, updated_at: now, }; @@ -1034,7 +1034,7 @@ impl AdminService { status: payload.status.unwrap_or(existing.status), settings: payload.settings.unwrap_or(existing.settings), created_at: existing.created_at, - updated_at: now_string()?, + updated_at: OffsetDateTime::now_utc(), }; self.registry @@ -1183,10 +1183,10 @@ impl AdminService { status: InvitationStatus::Pending, token_hash: hash_access_secret(&invite_token), expires_at: match payload.expires_at { - Some(expires_at) => expires_at, + Some(expires_at) => parse_timestamp(&expires_at)?, None => default_invitation_expiry()?, }, - created_at: now_string()?, + created_at: OffsetDateTime::now_utc(), }, }; @@ -2362,9 +2362,7 @@ impl AdminService { auth_profile: &AuthProfile, ) -> Result { let mut secrets = BTreeMap::new(); - let used_at = now_string().map_err(|error| RuntimeError::SecretCrypto { - details: error.to_string(), - })?; + let used_at = OffsetDateTime::now_utc(); for secret_id in auth_profile.config.secret_ids() { let secret = self @@ -2451,7 +2449,7 @@ impl AdminService { self.ensure_workspace_exists(workspace_id).await?; validate_secret_payload(&payload)?; - let now = now_string()?; + let now = OffsetDateTime::now_utc(); let secret = Secret { id: SecretId::new(new_prefixed_id("secret")), workspace_id: workspace_id.clone(), @@ -2459,7 +2457,7 @@ impl AdminService { kind: payload.kind, status: SecretStatus::Active, current_version: 1, - created_at: now.clone(), + created_at: now, updated_at: now, last_used_at: None, }; @@ -2494,7 +2492,7 @@ impl AdminService { return Err(ApiError::validation("secret value must not be null")); } - let now = now_string()?; + let now = OffsetDateTime::now_utc(); let ciphertext = self .secret_crypto .encrypt(&payload.value) @@ -4615,6 +4613,11 @@ fn now_string() -> Result { .map_err(|error| ApiError::internal(error.to_string())) } +fn parse_timestamp(value: &str) -> Result { + OffsetDateTime::parse(value, &Rfc3339) + .map_err(|_| ApiError::validation("timestamp must be RFC 3339")) +} + fn format_timestamp(timestamp: OffsetDateTime) -> String { timestamp .format(&Rfc3339) @@ -4642,9 +4645,7 @@ async fn resolve_runtime_auth_for_task( })?; let mut secrets = BTreeMap::new(); - let used_at = now_string().map_err(|error| RuntimeError::SecretCrypto { - details: error.to_string(), - })?; + let used_at = OffsetDateTime::now_utc(); for secret_id in auth_profile.config.secret_ids() { let secret = registry @@ -4682,12 +4683,10 @@ async fn resolve_runtime_auth_for_task( ResolvedAuth::from_profile(&auth_profile, &secrets).map(Some) } -fn default_invitation_expiry() -> Result { +fn default_invitation_expiry() -> Result { OffsetDateTime::now_utc() .checked_add(time::Duration::days(7)) - .ok_or_else(|| ApiError::internal("failed to compute invitation expiry"))? - .format(&Rfc3339) - .map_err(|error| ApiError::internal(error.to_string())) + .ok_or_else(|| ApiError::internal("failed to compute invitation expiry")) } fn runtime_error_code(error: &RuntimeError) -> &'static str { diff --git a/apps/mcp-server/src/app.rs b/apps/mcp-server/src/app.rs index 0cddd9c..647cb6c 100644 --- a/apps/mcp-server/src/app.rs +++ b/apps/mcp-server/src/app.rs @@ -560,7 +560,7 @@ async fn resolve_auth_profile( auth_profile: &AuthProfile, ) -> Result { let mut secrets = BTreeMap::new(); - let used_at = now_rfc3339(); + let used_at = OffsetDateTime::now_utc(); for secret_id in auth_profile.config.secret_ids() { let secret = registry @@ -1918,10 +1918,6 @@ fn format_rfc3339(timestamp: OffsetDateTime) -> String { .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_owned()) } -fn now_rfc3339() -> String { - format_rfc3339(OffsetDateTime::now_utc()) -} - fn add_millis(timestamp: OffsetDateTime, millis: u64) -> OffsetDateTime { let delta = time::Duration::milliseconds(i64::try_from(millis).unwrap_or(i64::MAX)); diff --git a/crates/crank-core/src/access.rs b/crates/crank-core/src/access.rs index 45716e8..a7aab1b 100644 --- a/crates/crank-core/src/access.rs +++ b/crates/crank-core/src/access.rs @@ -58,7 +58,8 @@ pub struct Membership { pub workspace_id: WorkspaceId, pub user_id: UserId, pub role: MembershipRole, - pub created_at: String, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -69,8 +70,10 @@ pub struct InvitationToken { pub role: MembershipRole, pub status: InvitationStatus, pub token_hash: String, - pub expires_at: String, - pub created_at: String, + #[serde(with = "time::serde::rfc3339")] + pub expires_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/crank-core/src/secret.rs b/crates/crank-core/src/secret.rs index 375223f..dc9f468 100644 --- a/crates/crank-core/src/secret.rs +++ b/crates/crank-core/src/secret.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; use crate::ids::{SecretId, UserId, WorkspaceId}; @@ -26,9 +27,12 @@ pub struct Secret { pub kind: SecretKind, pub status: SecretStatus, pub current_version: u32, - pub created_at: String, - pub updated_at: String, - pub last_used_at: Option, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339::option")] + pub last_used_at: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -37,6 +41,56 @@ pub struct SecretVersion { pub version: u32, pub ciphertext: String, pub key_version: String, - pub created_at: String, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, pub created_by: Option, } + +#[cfg(test)] +mod tests { + use serde_json::json; + use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + + use super::{Secret, SecretKind, SecretStatus, SecretVersion}; + use crate::ids::{SecretId, UserId, WorkspaceId}; + + #[test] + fn secret_serializes_timestamps_as_rfc3339() { + let secret = Secret { + id: SecretId::new("secret_01"), + workspace_id: WorkspaceId::new("ws_01"), + name: "primary".to_owned(), + kind: SecretKind::Token, + status: SecretStatus::Active, + current_version: 2, + created_at: OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap(), + updated_at: OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap(), + last_used_at: Some(OffsetDateTime::parse("2026-03-25T12:07:00Z", &Rfc3339).unwrap()), + }; + + let value = serde_json::to_value(&secret).unwrap(); + + assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z")); + assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z")); + assert_eq!(value["last_used_at"], json!("2026-03-25T12:07:00Z")); + } + + #[test] + fn secret_version_deserializes_created_at_from_rfc3339() { + let version: SecretVersion = serde_json::from_value(json!({ + "secret_id": "secret_01", + "version": 2, + "ciphertext": "opaque", + "key_version": "v2", + "created_at": "2026-03-25T12:07:00Z", + "created_by": "user_01" + })) + .unwrap(); + + assert_eq!( + version.created_at, + OffsetDateTime::parse("2026-03-25T12:07:00Z", &Rfc3339).unwrap() + ); + assert_eq!(version.created_by, Some(UserId::new("user_01"))); + } +} diff --git a/crates/crank-core/src/workspace.rs b/crates/crank-core/src/workspace.rs index 4cc0a6e..efa818a 100644 --- a/crates/crank-core/src/workspace.rs +++ b/crates/crank-core/src/workspace.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; +use time::OffsetDateTime; use crate::ids::WorkspaceId; @@ -17,6 +18,58 @@ pub struct Workspace { pub display_name: String, pub status: WorkspaceStatus, pub settings: Value, - pub created_at: String, - pub updated_at: String, + #[serde(with = "time::serde::rfc3339")] + pub created_at: OffsetDateTime, + #[serde(with = "time::serde::rfc3339")] + pub updated_at: OffsetDateTime, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use time::{OffsetDateTime, format_description::well_known::Rfc3339}; + + use super::{Workspace, WorkspaceStatus}; + use crate::ids::WorkspaceId; + + #[test] + fn workspace_serializes_timestamps_as_rfc3339() { + let workspace = Workspace { + id: WorkspaceId::new("ws_01"), + slug: "primary".to_owned(), + display_name: "Primary".to_owned(), + status: WorkspaceStatus::Active, + settings: json!({"region":"eu"}), + created_at: OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap(), + updated_at: OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap(), + }; + + let value = serde_json::to_value(&workspace).unwrap(); + + assert_eq!(value["created_at"], json!("2026-03-25T12:00:00Z")); + assert_eq!(value["updated_at"], json!("2026-03-25T12:05:00Z")); + } + + #[test] + fn workspace_deserializes_timestamps_from_rfc3339() { + let workspace: Workspace = serde_json::from_value(json!({ + "id": "ws_01", + "slug": "primary", + "display_name": "Primary", + "status": "active", + "settings": {"region":"eu"}, + "created_at": "2026-03-25T12:00:00Z", + "updated_at": "2026-03-25T12:05:00Z" + })) + .unwrap(); + + assert_eq!( + workspace.created_at, + OffsetDateTime::parse("2026-03-25T12:00:00Z", &Rfc3339).unwrap() + ); + assert_eq!( + workspace.updated_at, + OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap() + ); + } } diff --git a/crates/crank-registry/src/model.rs b/crates/crank-registry/src/model.rs index 26264fd..e1f4ec7 100644 --- a/crates/crank-registry/src/model.rs +++ b/crates/crank-registry/src/model.rs @@ -61,7 +61,7 @@ pub struct MembershipRecord { pub workspace_id: WorkspaceId, pub user: User, pub role: MembershipRole, - pub created_at: String, + pub created_at: OffsetDateTime, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -448,8 +448,8 @@ pub struct RotateSecretRequest<'a> { pub secret_id: &'a SecretId, pub ciphertext: &'a str, pub key_version: &'a str, - pub created_at: &'a str, - pub updated_at: &'a str, + pub created_at: &'a OffsetDateTime, + pub updated_at: &'a OffsetDateTime, pub created_by: Option<&'a crank_core::UserId>, } diff --git a/crates/crank-registry/src/postgres/mod.rs b/crates/crank-registry/src/postgres/mod.rs index ae79bf3..7a0d364 100644 --- a/crates/crank-registry/src/postgres/mod.rs +++ b/crates/crank-registry/src/postgres/mod.rs @@ -1478,8 +1478,8 @@ mod tests { display_name: "Extra Workspace".to_owned(), status: crank_core::WorkspaceStatus::Active, settings: json!({"region":"eu"}), - created_at: "2026-03-25T12:00:00Z".to_owned(), - updated_at: "2026-03-25T12:00:00Z".to_owned(), + created_at: timestamp("2026-03-25T12:00:00Z"), + updated_at: timestamp("2026-03-25T12:00:00Z"), }; let mut user = User { id: UserId::new("user_extra_01"), @@ -1544,8 +1544,8 @@ mod tests { display_name: "Profile 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(), + created_at: timestamp("2026-03-25T12:00:00Z"), + updated_at: timestamp("2026-03-25T12:00:00Z"), }; let other_workspace = Workspace { id: WorkspaceId::new("ws_profile_02"), @@ -1553,8 +1553,8 @@ mod tests { display_name: "Other Workspace".to_owned(), status: crank_core::WorkspaceStatus::Active, settings: json!({}), - created_at: "2026-03-25T12:01:00Z".to_owned(), - updated_at: "2026-03-25T12:01:00Z".to_owned(), + created_at: timestamp("2026-03-25T12:01:00Z"), + updated_at: timestamp("2026-03-25T12:01:00Z"), }; let user_id = registry .upsert_bootstrap_user("profile@example.com", "Owner", "hashed-password") @@ -1611,8 +1611,8 @@ mod tests { 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(), + created_at: timestamp("2026-03-25T12:00:00Z"), + updated_at: timestamp("2026-03-25T12:00:00Z"), }; let user_id = registry .upsert_bootstrap_user("session@example.com", "Owner", "hashed-password") @@ -1666,8 +1666,8 @@ mod tests { display_name: "Keys 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(), + created_at: timestamp("2026-03-25T12:00:00Z"), + updated_at: timestamp("2026-03-25T12:00:00Z"), }; let api_key = PlatformApiKey { id: PlatformApiKeyId::new("key_01"), diff --git a/crates/crank-registry/src/postgres/secret.rs b/crates/crank-registry/src/postgres/secret.rs index 2aac7a6..9aab09c 100644 --- a/crates/crank-registry/src/postgres/secret.rs +++ b/crates/crank-registry/src/postgres/secret.rs @@ -13,9 +13,9 @@ impl PostgresRegistry { kind, status, current_version, - to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\", - to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"updated_at!\", - to_char(last_used_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_used_at + created_at as \"created_at!: time::OffsetDateTime\", + updated_at as \"updated_at!: time::OffsetDateTime\", + last_used_at as \"last_used_at: time::OffsetDateTime\" from secrets where workspace_id = $1 order by name asc", @@ -56,9 +56,9 @@ impl PostgresRegistry { kind, status, current_version, - to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\", - to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"updated_at!\", - to_char(last_used_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as last_used_at + created_at as \"created_at!: time::OffsetDateTime\", + updated_at as \"updated_at!: time::OffsetDateTime\", + last_used_at as \"last_used_at: time::OffsetDateTime\" from secrets where workspace_id = $1 and id = $2", workspace_id.as_str(), @@ -96,7 +96,7 @@ impl PostgresRegistry { sv.version, sv.ciphertext, sv.key_version, - to_char(sv.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\", + sv.created_at as \"created_at!: time::OffsetDateTime\", sv.created_by from secrets s join secret_versions sv @@ -149,9 +149,9 @@ impl PostgresRegistry { .bind(serialize_enum_text(&request.secret.kind, "kind")?) .bind(serialize_enum_text(&request.secret.status, "status")?) .bind(to_db_version(request.secret.current_version)) - .bind(request.secret.last_used_at.as_deref()) - .bind(&request.secret.created_at) - .bind(&request.secret.updated_at) + .bind(request.secret.last_used_at) + .bind(request.secret.created_at) + .bind(request.secret.updated_at) .execute(&mut *tx) .await; @@ -173,7 +173,7 @@ impl PostgresRegistry { .bind(to_db_version(request.secret.current_version)) .bind(request.ciphertext) .bind(request.key_version) - .bind(&request.secret.created_at) + .bind(request.secret.created_at) .bind(request.created_by.map(|value| value.as_str())) .execute(&mut *tx) .await?; @@ -247,7 +247,7 @@ impl PostgresRegistry { version: next_version, ciphertext: request.ciphertext.to_owned(), key_version: request.key_version.to_owned(), - created_at: request.created_at.to_owned(), + created_at: *request.created_at, created_by: request.created_by.cloned(), }, }) @@ -280,7 +280,7 @@ impl PostgresRegistry { &self, workspace_id: &WorkspaceId, secret_id: &SecretId, - used_at: &str, + used_at: &OffsetDateTime, ) -> Result<(), RegistryError> { let result = sqlx::query( "update secrets diff --git a/crates/crank-registry/src/postgres/workspace.rs b/crates/crank-registry/src/postgres/workspace.rs index 0e98937..021a54a 100644 --- a/crates/crank-registry/src/postgres/workspace.rs +++ b/crates/crank-registry/src/postgres/workspace.rs @@ -1,5 +1,4 @@ use super::*; -use time::OffsetDateTime; impl PostgresRegistry { pub async fn list_workspaces(&self) -> Result, RegistryError> { @@ -10,8 +9,8 @@ impl PostgresRegistry { display_name, status, settings_json, - to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\", - to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"updated_at!\" + created_at as \"created_at!: time::OffsetDateTime\", + updated_at as \"updated_at!: time::OffsetDateTime\" from workspaces order by slug asc", ) @@ -46,8 +45,8 @@ impl PostgresRegistry { w.display_name, w.status, w.settings_json, - to_char(w.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\", - to_char(w.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"updated_at!\", + w.created_at as \"created_at!: time::OffsetDateTime\", + w.updated_at as \"updated_at!: time::OffsetDateTime\", m.role from memberships m join workspaces w on w.id = m.workspace_id @@ -85,11 +84,11 @@ impl PostgresRegistry { m.workspace_id, m.user_id, m.role, - to_char(m.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\", + m.created_at as \"created_at!: time::OffsetDateTime\", u.email, u.display_name, u.status, - u.created_at as \"user_created_at!: OffsetDateTime\" + u.created_at as \"user_created_at!: time::OffsetDateTime\" from memberships m join users u on u.id = m.user_id where m.workspace_id = $1 @@ -180,8 +179,8 @@ impl PostgresRegistry { role, status, token_hash, - to_char(expires_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"expires_at!\", - to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\" + expires_at as \"expires_at!: time::OffsetDateTime\", + created_at as \"created_at!: time::OffsetDateTime\" from invitation_tokens where workspace_id = $1 order by created_at desc", @@ -232,8 +231,8 @@ impl PostgresRegistry { .bind(serialize_enum_text(&request.invitation.role, "role")?) .bind(serialize_enum_text(&request.invitation.status, "status")?) .bind(&request.invitation.token_hash) - .bind(&request.invitation.expires_at) - .bind(&request.invitation.created_at) + .bind(request.invitation.expires_at) + .bind(request.invitation.created_at) .execute(&self.pool) .await?; @@ -287,8 +286,8 @@ impl PostgresRegistry { display_name, status, settings_json, - to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"created_at!\", - to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as \"updated_at!\" + created_at as \"created_at!: time::OffsetDateTime\", + updated_at as \"updated_at!: time::OffsetDateTime\" from workspaces where id = $1", workspace_id.as_str(), @@ -334,8 +333,8 @@ impl PostgresRegistry { .bind(&request.workspace.display_name) .bind(serialize_enum_text(&request.workspace.status, "status")?) .bind(Json(request.workspace.settings.clone())) - .bind(&request.workspace.created_at) - .bind(&request.workspace.updated_at) + .bind(request.workspace.created_at) + .bind(request.workspace.updated_at) .execute(&self.pool) .await; @@ -369,7 +368,7 @@ impl PostgresRegistry { .bind(&request.workspace.display_name) .bind(serialize_enum_text(&request.workspace.status, "status")?) .bind(Json(request.workspace.settings.clone())) - .bind(&request.workspace.updated_at) + .bind(request.workspace.updated_at) .bind(request.workspace.id.as_str()) .execute(&self.pool) .await;