diff --git a/.sqlx/query-097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab.json b/.sqlx/query-097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab.json new file mode 100644 index 0000000000..90cdd5ba33 --- /dev/null +++ b/.sqlx/query-097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab.json @@ -0,0 +1,124 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE owner_user_token = ?\nORDER BY id DESC\nLIMIT ? OFFSET ?\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token: ApiKeyToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 1, + "name": "api_key_prefix!", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 3, + "name": "maybe_description", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 2048 + } + }, + { + "ordinal": 4, + "name": "owner_user_token: UserToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 5, + "name": "ip_address_creation", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 160 + } + }, + { + "ordinal": 6, + "name": "ip_address_update", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 160 + } + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 9, + "name": "maybe_deleted_at", + "type_info": { + "type": "Timestamp", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 63, + "max_size": 19 + } + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + false, + true, + false, + true, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "097b8a8d02734c8c8e31dbb8a3bf4ace970693a2a0df2f05a31caf86b4d3d0ab" +} diff --git a/.sqlx/query-9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922.json b/.sqlx/query-9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922.json new file mode 100644 index 0000000000..21ba658dd5 --- /dev/null +++ b/.sqlx/query-9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922.json @@ -0,0 +1,124 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE api_key = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token: ApiKeyToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 1, + "name": "api_key_prefix!", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 3, + "name": "maybe_description", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 2048 + } + }, + { + "ordinal": 4, + "name": "owner_user_token: UserToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 5, + "name": "ip_address_creation", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 160 + } + }, + { + "ordinal": 6, + "name": "ip_address_update", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 160 + } + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 9, + "name": "maybe_deleted_at", + "type_info": { + "type": "Timestamp", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 63, + "max_size": 19 + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + true, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "9b30c67842e4c408c9002fc116dc86705484268efcadb3100ef13244bcd8f922" +} diff --git a/.sqlx/query-b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe.json b/.sqlx/query-b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe.json new file mode 100644 index 0000000000..e48a0ccfef --- /dev/null +++ b/.sqlx/query-b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nUPDATE api_keys\nSET\n maybe_description = ?,\n ip_address_update = ?\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "b72d951fc8b60e3622bb981dd5cd66ce86c6c2d788c5606d2df80a3d07e588fe" +} diff --git a/.sqlx/query-bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4.json b/.sqlx/query-bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4.json new file mode 100644 index 0000000000..a72b54ddcf --- /dev/null +++ b/.sqlx/query-bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nINSERT INTO api_keys\nSET\n token = ?,\n api_key = ?,\n name = ?,\n maybe_description = ?,\n owner_user_token = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW()\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 7 + }, + "nullable": [] + }, + "hash": "bb39045ffcc9d3a9e150b32e7ff884dccbae60101deeaaad23ccd430b25bb5a4" +} diff --git a/.sqlx/query-bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c.json b/.sqlx/query-bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c.json new file mode 100644 index 0000000000..33004c7802 --- /dev/null +++ b/.sqlx/query-bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c.json @@ -0,0 +1,124 @@ +{ + "db_name": "MySQL", + "query": "\nSELECT\n token as `token: ApiKeyToken`,\n LEFT(api_key, 20) as `api_key_prefix!`,\n name,\n maybe_description,\n owner_user_token as `owner_user_token: UserToken`,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n maybe_deleted_at\nFROM api_keys\nWHERE token = ?\nLIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token: ApiKeyToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 1, + "name": "api_key_prefix!", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 80 + } + }, + { + "ordinal": 2, + "name": "name", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 1020 + } + }, + { + "ordinal": 3, + "name": "maybe_description", + "type_info": { + "type": "VarString", + "flags": "BINARY", + "char_set": 224, + "max_size": 2048 + } + }, + { + "ordinal": 4, + "name": "owner_user_token: UserToken", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 128 + } + }, + { + "ordinal": 5, + "name": "ip_address_creation", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 160 + } + }, + { + "ordinal": 6, + "name": "ip_address_update", + "type_info": { + "type": "VarString", + "flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE", + "char_set": 224, + "max_size": 160 + } + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | BINARY | TIMESTAMP", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 8, + "name": "updated_at", + "type_info": { + "type": "Timestamp", + "flags": "NOT_NULL | BINARY | TIMESTAMP | ON_UPDATE_NOW", + "char_set": 63, + "max_size": 19 + } + }, + { + "ordinal": 9, + "name": "maybe_deleted_at", + "type_info": { + "type": "Timestamp", + "flags": "MULTIPLE_KEY | BINARY", + "char_set": 63, + "max_size": 19 + } + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + true, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "bb542b8a86cf2bf770daf29e09a92be981b307810241dd3cc1a7225a83f6824c" +} diff --git a/.sqlx/query-c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805.json b/.sqlx/query-c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805.json new file mode 100644 index 0000000000..30a93c65c5 --- /dev/null +++ b/.sqlx/query-c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805.json @@ -0,0 +1,12 @@ +{ + "db_name": "MySQL", + "query": "\nUPDATE api_keys\nSET maybe_deleted_at = NOW()\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "c28c716aa08601b208c727102680bfa91025f0e3958390ed9e27e521d38b5805" +} diff --git a/_database/sql/migrations/2026-06-15-124731-0000_api_keys/down.sql b/_database/sql/migrations/2026-06-15-124731-0000_api_keys/down.sql new file mode 100644 index 0000000000..4e0e2291dc --- /dev/null +++ b/_database/sql/migrations/2026-06-15-124731-0000_api_keys/down.sql @@ -0,0 +1,5 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +DROP TABLE IF EXISTS api_keys; diff --git a/_database/sql/migrations/2026-06-15-124731-0000_api_keys/up.sql b/_database/sql/migrations/2026-06-15-124731-0000_api_keys/up.sql new file mode 100644 index 0000000000..f6ae1cc224 --- /dev/null +++ b/_database/sql/migrations/2026-06-15-124731-0000_api_keys/up.sql @@ -0,0 +1,50 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +CREATE TABLE api_keys ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + + -- Effective "primary key" (used internally for lookups and as a foreign key). + token VARCHAR(32) NOT NULL, + + -- The actual API key value presented by clients to authenticate. + api_key VARCHAR(255) NOT NULL, + + -- ========== KEY INFO ========== + + -- User-supplied display name for the key. + name VARCHAR(255) NOT NULL, + + -- Optional user-supplied description. + maybe_description VARCHAR(512) DEFAULT NULL, + + -- ========== OWNER ========== + + -- The user that owns this API key. + owner_user_token VARCHAR(32) NOT NULL, + + -- ========== IP ADDRESSES ========== + + -- IP address that created the key. Wide enough for IPv4/IPv6. + ip_address_creation VARCHAR(40) NOT NULL, + + -- IP address that last updated the key. Wide enough for IPv4/IPv6. + ip_address_update VARCHAR(40) NOT NULL, + + -- ========== TIMESTAMPS ========== + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- Soft-delete timestamp. NULL means the key is live. + maybe_deleted_at TIMESTAMP NULL, + + -- INDICES -- + PRIMARY KEY (id), + UNIQUE KEY (token), + UNIQUE KEY (api_key), + KEY fk_owner_user_token (owner_user_token), + KEY index_maybe_deleted_at (maybe_deleted_at) + +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/_database/sql/migrations_squashed/api_keys.sql b/_database/sql/migrations_squashed/api_keys.sql new file mode 100644 index 0000000000..f6ae1cc224 --- /dev/null +++ b/_database/sql/migrations_squashed/api_keys.sql @@ -0,0 +1,50 @@ +-- noinspection SqlDialectInspectionForFile +-- noinspection SqlNoDataSourceInspectionForFile +-- noinspection SqlResolveForFile + +CREATE TABLE api_keys ( + id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, + + -- Effective "primary key" (used internally for lookups and as a foreign key). + token VARCHAR(32) NOT NULL, + + -- The actual API key value presented by clients to authenticate. + api_key VARCHAR(255) NOT NULL, + + -- ========== KEY INFO ========== + + -- User-supplied display name for the key. + name VARCHAR(255) NOT NULL, + + -- Optional user-supplied description. + maybe_description VARCHAR(512) DEFAULT NULL, + + -- ========== OWNER ========== + + -- The user that owns this API key. + owner_user_token VARCHAR(32) NOT NULL, + + -- ========== IP ADDRESSES ========== + + -- IP address that created the key. Wide enough for IPv4/IPv6. + ip_address_creation VARCHAR(40) NOT NULL, + + -- IP address that last updated the key. Wide enough for IPv4/IPv6. + ip_address_update VARCHAR(40) NOT NULL, + + -- ========== TIMESTAMPS ========== + + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- Soft-delete timestamp. NULL means the key is live. + maybe_deleted_at TIMESTAMP NULL, + + -- INDICES -- + PRIMARY KEY (id), + UNIQUE KEY (token), + UNIQUE KEY (api_key), + KEY fk_owner_user_token (owner_user_token), + KEY index_maybe_deleted_at (maybe_deleted_at) + +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/common.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/common.rs new file mode 100644 index 0000000000..6769167106 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/common.rs @@ -0,0 +1,42 @@ +use chrono::{DateTime, Utc}; +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::api_keys::ApiKeyToken; +use tokens::tokens::users::UserToken; + +/// Canonical wire shape for an API key, used by the list rows and the +/// single-key GET. +/// +/// NB: the full secret `api_key` is NEVER included here — only +/// `truncated_api_key` (its first 20 characters), enough to disambiguate keys +/// in a UI. The full value is returned exactly once, at creation time. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub struct ApiKeyInfo { + pub token: ApiKeyToken, + + /// First 20 characters of the API key. The full secret is never returned + /// after creation. + pub truncated_api_key: String, + + pub name: String, + pub maybe_description: Option, + + pub owner_user_token: UserToken, + + pub ip_address_creation: String, + pub ip_address_update: String, + + pub created_at: DateTime, + pub updated_at: DateTime, + + /// Soft-delete timestamp. `None` for live keys. + pub maybe_deleted_at: Option>, +} + +/// Path info for endpoints addressed by the API key's `token` (NOT the secret +/// `api_key` value): `GET` / `DELETE` / `PUT` `/v1/api_keys/{api_key_token}`. +#[derive(Deserialize, ToSchema)] +pub struct ApiKeyPathInfo { + pub api_key_token: ApiKeyToken, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/create_api_key.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/create_api_key.rs new file mode 100644 index 0000000000..6455bb5d3c --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/create_api_key.rs @@ -0,0 +1,28 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use tokens::tokens::api_keys::ApiKeyToken; + +// ── POST /v1/api_keys/create ── + +#[derive(Deserialize, ToSchema)] +pub struct CreateApiKeyRequest { + /// Display name (title) for the key. + pub name: String, + + /// Optional description. + pub maybe_description: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct CreateApiKeySuccessResponse { + pub success: bool, + + /// The key's `token` — used for all subsequent management + /// (get / update / delete). Safe to store and display. + pub api_key_token: ApiKeyToken, + + /// The full secret API key value. This is the ONLY time it is ever + /// returned — the caller must store it now, as it cannot be retrieved again. + pub api_key: String, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/delete_api_key.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/delete_api_key.rs new file mode 100644 index 0000000000..361ed4ac09 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/delete_api_key.rs @@ -0,0 +1,9 @@ +use serde_derive::Serialize; +use utoipa::ToSchema; + +// ── DELETE /v1/api_keys/{api_key_token} ── + +#[derive(Serialize, ToSchema)] +pub struct DeleteApiKeySuccessResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/get_api_key.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/get_api_key.rs new file mode 100644 index 0000000000..7f131fcfbb --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/get_api_key.rs @@ -0,0 +1,12 @@ +use serde_derive::Serialize; +use utoipa::ToSchema; + +use crate::api_keys::common::ApiKeyInfo; + +// ── GET /v1/api_keys/{api_key_token} ── + +#[derive(Serialize, ToSchema)] +pub struct GetApiKeySuccessResponse { + pub success: bool, + pub api_key: ApiKeyInfo, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/list_api_keys.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/list_api_keys.rs new file mode 100644 index 0000000000..9e3750f903 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/list_api_keys.rs @@ -0,0 +1,24 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +use crate::api_keys::common::ApiKeyInfo; + +// ── GET /v1/api_keys/list ── + +#[derive(Deserialize, ToSchema, IntoParams)] +pub struct ListApiKeysQueryParams { + /// Page size. Defaults server-side if omitted. + pub limit: Option, + + /// Number of rows to skip. Defaults to 0. + pub offset: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct ListApiKeysSuccessResponse { + pub success: bool, + + /// The user's API keys (including soft-deleted ones), newest first. Each + /// entry's key value is truncated — the full secret is never listed. + pub api_keys: Vec, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/mod.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/mod.rs new file mode 100644 index 0000000000..e4a9b484f9 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/mod.rs @@ -0,0 +1,6 @@ +pub mod common; +pub mod create_api_key; +pub mod delete_api_key; +pub mod get_api_key; +pub mod list_api_keys; +pub mod update_api_key; diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/update_api_key.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/update_api_key.rs new file mode 100644 index 0000000000..c51f7193d4 --- /dev/null +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/api_keys/update_api_key.rs @@ -0,0 +1,15 @@ +use serde_derive::{Deserialize, Serialize}; +use utoipa::ToSchema; + +// ── PUT /v1/api_keys/{api_key_token} ── + +#[derive(Deserialize, ToSchema)] +pub struct UpdateApiKeyRequest { + /// New description for the key. `None` clears it. + pub maybe_description: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct UpdateApiKeySuccessResponse { + pub success: bool, +} diff --git a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs index 41d84b638b..a9d2efc0fb 100644 --- a/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs +++ b/crates/api_clients/artcraft/artcraft_api_defs/src/lib.rs @@ -1,4 +1,5 @@ pub mod analytics; +pub mod api_keys; pub mod characters; pub mod common; pub mod credits; diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/api_key_row.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/api_key_row.rs new file mode 100644 index 0000000000..6c2f9f2394 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/api_key_row.rs @@ -0,0 +1,27 @@ +use chrono::{DateTime, Utc}; + +use tokens::tokens::api_keys::ApiKeyToken; +use tokens::tokens::users::UserToken; + +/// A materialized `api_keys` row, minus the internal `id` and the full secret +/// `api_key`. `api_key_prefix` is the first 20 characters of the key — enough to +/// disambiguate keys in a UI without ever exposing the secret value. +#[derive(Debug, Clone)] +pub struct ApiKeyRow { + pub token: ApiKeyToken, + + /// First 20 characters of the `api_key`. The full secret is never returned. + pub api_key_prefix: String, + + pub name: String, + pub maybe_description: Option, + + pub owner_user_token: UserToken, + + pub ip_address_creation: String, + pub ip_address_update: String, + + pub created_at: DateTime, + pub updated_at: DateTime, + pub maybe_deleted_at: Option>, +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/delete_api_key.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/delete_api_key.rs new file mode 100644 index 0000000000..eaa944e205 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/delete_api_key.rs @@ -0,0 +1,38 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use tokens::tokens::api_keys::ApiKeyToken; + +pub struct DeleteApiKeyArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub token: &'e ApiKeyToken, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Soft-delete an API key by stamping `maybe_deleted_at = NOW()`. Returns the +/// number of rows affected (0 if no live key matched the token). +pub async fn delete_api_key<'e, 'c: 'e, E>( + args: DeleteApiKeyArgs<'e, 'c, E>, +) -> Result +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +UPDATE api_keys +SET maybe_deleted_at = NOW() +WHERE token = ? + AND maybe_deleted_at IS NULL +LIMIT 1 + "#, + args.token.as_str(), + ) + .execute(args.mysql_executor) + .await?; + + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/get_api_key_by_token.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/get_api_key_by_token.rs new file mode 100644 index 0000000000..4dbd716176 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/get_api_key_by_token.rs @@ -0,0 +1,63 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use tokens::tokens::api_keys::ApiKeyToken; +use tokens::tokens::users::UserToken; + +use crate::queries::api_keys::api_key_row::ApiKeyRow; + +pub struct GetApiKeyByTokenArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub token: &'e ApiKeyToken, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Look up an API key by its `token`. Returns every detail (including +/// `maybe_deleted_at`, so soft-deleted keys are still visible here) except the +/// internal `id` and the full secret `api_key` (only its first 20 characters +/// are returned). `Ok(None)` if no row matches the token. +pub async fn get_api_key_by_token<'e, 'c: 'e, E>( + args: GetApiKeyByTokenArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +SELECT + token as `token: ApiKeyToken`, + LEFT(api_key, 20) as `api_key_prefix!`, + name, + maybe_description, + owner_user_token as `owner_user_token: UserToken`, + ip_address_creation, + ip_address_update, + created_at, + updated_at, + maybe_deleted_at +FROM api_keys +WHERE token = ? +LIMIT 1 + "#, + args.token.as_str(), + ) + .fetch_optional(args.mysql_executor) + .await?; + + Ok(result.map(|r| ApiKeyRow { + token: r.token, + api_key_prefix: r.api_key_prefix, + name: r.name, + maybe_description: r.maybe_description, + owner_user_token: r.owner_user_token, + ip_address_creation: r.ip_address_creation, + ip_address_update: r.ip_address_update, + created_at: r.created_at, + updated_at: r.updated_at, + maybe_deleted_at: r.maybe_deleted_at, + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/get_live_api_key_by_value.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/get_live_api_key_by_value.rs new file mode 100644 index 0000000000..b163b526c7 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/get_live_api_key_by_value.rs @@ -0,0 +1,66 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use tokens::tokens::api_keys::ApiKeyToken; +use tokens::tokens::users::UserToken; + +use crate::queries::api_keys::api_key_row::ApiKeyRow; + +pub struct GetLiveApiKeyByValueArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + /// The actual API key secret presented by the client. + pub api_key: &'e str, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Look up a **live** API key by its secret value (for authenticating an +/// incoming request). Returns `Ok(None)` if no row matches OR if the matching +/// row is soft-deleted (`maybe_deleted_at IS NOT NULL`). The full secret is +/// never echoed back — only its first 20 characters — and the internal `id` is +/// omitted. +pub async fn get_live_api_key_by_value<'e, 'c: 'e, E>( + args: GetLiveApiKeyByValueArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +SELECT + token as `token: ApiKeyToken`, + LEFT(api_key, 20) as `api_key_prefix!`, + name, + maybe_description, + owner_user_token as `owner_user_token: UserToken`, + ip_address_creation, + ip_address_update, + created_at, + updated_at, + maybe_deleted_at +FROM api_keys +WHERE api_key = ? + AND maybe_deleted_at IS NULL +LIMIT 1 + "#, + args.api_key, + ) + .fetch_optional(args.mysql_executor) + .await?; + + Ok(result.map(|r| ApiKeyRow { + token: r.token, + api_key_prefix: r.api_key_prefix, + name: r.name, + maybe_description: r.maybe_description, + owner_user_token: r.owner_user_token, + ip_address_creation: r.ip_address_creation, + ip_address_update: r.ip_address_update, + created_at: r.created_at, + updated_at: r.updated_at, + maybe_deleted_at: r.maybe_deleted_at, + })) +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/insert_api_key.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/insert_api_key.rs new file mode 100644 index 0000000000..30787e42f1 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/insert_api_key.rs @@ -0,0 +1,58 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use tokens::tokens::api_keys::ApiKeyToken; +use tokens::tokens::users::UserToken; + +pub struct InsertApiKeyArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub owner_user_token: &'e UserToken, + pub ip_address: &'e str, + pub name: &'e str, + pub maybe_description: Option<&'e str>, + pub api_key: &'e str, + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Insert a new API key. The `ApiKeyToken` is minted here (in the data-access +/// layer, not the handler) and returned to the caller. The provided IP address +/// is stamped on both the creation and update IP columns, and `created_at` is +/// set to `NOW()`. +pub async fn insert_api_key<'e, 'c: 'e, E>( + args: InsertApiKeyArgs<'e, 'c, E>, +) -> Result +where + E: 'e + Executor<'c, Database = MySql>, +{ + let token = ApiKeyToken::generate(); + + sqlx::query!( + r#" +INSERT INTO api_keys +SET + token = ?, + api_key = ?, + name = ?, + maybe_description = ?, + owner_user_token = ?, + ip_address_creation = ?, + ip_address_update = ?, + created_at = NOW() + "#, + token.as_str(), + args.api_key, + args.name, + args.maybe_description, + args.owner_user_token.as_str(), + args.ip_address, + args.ip_address, + ) + .execute(args.mysql_executor) + .await?; + + Ok(token) +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/list_api_keys_for_user.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/list_api_keys_for_user.rs new file mode 100644 index 0000000000..97a34094b6 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/list_api_keys_for_user.rs @@ -0,0 +1,77 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use tokens::tokens::api_keys::ApiKeyToken; +use tokens::tokens::users::UserToken; + +use crate::queries::api_keys::api_key_row::ApiKeyRow; + +pub struct ListApiKeysForUserArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub owner_user_token: &'e UserToken, + + /// Page size and page offset. Pagination is LIMIT/OFFSET rather than keyset + /// so we don't have to expose the internal `id`. + pub limit: u32, + pub offset: u32, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// List a user's API keys, newest first, **including soft-deleted** keys. +/// Never returns the full `api_key` — only its first 20 characters — and never +/// returns the internal `id`. +pub async fn list_api_keys_for_user<'e, 'c: 'e, E>( + args: ListApiKeysForUserArgs<'e, 'c, E>, +) -> Result, sqlx::Error> +where + E: 'e + Executor<'c, Database = MySql>, +{ + let limit = args.limit as i64; + let offset = args.offset as i64; + + let rows = sqlx::query!( + r#" +SELECT + token as `token: ApiKeyToken`, + LEFT(api_key, 20) as `api_key_prefix!`, + name, + maybe_description, + owner_user_token as `owner_user_token: UserToken`, + ip_address_creation, + ip_address_update, + created_at, + updated_at, + maybe_deleted_at +FROM api_keys +WHERE owner_user_token = ? +ORDER BY id DESC +LIMIT ? OFFSET ? + "#, + args.owner_user_token.as_str(), + limit, + offset, + ) + .fetch_all(args.mysql_executor) + .await? + .into_iter() + .map(|r| ApiKeyRow { + token: r.token, + api_key_prefix: r.api_key_prefix, + name: r.name, + maybe_description: r.maybe_description, + owner_user_token: r.owner_user_token, + ip_address_creation: r.ip_address_creation, + ip_address_update: r.ip_address_update, + created_at: r.created_at, + updated_at: r.updated_at, + maybe_deleted_at: r.maybe_deleted_at, + }) + .collect::>(); + + Ok(rows) +} diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/mod.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/mod.rs new file mode 100644 index 0000000000..898cc84f80 --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/mod.rs @@ -0,0 +1,7 @@ +pub mod api_key_row; +pub mod delete_api_key; +pub mod get_api_key_by_token; +pub mod get_live_api_key_by_value; +pub mod insert_api_key; +pub mod list_api_keys_for_user; +pub mod update_api_key; diff --git a/crates/schema/database/mysql_queries/src/queries/api_keys/update_api_key.rs b/crates/schema/database/mysql_queries/src/queries/api_keys/update_api_key.rs new file mode 100644 index 0000000000..25438c1b9b --- /dev/null +++ b/crates/schema/database/mysql_queries/src/queries/api_keys/update_api_key.rs @@ -0,0 +1,48 @@ +use std::marker::PhantomData; + +use sqlx::{Executor, MySql}; + +use tokens::tokens::api_keys::ApiKeyToken; + +pub struct UpdateApiKeyArgs<'e, 'c, E> +where + E: 'e + Executor<'c, Database = MySql>, +{ + pub token: &'e ApiKeyToken, + pub maybe_description: Option<&'e str>, + + /// IP address of the request making the update; stamped on `ip_address_update`. + pub ip_address: &'e str, + + pub mysql_executor: E, + pub phantom: PhantomData<&'c E>, +} + +/// Update an API key's `maybe_description`, also refreshing `ip_address_update` +/// (and, via the column's `ON UPDATE` clause, `updated_at`). Scoped to live keys +/// only. Returns the number of rows affected (0 if no live key matched). +pub async fn update_api_key<'e, 'c: 'e, E>( + args: UpdateApiKeyArgs<'e, 'c, E>, +) -> Result +where + E: 'e + Executor<'c, Database = MySql>, +{ + let result = sqlx::query!( + r#" +UPDATE api_keys +SET + maybe_description = ?, + ip_address_update = ? +WHERE token = ? + AND maybe_deleted_at IS NULL +LIMIT 1 + "#, + args.maybe_description, + args.ip_address, + args.token.as_str(), + ) + .execute(args.mysql_executor) + .await?; + + Ok(result.rows_affected()) +} diff --git a/crates/schema/database/mysql_queries/src/queries/mod.rs b/crates/schema/database/mysql_queries/src/queries/mod.rs index de7e561443..bc4474b920 100644 --- a/crates/schema/database/mysql_queries/src/queries/mod.rs +++ b/crates/schema/database/mysql_queries/src/queries/mod.rs @@ -1,5 +1,6 @@ pub mod analytics_active_users; +pub mod api_keys; pub mod api_tokens; pub mod audit_logs; pub mod batch_generations; diff --git a/crates/schema/public/tokens/src/prefixes.rs b/crates/schema/public/tokens/src/prefixes.rs index fba133d614..671f543701 100644 --- a/crates/schema/public/tokens/src/prefixes.rs +++ b/crates/schema/public/tokens/src/prefixes.rs @@ -23,6 +23,7 @@ use strum::EnumIter; #[cfg_attr(test, derive(EnumIter, EnumCount))] pub(crate) enum TokenPrefix { AnonymousVisitorTracking, // AVTs are not stored as primary keys in any table, but an index in many tables. + ApiKey, AppSession, // NB: These are generated client-side (!) AuditLog, BatchGeneration, @@ -112,6 +113,7 @@ impl PrefixGenerator for TokenPrefix { fn prefix(self) -> &'static str { match self { Self::AnonymousVisitorTracking => "avt_", + Self::ApiKey => "api_key_", Self::AppSession => "app_session_", Self::AuditLog => "audit_", Self::BatchGeneration => "batch_g_", @@ -348,6 +350,7 @@ mod tests { || prefix == "INT_API:" || prefix == "batch_g_" || prefix == "beta_key_" + || prefix == "api_key_" || prefix == "email_job_" || prefix == "pw_reset_" || prefix == "tts_task_" diff --git a/crates/schema/public/tokens/src/tokens/api_keys.rs b/crates/schema/public/tokens/src/tokens/api_keys.rs new file mode 100644 index 0000000000..2125b8dc8e --- /dev/null +++ b/crates/schema/public/tokens/src/tokens/api_keys.rs @@ -0,0 +1,17 @@ +use std::fmt::Debug; + +use serde::Deserialize; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::prefixes::TokenPrefix; + +/// The primary key for api_keys. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, Default, ToSchema)] +#[cfg_attr(feature = "database", derive(sqlx::Type))] +#[cfg_attr(feature = "database", sqlx(transparent))] +pub struct ApiKeyToken(pub String); + +impl_mysql_token_from_row!(ApiKeyToken); +impl_string_token!(ApiKeyToken); +impl_crockford_generator!(ApiKeyToken, 32usize, TokenPrefix::ApiKey, CrockfordLower); diff --git a/crates/schema/public/tokens/src/tokens/mod.rs b/crates/schema/public/tokens/src/tokens/mod.rs index c2aa44c55c..0d356f836f 100644 --- a/crates/schema/public/tokens/src/tokens/mod.rs +++ b/crates/schema/public/tokens/src/tokens/mod.rs @@ -1,4 +1,5 @@ pub mod anonymous_visitor_tracking; +pub mod api_keys; pub mod api_tokens_external; pub mod api_tokens_internal; pub mod app_session; diff --git a/crates/service/web/storyteller_web/src/docs/api_doc.rs b/crates/service/web/storyteller_web/src/docs/api_doc.rs index 3c515fd6ee..f15b9ca09d 100644 --- a/crates/service/web/storyteller_web/src/docs/api_doc.rs +++ b/crates/service/web/storyteller_web/src/docs/api_doc.rs @@ -95,6 +95,19 @@ use crate::http_server::endpoints::moderation::user_bans::moderation_ban_user_ha use crate::http_server::endpoints::moderation::user_emails::moderator_change_user_email_handler::*; use crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user_handler::*; +// API key endpoints +use crate::http_server::endpoints::api_keys::create_api_key_handler::*; +use crate::http_server::endpoints::api_keys::list_api_keys_handler::*; +use crate::http_server::endpoints::api_keys::get_api_key_handler::*; +use crate::http_server::endpoints::api_keys::delete_api_key_handler::*; +use crate::http_server::endpoints::api_keys::update_api_key_handler::*; +use artcraft_api_defs::api_keys::common::*; +use artcraft_api_defs::api_keys::create_api_key::*; +use artcraft_api_defs::api_keys::list_api_keys::*; +use artcraft_api_defs::api_keys::get_api_key::*; +use artcraft_api_defs::api_keys::delete_api_key::*; +use artcraft_api_defs::api_keys::update_api_key::*; + // Folder endpoints use crate::http_server::endpoints::folders::folder::color_code_folder_handler::*; use crate::http_server::endpoints::folders::folder::cover_image_folder_handler::*; @@ -561,6 +574,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux crate::http_server::endpoints::moderation::user_emails::moderator_list_email_address_changes_for_user_handler::moderator_list_email_address_changes_for_user_handler, crate::http_server::endpoints::moderation::user_stripe_data::moderator_get_user_stripe_customer_ids_handler::moderator_get_user_stripe_customer_ids_handler, + // API keys + crate::http_server::endpoints::api_keys::create_api_key_handler::create_api_key_handler, + crate::http_server::endpoints::api_keys::list_api_keys_handler::list_api_keys_handler, + crate::http_server::endpoints::api_keys::get_api_key_handler::get_api_key_handler, + crate::http_server::endpoints::api_keys::delete_api_key_handler::delete_api_key_handler, + crate::http_server::endpoints::api_keys::update_api_key_handler::update_api_key_handler, + // Folders crate::http_server::endpoints::folders::folder::create_folder_handler::create_folder_handler, crate::http_server::endpoints::folders::folder::list_folders_handler::list_folders_handler, @@ -1250,6 +1270,18 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux // User Emails (Moderation) ModeratorListUserEmailChangesQueryParams, + // API keys + ApiKeyInfo, + ApiKeyPathInfo, + CreateApiKeyRequest, + CreateApiKeySuccessResponse, + ListApiKeysQueryParams, + ListApiKeysSuccessResponse, + GetApiKeySuccessResponse, + DeleteApiKeySuccessResponse, + UpdateApiKeyRequest, + UpdateApiKeySuccessResponse, + // Folders FolderInfo, FolderThumbnail, diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/api_key_info_conversion.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/api_key_info_conversion.rs new file mode 100644 index 0000000000..973d5adccc --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/api_key_info_conversion.rs @@ -0,0 +1,20 @@ +use artcraft_api_defs::api_keys::common::ApiKeyInfo; +use mysql_queries::queries::api_keys::api_key_row::ApiKeyRow; + +/// Convert a data-access `ApiKeyRow` into the public `ApiKeyInfo` wire shape. +/// The row only ever carries the truncated key prefix (never the full secret), +/// so this is a straight field map. +pub fn api_key_row_to_info(row: ApiKeyRow) -> ApiKeyInfo { + ApiKeyInfo { + token: row.token, + truncated_api_key: row.api_key_prefix, + name: row.name, + maybe_description: row.maybe_description, + owner_user_token: row.owner_user_token, + ip_address_creation: row.ip_address_creation, + ip_address_update: row.ip_address_update, + created_at: row.created_at, + updated_at: row.updated_at, + maybe_deleted_at: row.maybe_deleted_at, + } +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs new file mode 100644 index 0000000000..4625a6a2bd --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/create_api_key_handler.rs @@ -0,0 +1,91 @@ +use std::marker::PhantomData; +use std::sync::Arc; + +use actix_web::web::Json; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::api_keys::create_api_key::{CreateApiKeyRequest, CreateApiKeySuccessResponse}; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs}; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::state::server_state::ServerState; +use crate::util::generate_api_key::generate_api_key; + +const MAX_NAME_LEN: usize = 255; +const MAX_DESCRIPTION_LEN: usize = 512; + +/// Create a new API key for the logged-in user. The full secret `api_key` +/// value is returned exactly once in this response — it can never be retrieved +/// again afterward. +#[utoipa::path( + post, + tag = "API Keys", + path = "/v1/api_keys/create", + request_body = CreateApiKeyRequest, + responses( + (status = 200, body = CreateApiKeySuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn create_api_key_handler( + http_request: HttpRequest, + request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?; + + let name = request.name.trim().to_string(); + + let maybe_description = request.maybe_description.as_ref().map(|s| s.trim().to_string()); + + if name.is_empty() { + return Err(CommonWebError::BadInputWithSimpleMessage("name is empty".to_string())); + } + if name.len() > MAX_NAME_LEN { + return Err(CommonWebError::BadInputWithSimpleMessage( + format!("name too long (max {} chars)", MAX_NAME_LEN), + )); + } + + if let Some(description) = &maybe_description { + if description.len() > MAX_DESCRIPTION_LEN { + return Err(CommonWebError::BadInputWithSimpleMessage( + format!("description too long (max {} chars)", MAX_DESCRIPTION_LEN), + )); + } + } + + let ip_address = get_request_ip(&http_request); + + // Generate the secret once. It is returned to the caller here and never again. + let api_key = generate_api_key(); + + let token = insert_api_key(InsertApiKeyArgs { + owner_user_token: &user_session.user_token, + ip_address: &ip_address, + name: &name, + maybe_description: maybe_description.as_deref(), + api_key: &api_key, + mysql_executor: &mut *conn, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("insert_api_key failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + Ok(Json(CreateApiKeySuccessResponse { + success: true, + api_key_token: token, + api_key, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/delete_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/delete_api_key_handler.rs new file mode 100644 index 0000000000..8d00af94ef --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/delete_api_key_handler.rs @@ -0,0 +1,73 @@ +use std::marker::PhantomData; +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::api_keys::common::ApiKeyPathInfo; +use artcraft_api_defs::api_keys::delete_api_key::DeleteApiKeySuccessResponse; +use mysql_queries::queries::api_keys::delete_api_key::{delete_api_key, DeleteApiKeyArgs}; +use mysql_queries::queries::api_keys::get_api_key_by_token::{ + get_api_key_by_token, GetApiKeyByTokenArgs, +}; +use tokens::tokens::api_keys::ApiKeyToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::state::server_state::ServerState; + +/// Soft-delete an API key by `token`, scoped to the logged-in user. +#[utoipa::path( + delete, + tag = "API Keys", + path = "/v1/api_keys/{api_key_token}", + params(("api_key_token" = ApiKeyToken, description = "The API key's token (not the secret value)")), + responses( + (status = 200, body = DeleteApiKeySuccessResponse), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn delete_api_key_handler( + http_request: HttpRequest, + path: Path, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?; + + // The by-token delete query isn't owner-scoped, so first look the key up and + // confirm ownership. Return NotFound for missing OR other-owned keys, so we + // never leak the existence of another user's key. + let row = get_api_key_by_token(GetApiKeyByTokenArgs { + token: &path.api_key_token, + mysql_executor: &mut *conn, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("get_api_key_by_token failed: {:?}", err); + CommonWebError::from_error(err) + })? + .filter(|row| row.owner_user_token == user_session.user_token) + .ok_or(CommonWebError::NotFound)?; + + // Only issue the delete if the key is still live. If it's already + // soft-deleted the desired end state already holds, so treat it as success. + if row.maybe_deleted_at.is_none() { + delete_api_key(DeleteApiKeyArgs { + token: &path.api_key_token, + mysql_executor: &mut *conn, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("delete_api_key failed: {:?}", err); + CommonWebError::from_error(err) + })?; + } + + Ok(Json(DeleteApiKeySuccessResponse { success: true })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/get_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/get_api_key_handler.rs new file mode 100644 index 0000000000..7f08ebd985 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/get_api_key_handler.rs @@ -0,0 +1,62 @@ +use std::marker::PhantomData; +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::api_keys::common::ApiKeyPathInfo; +use artcraft_api_defs::api_keys::get_api_key::GetApiKeySuccessResponse; +use mysql_queries::queries::api_keys::get_api_key_by_token::{ + get_api_key_by_token, GetApiKeyByTokenArgs, +}; +use tokens::tokens::api_keys::ApiKeyToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::api_keys::api_key_info_conversion::api_key_row_to_info; +use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::state::server_state::ServerState; + +/// Fetch a single API key by its `token`, scoped to the logged-in user. +/// `NotFound` if the key doesn't exist or belongs to a different user (so we +/// don't leak the existence of others' keys). Never returns the full secret. +#[utoipa::path( + get, + tag = "API Keys", + path = "/v1/api_keys/{api_key_token}", + params(("api_key_token" = ApiKeyToken, description = "The API key's token (not the secret value)")), + responses( + (status = 200, body = GetApiKeySuccessResponse), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn get_api_key_handler( + http_request: HttpRequest, + path: Path, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?; + + let row = get_api_key_by_token(GetApiKeyByTokenArgs { + token: &path.api_key_token, + mysql_executor: &mut *conn, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("get_api_key_by_token failed: {:?}", err); + CommonWebError::from_error(err) + })? + .filter(|row| row.owner_user_token == user_session.user_token) + .ok_or(CommonWebError::NotFound)?; + + Ok(Json(GetApiKeySuccessResponse { + success: true, + api_key: api_key_row_to_info(row), + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/list_api_keys_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/list_api_keys_handler.rs new file mode 100644 index 0000000000..d3d9d41401 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/list_api_keys_handler.rs @@ -0,0 +1,67 @@ +use std::marker::PhantomData; +use std::sync::Arc; + +use actix_web::web::{Json, Query}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::api_keys::list_api_keys::{ListApiKeysQueryParams, ListApiKeysSuccessResponse}; +use mysql_queries::queries::api_keys::list_api_keys_for_user::{ + list_api_keys_for_user, ListApiKeysForUserArgs, +}; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::endpoints::api_keys::api_key_info_conversion::api_key_row_to_info; +use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::state::server_state::ServerState; + +const DEFAULT_LIMIT: u32 = 100; +const MAX_LIMIT: u32 = 1000; + +/// List the logged-in user's API keys, newest first, **including +/// soft-deleted** keys. Paginated via `limit`/`offset`. Never returns the full +/// secret — each entry carries only the truncated key prefix. +#[utoipa::path( + get, + tag = "API Keys", + path = "/v1/api_keys/list", + params(ListApiKeysQueryParams), + responses( + (status = 200, body = ListApiKeysSuccessResponse), + (status = 401, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn list_api_keys_handler( + http_request: HttpRequest, + query: Query, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?; + + let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + let offset = query.offset.unwrap_or(0); + + let rows = list_api_keys_for_user(ListApiKeysForUserArgs { + owner_user_token: &user_session.user_token, + limit, + offset, + mysql_executor: &mut *conn, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("list_api_keys_for_user failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + let api_keys = rows.into_iter().map(api_key_row_to_info).collect(); + + Ok(Json(ListApiKeysSuccessResponse { + success: true, + api_keys, + })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/mod.rs new file mode 100644 index 0000000000..d6baf05091 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/mod.rs @@ -0,0 +1,6 @@ +pub mod api_key_info_conversion; +pub mod create_api_key_handler; +pub mod delete_api_key_handler; +pub mod get_api_key_handler; +pub mod list_api_keys_handler; +pub mod update_api_key_handler; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/update_api_key_handler.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/update_api_key_handler.rs new file mode 100644 index 0000000000..30c2ff7c67 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/api_keys/update_api_key_handler.rs @@ -0,0 +1,92 @@ +use std::marker::PhantomData; +use std::sync::Arc; + +use actix_web::web::{Json, Path}; +use actix_web::{web, HttpRequest}; +use log::warn; + +use artcraft_api_defs::api_keys::common::ApiKeyPathInfo; +use artcraft_api_defs::api_keys::update_api_key::{UpdateApiKeyRequest, UpdateApiKeySuccessResponse}; +use http_server_common::request::get_request_ip::get_request_ip; +use mysql_queries::queries::api_keys::get_api_key_by_token::{ + get_api_key_by_token, GetApiKeyByTokenArgs, +}; +use mysql_queries::queries::api_keys::update_api_key::{update_api_key, UpdateApiKeyArgs}; +use tokens::tokens::api_keys::ApiKeyToken; + +use crate::http_server::common_responses::common_web_error::CommonWebError; +use crate::http_server::web_utils::user_session::require_user_session::require_user_session; +use crate::state::server_state::ServerState; + +const MAX_DESCRIPTION_LEN: usize = 512; + +/// Update an API key's description (also refreshing its update IP), scoped to +/// the logged-in user. `NotFound` if the key doesn't exist, belongs to another +/// user, or has been deleted. +#[utoipa::path( + put, + tag = "API Keys", + path = "/v1/api_keys/{api_key_token}", + params(("api_key_token" = ApiKeyToken, description = "The API key's token (not the secret value)")), + request_body = UpdateApiKeyRequest, + responses( + (status = 200, body = UpdateApiKeySuccessResponse), + (status = 400, body = CommonWebError), + (status = 401, body = CommonWebError), + (status = 404, body = CommonWebError), + (status = 500, body = CommonWebError), + ), +)] +pub async fn update_api_key_handler( + http_request: HttpRequest, + path: Path, + request: Json, + server_state: web::Data>, +) -> Result, CommonWebError> { + let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| { + warn!("MySQL pool error: {:?}", err); + CommonWebError::from_error(err) + })?; + + let user_session = require_user_session(&http_request, &server_state.session_checker, &mut *conn).await?; + + if let Some(description) = &request.maybe_description { + if description.len() > MAX_DESCRIPTION_LEN { + return Err(CommonWebError::BadInputWithSimpleMessage( + format!("description too long (max {} chars)", MAX_DESCRIPTION_LEN), + )); + } + } + + // Confirm the key exists and is owned by this user before updating (the + // update query isn't owner-scoped). NotFound for missing/other-owned keys. + let existing = get_api_key_by_token(GetApiKeyByTokenArgs { + token: &path.api_key_token, + mysql_executor: &mut *conn, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("get_api_key_by_token failed: {:?}", err); + CommonWebError::from_error(err) + })? + .filter(|row| row.owner_user_token == user_session.user_token) + .ok_or(CommonWebError::NotFound)?; + + if existing.maybe_deleted_at.is_some() { + return Err(CommonWebError::NotFound); + } + + let ip_address = get_request_ip(&http_request); + + update_api_key(UpdateApiKeyArgs { + token: &path.api_key_token, + maybe_description: request.maybe_description.as_deref(), + ip_address: &ip_address, + mysql_executor: &mut *conn, + phantom: PhantomData, + }).await.map_err(|err| { + warn!("update_api_key failed: {:?}", err); + CommonWebError::from_error(err) + })?; + + Ok(Json(UpdateApiKeySuccessResponse { success: true })) +} diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs index 0a7ea04bc1..4d593ba4d7 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/mod.rs @@ -1,4 +1,5 @@ pub mod analytics; +pub mod api_keys; pub mod app_state; pub mod beta_keys; pub mod billing_fakeyou; diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs index 8983a3842d..e3051809a7 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/add_application_routes.rs @@ -1,4 +1,5 @@ use crate::http_server::routes::application_routes::analytics_routes::add_analytics_routes; +use crate::http_server::routes::application_routes::api_keys_routes::add_api_keys_routes; use crate::http_server::routes::application_routes::billing_fakeyou_routes::add_billing_fakeyou_routes; use crate::http_server::routes::application_routes::character_routes::add_character_routes; use crate::http_server::routes::application_routes::comments_routes::add_comments_routes; @@ -57,6 +58,7 @@ where // Media files routes app = add_media_file_routes(app); // /v1/media_files/... app = add_folders_routes(app); // /v1/folders/... + app = add_api_keys_routes(app); // /v1/api_keys/... app = add_featured_item_routes(app); // /v1/featured_item/... app = add_prompts_routes(app); // /v1/prompts/... diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/api_keys_routes.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/api_keys_routes.rs new file mode 100644 index 0000000000..c808e7435b --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/api_keys_routes.rs @@ -0,0 +1,46 @@ +use actix_http::body::MessageBody; +use actix_service::ServiceFactory; +use actix_web::dev::{ServiceRequest, ServiceResponse}; +use actix_web::{web, App, Error, HttpResponse}; + +use crate::http_server::endpoints::api_keys::create_api_key_handler::create_api_key_handler; +use crate::http_server::endpoints::api_keys::delete_api_key_handler::delete_api_key_handler; +use crate::http_server::endpoints::api_keys::get_api_key_handler::get_api_key_handler; +use crate::http_server::endpoints::api_keys::list_api_keys_handler::list_api_keys_handler; +use crate::http_server::endpoints::api_keys::update_api_key_handler::update_api_key_handler; + +pub fn add_api_keys_routes(app: App) -> App +where + B: MessageBody, + T: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = Error, + InitError = (), + >, +{ + app.service( + web::scope("/v1/api_keys") + // NB: Static routes are registered BEFORE the dynamic `/{api_key_token}` + // route so they take precedence. API key tokens are always `api_key_…`, + // so they can never collide with these literals anyway. + .service( + web::resource("/create") + .route(web::post().to(create_api_key_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ) + .service( + web::resource("/list") + .route(web::get().to(list_api_keys_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ) + .service( + web::resource("/{api_key_token}") + .route(web::get().to(get_api_key_handler)) + .route(web::delete().to(delete_api_key_handler)) + .route(web::put().to(update_api_key_handler)) + .route(web::head().to(|| HttpResponse::Ok())), + ), + ) +} diff --git a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs index d22ececf7e..e7f8b9a836 100644 --- a/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/routes/application_routes/mod.rs @@ -1,4 +1,5 @@ mod analytics_routes; +mod api_keys_routes; mod billing_fakeyou_routes; mod character_routes; mod comments_routes; diff --git a/crates/service/web/storyteller_web/src/util/generate_api_key.rs b/crates/service/web/storyteller_web/src/util/generate_api_key.rs new file mode 100644 index 0000000000..e6e486a3e8 --- /dev/null +++ b/crates/service/web/storyteller_web/src/util/generate_api_key.rs @@ -0,0 +1,114 @@ +use rand::Rng; + +/// All Artcraft API keys begin with this prefix. It counts toward the total key length. +/// +/// The underscores in this literal are the ONLY underscores permitted in a key — the random +/// suffix is pure Crockford-lowercase alphanumeric. (Well-known keys do this too: Stripe uses +/// `sk_live_`, GitHub `ghp_`, etc. — a readable prefix plus a long random body.) +const API_KEY_PREFIX: &str = "artcraft_api_"; + +/// Crockford base32 lowercase alphabet — 32 symbols, deliberately excluding the ambiguous +/// `i`, `l`, `o`, `u`. Every symbol is alphanumeric, so keys never contain `.`, `+`, `/`, `_`, +/// or any other punctuation in the random body. Each symbol carries log2(32) = 5 bits. +/// +/// NB: Unlike Stripe/GitHub keys (which use a full base-36 `0-9a-z` alphabet), ours is Crockford +/// so the body is unambiguous to read/transcribe. +const CROCKFORD_LOWERCASE_CHARSET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; + +/// Number of random Crockford characters in the entropy suffix. +/// +/// 40 symbols * 5 bits = **200 bits** of entropy — far above the ~128-bit cryptographic-security +/// floor, and ahead of a typical Stripe secret key (~24 base-36 chars ≈ 124 bits). The total key +/// length is therefore `API_KEY_PREFIX.len() + 40` = 53 characters. +const ENTROPY_CHAR_COUNT: usize = 40; + +/// Generate a cryptographically-secure Artcraft API key, e.g. +/// `artcraft_api_3k7q9w0xv2hs5n8m4d6r1t...` (40 Crockford chars after the prefix). +/// +/// Infallible: it draws from the thread-local CSPRNG (`rand::rng()`, a ChaCha-based, OS-seeded +/// generator), which never fails. +pub fn generate_api_key() -> String { + let mut rng = rand::rng(); + + let mut key = String::with_capacity(API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT); + key.push_str(API_KEY_PREFIX); + + for _ in 0..ENTROPY_CHAR_COUNT { + let index = rng.random_range(0..CROCKFORD_LOWERCASE_CHARSET.len()); + key.push(CROCKFORD_LOWERCASE_CHARSET[index] as char); + } + + key +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use super::*; + + #[test] + fn starts_with_prefix() { + assert!(generate_api_key().starts_with("artcraft_api_")); + assert!(generate_api_key().starts_with(API_KEY_PREFIX)); + } + + #[test] + fn has_expected_total_length() { + assert_eq!(generate_api_key().len(), API_KEY_PREFIX.len() + ENTROPY_CHAR_COUNT); + } + + #[test] + fn entropy_suffix_is_crockford_lowercase() { + let key = generate_api_key(); + let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); + + assert_eq!(suffix.len(), ENTROPY_CHAR_COUNT); + for byte in suffix.bytes() { + assert!( + CROCKFORD_LOWERCASE_CHARSET.contains(&byte), + "unexpected character {:?} in entropy suffix", + byte as char, + ); + } + // Crockford excludes the ambiguous i, l, o, u. + assert!(!suffix.contains(['i', 'l', 'o', 'u'])); + } + + #[test] + fn forbidden_characters_are_absent() { + let key = generate_api_key(); + for forbidden in ['.', '+', '/'] { + assert!(!key.contains(forbidden), "key must not contain {:?}", forbidden); + } + } + + #[test] + fn underscores_only_appear_in_the_prefix() { + let key = generate_api_key(); + let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); + assert!(!suffix.contains('_'), "the random body must not contain underscores"); + } + + #[test] + fn entropy_suffix_is_alphanumeric() { + let key = generate_api_key(); + let suffix = key.strip_prefix(API_KEY_PREFIX).expect("key must start with the prefix"); + assert!(suffix.chars().all(|c| c.is_ascii_alphanumeric())); + } + + #[test] + fn entropy_is_cryptographically_sufficient() { + // 32-symbol alphabet => 5 bits per character. Require a comfortable margin over 128 bits. + let bits = ENTROPY_CHAR_COUNT * 5; + assert!(bits >= 128, "insufficient entropy: {bits} bits"); + } + + #[test] + fn keys_are_unique() { + let mut seen = HashSet::new(); + for _ in 0..1000 { + assert!(seen.insert(generate_api_key()), "generated a duplicate key"); + } + } +} diff --git a/crates/service/web/storyteller_web/src/util/mod.rs b/crates/service/web/storyteller_web/src/util/mod.rs index 3dbe31fbd1..54665af215 100644 --- a/crates/service/web/storyteller_web/src/util/mod.rs +++ b/crates/service/web/storyteller_web/src/util/mod.rs @@ -7,6 +7,7 @@ pub mod cleaners; pub mod delete_role_disambiguation; pub mod encrypted_sort_id; pub mod enroll_in_studio; +pub mod generate_api_key; pub mod http_download_url_to_bytes; pub mod http_download_url_to_tempfile; pub mod lookup;