mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-08 02:52:12 +00:00
mcp sessions (#1917)
This commit is contained in:
1 parent
bde7760080
commit
44ff9ce338
72 files changed
+3128
-176
No files matched your search
+135
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: McpSessionToken`,\n user_token as `user_token: UserToken`,\n maybe_mcp_client_name,\n maybe_mcp_client_version,\n maybe_mcp_client_vendor,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n expires_at,\n maybe_deleted_at\nFROM mcp_sessions\nWHERE user_token = ?\nORDER BY id DESC\nLIMIT ? OFFSET ?\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: McpSessionToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "maybe_mcp_client_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_mcp_client_version",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_mcp_client_vendor",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "ip_address_creation",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | 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": "expires_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_deleted_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5ad1951163b15a9e241f949f2e66cde830d51d70b395f93000bc47ecb9c2083f"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE mcp_sessions\nSET maybe_deleted_at = NOW()\nWHERE private_session_token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "827eb468e1f732f72e0dc8f655add2b50f12cb933bba230a1fd4abb594a1944a"
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n users.token as `user_token: UserToken`,\n users.username,\n users.display_name,\n users.email_address,\n users.user_role_slug,\n users.is_banned,\n user_roles.can_ban_users,\n users.maybe_feature_flags,\n mcp_sessions.token as `mcp_session_token: McpSessionToken`\nFROM mcp_sessions\nINNER JOIN users\n ON users.token = mcp_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE mcp_sessions.private_session_token = ?\n AND mcp_sessions.maybe_deleted_at IS NULL\n AND mcp_sessions.expires_at > NOW()\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "username",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "display_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 80
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "email_address",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "user_role_slug",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 64
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "is_banned",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "NOT_NULL",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "can_ban_users",
|
||||
"type_info": {
|
||||
"type": "Tiny",
|
||||
"flags": "",
|
||||
"char_set": 63,
|
||||
"max_size": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "maybe_feature_flags",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "mcp_session_token: McpSessionToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "847acba63c2ee29b5d59bea4cdd88f12ec4b3e8a4317ab26a1e9039c0b66a1b2"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE mcp_sessions\nSET maybe_deleted_at = NOW()\nWHERE token = ?\n AND maybe_deleted_at IS NULL\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8b61abdfcfcd5c601489521793ef5c8cbe47f408e5c7e9c9a6bf01c9c8a57247"
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nSELECT\n token as `token: McpSessionToken`,\n user_token as `user_token: UserToken`,\n maybe_mcp_client_name,\n maybe_mcp_client_version,\n maybe_mcp_client_vendor,\n ip_address_creation,\n ip_address_update,\n created_at,\n updated_at,\n expires_at,\n maybe_deleted_at\nFROM mcp_sessions\nWHERE token = ?\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token: McpSessionToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "user_token: UserToken",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"char_set": 224,
|
||||
"max_size": 128
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "maybe_mcp_client_name",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "maybe_mcp_client_version",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "maybe_mcp_client_vendor",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "BINARY",
|
||||
"char_set": 224,
|
||||
"max_size": 1020
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "ip_address_creation",
|
||||
"type_info": {
|
||||
"type": "VarString",
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | 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": "expires_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "NOT_NULL | BINARY | TIMESTAMP",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "maybe_deleted_at",
|
||||
"type_info": {
|
||||
"type": "Timestamp",
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"char_set": 63,
|
||||
"max_size": 19
|
||||
}
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c59cea297d441b4960074802809af5ff0bdf8969abb34c01101584bffd887010"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nUPDATE mcp_sessions\nSET\n expires_at = NOW() + INTERVAL ? DAY,\n private_refresh_token = ?,\n ip_address_update = ?\nWHERE private_session_token = ?\n AND private_refresh_token = ?\n AND maybe_deleted_at IS NULL\n AND expires_at > NOW()\nLIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "ed8f7ef94099d2dddf50f819316485351e149db72ce8a4f5cff6b57f4b84244d"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"query": "\nINSERT INTO mcp_sessions\nSET\n token = ?,\n private_session_token = ?,\n private_refresh_token = ?,\n user_token = ?,\n maybe_mcp_client_name = ?,\n maybe_mcp_client_version = ?,\n maybe_mcp_client_vendor = ?,\n ip_address_creation = ?,\n ip_address_update = ?,\n created_at = NOW(),\n expires_at = NOW() + INTERVAL ? DAY\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 10
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "f67a73ea3d5751cc76d2d6f954bd939179b3e9005a359e04c3a4dd78f8fe562e"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS mcp_sessions;
|
||||
@@ -0,0 +1,65 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Sessions for MCP and other non-human users.
|
||||
-- These work like `user_sessions`, but are created and consumed by MCP clients.
|
||||
CREATE TABLE mcp_sessions (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Non-private "primary key" for the session.
|
||||
-- Used as a key to delete/manage sessions in the dashboard.
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Private entropy presented by clients; used for lookup of live sessions.
|
||||
private_session_token VARCHAR(64) NOT NULL,
|
||||
|
||||
-- Private refresh credential. Presenting this on the refresh endpoint extends the
|
||||
-- session's expiry and ROTATES this value — the previous refresh token becomes invalid.
|
||||
private_refresh_token VARCHAR(64) NOT NULL,
|
||||
|
||||
-- The user that the session belongs to.
|
||||
user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== CLIENT INFO ==========
|
||||
|
||||
-- Trimmed name of the MCP client, if any.
|
||||
maybe_mcp_client_name VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Trimmed version of the MCP client, if any.
|
||||
maybe_mcp_client_version VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Trimmed vendor of the MCP client, if any.
|
||||
maybe_mcp_client_vendor VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that created the session. Wide enough for IPv4/IPv6.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the session. 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,
|
||||
|
||||
-- When the session expires, if not refreshed.
|
||||
-- This must be set by the server code, or the session is invalid.
|
||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Soft-delete timestamp. NULL means the session is live.
|
||||
-- deletion = session termination
|
||||
maybe_deleted_at TIMESTAMP NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (private_session_token),
|
||||
UNIQUE KEY (private_refresh_token),
|
||||
KEY fk_user_token (user_token),
|
||||
KEY index_ip_address_creation (ip_address_creation),
|
||||
KEY index_maybe_deleted_at (maybe_deleted_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -0,0 +1,65 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- Sessions for MCP and other non-human users.
|
||||
-- These work like `user_sessions`, but are created and consumed by MCP clients.
|
||||
CREATE TABLE mcp_sessions (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Non-private "primary key" for the session.
|
||||
-- Used as a key to delete/manage sessions in the dashboard.
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- Private entropy presented by clients; used for lookup of live sessions.
|
||||
private_session_token VARCHAR(64) NOT NULL,
|
||||
|
||||
-- Private refresh credential. Presenting this on the refresh endpoint extends the
|
||||
-- session's expiry and ROTATES this value — the previous refresh token becomes invalid.
|
||||
private_refresh_token VARCHAR(64) NOT NULL,
|
||||
|
||||
-- The user that the session belongs to.
|
||||
user_token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- ========== CLIENT INFO ==========
|
||||
|
||||
-- Trimmed name of the MCP client, if any.
|
||||
maybe_mcp_client_name VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Trimmed version of the MCP client, if any.
|
||||
maybe_mcp_client_version VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- Trimmed vendor of the MCP client, if any.
|
||||
maybe_mcp_client_vendor VARCHAR(255) DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- IP address that created the session. Wide enough for IPv4/IPv6.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- IP address that last updated the session. 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,
|
||||
|
||||
-- When the session expires, if not refreshed.
|
||||
-- This must be set by the server code, or the session is invalid.
|
||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
-- Soft-delete timestamp. NULL means the session is live.
|
||||
-- deletion = session termination
|
||||
maybe_deleted_at TIMESTAMP NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (private_session_token),
|
||||
UNIQUE KEY (private_refresh_token),
|
||||
KEY fk_user_token (user_token),
|
||||
KEY index_ip_address_creation (ip_address_creation),
|
||||
KEY index_maybe_deleted_at (maybe_deleted_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -7,6 +7,7 @@ pub mod folders;
|
||||
pub mod generate;
|
||||
pub mod internal;
|
||||
pub mod jobs;
|
||||
pub mod mcp_sessions;
|
||||
pub mod media_file;
|
||||
pub mod moderation;
|
||||
pub mod omni_api;
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::mcp_sessions::McpSessionToken;
|
||||
|
||||
/// Canonical wire shape for an MCP session, used by the dashboard list.
|
||||
///
|
||||
/// NB: the secret `private_session_token` is NEVER included here — it is returned exactly once,
|
||||
/// at creation time. The `token` is the non-private management handle.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct McpSessionInfo {
|
||||
pub token: McpSessionToken,
|
||||
|
||||
/// Trimmed name of the MCP client that created the session, if any.
|
||||
pub maybe_mcp_client_name: Option<String>,
|
||||
|
||||
/// Trimmed version of the MCP client, if any.
|
||||
pub maybe_mcp_client_version: Option<String>,
|
||||
|
||||
/// Trimmed vendor of the MCP client, if any.
|
||||
pub maybe_mcp_client_vendor: Option<String>,
|
||||
|
||||
pub ip_address_creation: String,
|
||||
pub ip_address_update: String,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
|
||||
/// When the session expires, if not refreshed.
|
||||
pub expires_at: DateTime<Utc>,
|
||||
|
||||
/// Termination timestamp. `None` for live (though possibly expired) sessions.
|
||||
pub maybe_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Path info for endpoints addressed by the session's management `token` (NOT the secret
|
||||
/// `private_session_token`): `POST /v1/mcp/session/{token}/delete`.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct McpSessionPathInfo {
|
||||
pub token: McpSessionToken,
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
|
||||
use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken;
|
||||
|
||||
// ── POST /v1/mcp/session/create ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CreateMcpSessionRequest {
|
||||
/// Name of the MCP client creating the session, if any.
|
||||
pub maybe_mcp_client_name: Option<String>,
|
||||
|
||||
/// Version of the MCP client, if any.
|
||||
pub maybe_mcp_client_version: Option<String>,
|
||||
|
||||
/// Vendor of the MCP client, if any.
|
||||
pub maybe_mcp_client_vendor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct CreateMcpSessionSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The secret credential the MCP client presents in the `Authorization` header. This is the
|
||||
/// ONLY time it is ever returned — the caller must store it now, as it cannot be retrieved
|
||||
/// again.
|
||||
pub private_session_token: McpSessionPrivateToken,
|
||||
|
||||
/// The secret credential the MCP client presents on `/v1/mcp/session/refresh` to extend the
|
||||
/// session. Each successful refresh returns a NEW refresh token and invalidates this one —
|
||||
/// store whichever value is newest.
|
||||
pub private_refresh_token: McpSessionRefreshToken,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// ── POST /v1/mcp/session/{token}/delete ──
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct DeleteMcpSessionSuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::mcp_sessions::common::McpSessionInfo;
|
||||
|
||||
// ── GET /v1/mcp/session/list ──
|
||||
|
||||
#[derive(Deserialize, ToSchema, IntoParams)]
|
||||
pub struct ListMcpSessionsQueryParams {
|
||||
/// Page size. Defaults server-side if omitted.
|
||||
pub limit: Option<u32>,
|
||||
|
||||
/// Number of rows to skip. Defaults to 0.
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct ListMcpSessionsSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The user's MCP sessions (including terminated ones), newest first. The secret
|
||||
/// `private_session_token` is never listed.
|
||||
pub mcp_sessions: Vec<McpSessionInfo>,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod common;
|
||||
pub mod create_mcp_session;
|
||||
pub mod delete_mcp_session;
|
||||
pub mod list_mcp_sessions;
|
||||
pub mod refresh_mcp_session;
|
||||
pub mod revoke_mcp_session;
|
||||
@@ -0,0 +1,22 @@
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken;
|
||||
|
||||
// ── POST /v1/mcp/session/refresh ──
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct RefreshMcpSessionRequest {
|
||||
/// The session's CURRENT refresh token (from creation, or from the most recent refresh).
|
||||
/// A stale or wrong value is an error — refresh tokens rotate and are never replayable.
|
||||
pub private_refresh_token: McpSessionRefreshToken,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct RefreshMcpSessionSuccessResponse {
|
||||
pub success: bool,
|
||||
|
||||
/// The NEW refresh token to present on the next refresh. The one just used is now invalid —
|
||||
/// the caller must store this replacement.
|
||||
pub private_refresh_token: McpSessionRefreshToken,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
use serde_derive::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
// ── POST /v1/mcp/session/revoke ──
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
pub struct RevokeMcpSessionSuccessResponse {
|
||||
pub success: bool,
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::mcp_sessions::McpSessionToken;
|
||||
|
||||
pub struct DeleteMcpSessionArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e McpSessionToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Terminate a session by its management `token` (the dashboard path), stamping
|
||||
/// `maybe_deleted_at = NOW()`. Returns the number of rows affected (0 if no live session
|
||||
/// matched the token). Ownership is NOT checked here — callers verify it first via
|
||||
/// `get_mcp_session_by_token`.
|
||||
pub async fn delete_mcp_session<'e, 'c: 'e, E>(
|
||||
args: DeleteMcpSessionArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE mcp_sessions
|
||||
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())
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::mcp_sessions::McpSessionToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::mcp_sessions::mcp_session_row::McpSessionRow;
|
||||
|
||||
pub struct GetMcpSessionByTokenArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub token: &'e McpSessionToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Fetch a session by its management `token` (NOT the private credential), including terminated
|
||||
/// sessions — callers check ownership and `maybe_deleted_at` themselves. Never returns the
|
||||
/// `private_session_token`.
|
||||
pub async fn get_mcp_session_by_token<'e, 'c: 'e, E>(
|
||||
args: GetMcpSessionByTokenArgs<'e, 'c, E>,
|
||||
) -> Result<Option<McpSessionRow>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let maybe_row = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
token as `token: McpSessionToken`,
|
||||
user_token as `user_token: UserToken`,
|
||||
maybe_mcp_client_name,
|
||||
maybe_mcp_client_version,
|
||||
maybe_mcp_client_vendor,
|
||||
ip_address_creation,
|
||||
ip_address_update,
|
||||
created_at,
|
||||
updated_at,
|
||||
expires_at,
|
||||
maybe_deleted_at
|
||||
FROM mcp_sessions
|
||||
WHERE token = ?
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.token.as_str(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(maybe_row.map(|r| McpSessionRow {
|
||||
token: r.token,
|
||||
user_token: r.user_token,
|
||||
maybe_mcp_client_name: r.maybe_mcp_client_name,
|
||||
maybe_mcp_client_version: r.maybe_mcp_client_version,
|
||||
maybe_mcp_client_vendor: r.maybe_mcp_client_vendor,
|
||||
ip_address_creation: r.ip_address_creation,
|
||||
ip_address_update: r.ip_address_update,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
expires_at: r.expires_at,
|
||||
maybe_deleted_at: r.maybe_deleted_at,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
|
||||
use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken;
|
||||
use tokens::tokens::mcp_sessions::McpSessionToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// How long a freshly created (or refreshed) MCP session lives without a refresh.
|
||||
pub const MCP_SESSION_LIFETIME_DAYS: u16 = 14;
|
||||
|
||||
pub struct InsertMcpSessionArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub user_token: &'e UserToken,
|
||||
pub ip_address: &'e str,
|
||||
pub maybe_mcp_client_name: Option<&'e str>,
|
||||
pub maybe_mcp_client_version: Option<&'e str>,
|
||||
pub maybe_mcp_client_vendor: Option<&'e str>,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// The tokens minted by [`insert_mcp_session`]. The private session and refresh tokens are
|
||||
/// handed to the caller exactly once, here — they are never selected back out of the database.
|
||||
pub struct InsertedMcpSession {
|
||||
pub token: McpSessionToken,
|
||||
pub private_session_token: McpSessionPrivateToken,
|
||||
pub private_refresh_token: McpSessionRefreshToken,
|
||||
}
|
||||
|
||||
/// Insert a new MCP session. The management `token`, the secret `private_session_token`, and
|
||||
/// the initial `private_refresh_token` are all 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 the session expires [`MCP_SESSION_LIFETIME_DAYS`] days out from
|
||||
/// MySQL `NOW()` unless refreshed.
|
||||
pub async fn insert_mcp_session<'e, 'c: 'e, E>(
|
||||
args: InsertMcpSessionArgs<'e, 'c, E>,
|
||||
) -> Result<InsertedMcpSession, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let token = McpSessionToken::generate();
|
||||
let private_session_token = McpSessionPrivateToken::generate();
|
||||
let private_refresh_token = McpSessionRefreshToken::generate();
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO mcp_sessions
|
||||
SET
|
||||
token = ?,
|
||||
private_session_token = ?,
|
||||
private_refresh_token = ?,
|
||||
user_token = ?,
|
||||
maybe_mcp_client_name = ?,
|
||||
maybe_mcp_client_version = ?,
|
||||
maybe_mcp_client_vendor = ?,
|
||||
ip_address_creation = ?,
|
||||
ip_address_update = ?,
|
||||
created_at = NOW(),
|
||||
expires_at = NOW() + INTERVAL ? DAY
|
||||
"#,
|
||||
token.as_str(),
|
||||
private_session_token.as_str(),
|
||||
private_refresh_token.as_str(),
|
||||
args.user_token.as_str(),
|
||||
args.maybe_mcp_client_name,
|
||||
args.maybe_mcp_client_version,
|
||||
args.maybe_mcp_client_vendor,
|
||||
args.ip_address,
|
||||
args.ip_address,
|
||||
MCP_SESSION_LIFETIME_DAYS,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(InsertedMcpSession {
|
||||
token,
|
||||
private_session_token,
|
||||
private_refresh_token,
|
||||
})
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::mcp_sessions::McpSessionToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::queries::mcp_sessions::mcp_session_row::McpSessionRow;
|
||||
|
||||
pub struct ListMcpSessionsForUserArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub 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 MCP sessions, newest first, **including terminated** sessions (each row carries
|
||||
/// `maybe_deleted_at` and `expires_at` so callers can filter). Never returns the secret
|
||||
/// `private_session_token`.
|
||||
pub async fn list_mcp_sessions_for_user<'e, 'c: 'e, E>(
|
||||
args: ListMcpSessionsForUserArgs<'e, 'c, E>,
|
||||
) -> Result<Vec<McpSessionRow>, 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: McpSessionToken`,
|
||||
user_token as `user_token: UserToken`,
|
||||
maybe_mcp_client_name,
|
||||
maybe_mcp_client_version,
|
||||
maybe_mcp_client_vendor,
|
||||
ip_address_creation,
|
||||
ip_address_update,
|
||||
created_at,
|
||||
updated_at,
|
||||
expires_at,
|
||||
maybe_deleted_at
|
||||
FROM mcp_sessions
|
||||
WHERE user_token = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"#,
|
||||
args.user_token.as_str(),
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
.fetch_all(args.mysql_executor)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| McpSessionRow {
|
||||
token: r.token,
|
||||
user_token: r.user_token,
|
||||
maybe_mcp_client_name: r.maybe_mcp_client_name,
|
||||
maybe_mcp_client_version: r.maybe_mcp_client_version,
|
||||
maybe_mcp_client_vendor: r.maybe_mcp_client_vendor,
|
||||
ip_address_creation: r.ip_address_creation,
|
||||
ip_address_update: r.ip_address_update,
|
||||
created_at: r.created_at,
|
||||
updated_at: r.updated_at,
|
||||
expires_at: r.expires_at,
|
||||
maybe_deleted_at: r.maybe_deleted_at,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(rows)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
use tokens::tokens::mcp_sessions::McpSessionToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A materialized `mcp_sessions` row, minus the internal `id` and the secret
|
||||
/// `private_session_token`. The private credential is returned exactly once, at creation time,
|
||||
/// and is never selected back out of the database.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct McpSessionRow {
|
||||
pub token: McpSessionToken,
|
||||
|
||||
pub user_token: UserToken,
|
||||
|
||||
pub maybe_mcp_client_name: Option<String>,
|
||||
pub maybe_mcp_client_version: Option<String>,
|
||||
pub maybe_mcp_client_vendor: Option<String>,
|
||||
|
||||
pub ip_address_creation: String,
|
||||
pub ip_address_update: String,
|
||||
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
|
||||
/// When the session expires, if not refreshed.
|
||||
pub expires_at: DateTime<Utc>,
|
||||
|
||||
/// Soft-delete (termination) timestamp. `None` for live sessions.
|
||||
pub maybe_deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
pub mod delete_mcp_session;
|
||||
pub mod get_mcp_session_by_token;
|
||||
pub mod insert_mcp_session;
|
||||
pub mod list_mcp_sessions_for_user;
|
||||
pub mod mcp_session_row;
|
||||
pub mod refresh_mcp_session;
|
||||
pub mod revoke_mcp_session;
|
||||
@@ -0,0 +1,70 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
|
||||
use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken;
|
||||
|
||||
use crate::queries::mcp_sessions::insert_mcp_session::MCP_SESSION_LIFETIME_DAYS;
|
||||
|
||||
pub struct RefreshMcpSessionArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub private_session_token: &'e McpSessionPrivateToken,
|
||||
|
||||
/// The session's CURRENT refresh credential. A stale or wrong value refreshes nothing.
|
||||
pub private_refresh_token: &'e McpSessionRefreshToken,
|
||||
|
||||
/// Stamped on `ip_address_update`.
|
||||
pub ip_address: &'e str,
|
||||
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Push a live session's expiry [`MCP_SESSION_LIFETIME_DAYS`] days out from MySQL `NOW()` and
|
||||
/// ROTATE its refresh credential (`updated_at` bumps automatically). Both the session
|
||||
/// credential and the current refresh token must match a live, unexpired session — otherwise
|
||||
/// nothing refreshes and `Ok(None)` is returned.
|
||||
///
|
||||
/// The rotation is deliberately NOT idempotent: each success mints (here, in the data-access
|
||||
/// layer) and returns a new refresh token, and the previous one is gone. Callers retrying a
|
||||
/// refresh whose response they lost will get an error, not a replay — we don't store the
|
||||
/// history of refresh tokens.
|
||||
pub async fn refresh_mcp_session<'e, 'c: 'e, E>(
|
||||
args: RefreshMcpSessionArgs<'e, 'c, E>,
|
||||
) -> Result<Option<McpSessionRefreshToken>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let new_refresh_token = McpSessionRefreshToken::generate();
|
||||
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE mcp_sessions
|
||||
SET
|
||||
expires_at = NOW() + INTERVAL ? DAY,
|
||||
private_refresh_token = ?,
|
||||
ip_address_update = ?
|
||||
WHERE private_session_token = ?
|
||||
AND private_refresh_token = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
AND expires_at > NOW()
|
||||
LIMIT 1
|
||||
"#,
|
||||
MCP_SESSION_LIFETIME_DAYS,
|
||||
new_refresh_token.as_str(),
|
||||
args.ip_address,
|
||||
args.private_session_token.as_str(),
|
||||
args.private_refresh_token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(new_refresh_token))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
|
||||
|
||||
pub struct RevokeMcpSessionArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
pub private_session_token: &'e McpSessionPrivateToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Terminate a session by its own private credential, stamping `maybe_deleted_at = NOW()`.
|
||||
/// An expired-but-undeleted session may still revoke itself (the end state is the same either
|
||||
/// way). Returns the number of rows affected (0 = unknown or already-terminated session).
|
||||
pub async fn revoke_mcp_session<'e, 'c: 'e, E>(
|
||||
args: RevokeMcpSessionArgs<'e, 'c, E>,
|
||||
) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE mcp_sessions
|
||||
SET maybe_deleted_at = NOW()
|
||||
WHERE private_session_token = ?
|
||||
AND maybe_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.private_session_token.as_str(),
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
@@ -22,6 +22,7 @@ pub mod google_sign_in_accounts;
|
||||
pub mod health_check;
|
||||
pub mod idepotency_tokens;
|
||||
pub mod ip_bans;
|
||||
pub mod mcp_sessions;
|
||||
pub mod media_files;
|
||||
pub mod media_uploads;
|
||||
pub mod model_categories;
|
||||
|
||||
+10
-4
@@ -1,11 +1,13 @@
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::mcp_sessions::McpSessionToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
/// A user authenticated by either an API key or a web session cookie.
|
||||
/// A user authenticated by an API key, a web session cookie, or an MCP session credential.
|
||||
///
|
||||
/// The two lookup queries (`get_api_or_web_session_user_by_api_key` and
|
||||
/// `get_api_or_web_session_user_by_session_token`) select congruent user fields so both can
|
||||
/// produce this one uniform record.
|
||||
/// The lookup queries (`get_api_or_web_session_user_by_api_key`,
|
||||
/// `get_api_or_web_session_user_by_session_token`, and
|
||||
/// `get_api_or_web_session_user_by_mcp_private_session_token`) select congruent user fields so
|
||||
/// all of them can produce this one uniform record.
|
||||
pub struct ApiOrWebSessionUserRecord {
|
||||
pub user_token: UserToken,
|
||||
pub username: String,
|
||||
@@ -21,4 +23,8 @@ pub struct ApiOrWebSessionUserRecord {
|
||||
|
||||
/// Only set when the lookup was by API key.
|
||||
pub maybe_api_key_token: Option<ApiKeyToken>,
|
||||
|
||||
/// Only set when the lookup was by MCP session credential. This is the session's non-private
|
||||
/// management `token`, never the `private_session_token`.
|
||||
pub maybe_mcp_session_token: Option<McpSessionToken>,
|
||||
}
|
||||
+1
@@ -68,5 +68,6 @@ LIMIT 1
|
||||
can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users),
|
||||
maybe_feature_flags: r.maybe_feature_flags,
|
||||
maybe_api_key_token: Some(r.api_key_token),
|
||||
maybe_mcp_session_token: None,
|
||||
}))
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
|
||||
use tokens::tokens::mcp_sessions::McpSessionToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::helpers::boolean_converters::{i8_to_bool, nullable_i8_to_bool_default_false};
|
||||
use crate::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord;
|
||||
|
||||
pub struct GetApiOrWebSessionUserByMcpPrivateSessionTokenArgs<'e, 'c, E>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
/// The secret MCP session credential presented by the client.
|
||||
pub private_session_token: &'e McpSessionPrivateToken,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
}
|
||||
|
||||
/// Look up the user behind an MCP session's private credential, selecting the same user fields
|
||||
/// as the API-key and web-session lookups so all three auth paths share one record shape.
|
||||
///
|
||||
/// Returns `Ok(None)` when no row matches, when the session is terminated or expired, or when
|
||||
/// the owning user is missing or deleted. The private credential is never echoed back — the
|
||||
/// record carries only the session's non-private management `token`.
|
||||
pub async fn get_api_or_web_session_user_by_mcp_private_session_token<'e, 'c: 'e, E>(
|
||||
args: GetApiOrWebSessionUserByMcpPrivateSessionTokenArgs<'e, 'c, E>,
|
||||
) -> Result<Option<ApiOrWebSessionUserRecord>, sqlx::Error>
|
||||
where
|
||||
E: 'e + Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
users.token as `user_token: UserToken`,
|
||||
users.username,
|
||||
users.display_name,
|
||||
users.email_address,
|
||||
users.user_role_slug,
|
||||
users.is_banned,
|
||||
user_roles.can_ban_users,
|
||||
users.maybe_feature_flags,
|
||||
mcp_sessions.token as `mcp_session_token: McpSessionToken`
|
||||
FROM mcp_sessions
|
||||
INNER JOIN users
|
||||
ON users.token = mcp_sessions.user_token
|
||||
LEFT OUTER JOIN user_roles
|
||||
ON users.user_role_slug = user_roles.slug
|
||||
WHERE mcp_sessions.private_session_token = ?
|
||||
AND mcp_sessions.maybe_deleted_at IS NULL
|
||||
AND mcp_sessions.expires_at > NOW()
|
||||
AND users.user_deleted_at IS NULL
|
||||
AND users.mod_deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
args.private_session_token.as_str(),
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await?;
|
||||
|
||||
Ok(result.map(|r| ApiOrWebSessionUserRecord {
|
||||
user_token: r.user_token,
|
||||
username: r.username,
|
||||
display_name: r.display_name,
|
||||
email_address: r.email_address,
|
||||
user_role_slug: r.user_role_slug,
|
||||
is_banned: i8_to_bool(r.is_banned),
|
||||
can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users),
|
||||
maybe_feature_flags: r.maybe_feature_flags,
|
||||
maybe_api_key_token: None,
|
||||
maybe_mcp_session_token: Some(r.mcp_session_token),
|
||||
}))
|
||||
}
|
||||
+1
@@ -65,5 +65,6 @@ LIMIT 1
|
||||
can_ban_users: nullable_i8_to_bool_default_false(r.can_ban_users),
|
||||
maybe_feature_flags: r.maybe_feature_flags,
|
||||
maybe_api_key_token: None,
|
||||
maybe_mcp_session_token: None,
|
||||
}))
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod api_or_web_session_user_record;
|
||||
pub mod get_api_or_web_session_user_by_api_key;
|
||||
pub mod get_api_or_web_session_user_by_mcp_private_session_token;
|
||||
pub mod get_api_or_web_session_user_by_session_token;
|
||||
@@ -0,0 +1,51 @@
|
||||
//! MCP session fixtures: real `mcp_sessions` rows created via the production
|
||||
//! insert query, plus test-only mutators for states the production queries
|
||||
//! never produce (e.g. an already-expired session).
|
||||
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use anyhow::anyhow;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use mysql_queries::queries::mcp_sessions::insert_mcp_session::{
|
||||
insert_mcp_session, InsertMcpSessionArgs, InsertedMcpSession,
|
||||
};
|
||||
use tokens::tokens::mcp_sessions::McpSessionToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
const TEST_IP_ADDRESS: &str = "127.0.0.1";
|
||||
|
||||
/// Create a live MCP session for the user via the production insert query.
|
||||
/// Returns both minted tokens (the management token and the private credential).
|
||||
pub async fn create_test_mcp_session(
|
||||
pool: &MySqlPool,
|
||||
user_token: &UserToken,
|
||||
) -> anyhow::Result<InsertedMcpSession> {
|
||||
let mut connection = pool.acquire().await?;
|
||||
|
||||
insert_mcp_session(InsertMcpSessionArgs {
|
||||
user_token,
|
||||
ip_address: TEST_IP_ADDRESS,
|
||||
maybe_mcp_client_name: Some("test mcp client"),
|
||||
maybe_mcp_client_version: None,
|
||||
maybe_mcp_client_vendor: None,
|
||||
mysql_executor: &mut *connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| anyhow!("insert_mcp_session failed: {err:?}"))
|
||||
}
|
||||
|
||||
/// Push a session's `expires_at` into the past. Production queries can never
|
||||
/// produce an expired-from-birth session, so tests exercising expiry set it
|
||||
/// directly.
|
||||
pub async fn force_expire_mcp_session(
|
||||
pool: &MySqlPool,
|
||||
token: &McpSessionToken,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query("UPDATE mcp_sessions SET expires_at = NOW() - INTERVAL 1 DAY WHERE token = ?")
|
||||
.bind(token.as_str())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod mcp_sessions;
|
||||
pub mod media_files;
|
||||
pub mod users;
|
||||
pub mod wallets;
|
||||
@@ -29,6 +29,9 @@ pub enum PlatformType {
|
||||
/// Requests authenticated via an `Authorization` header API key (the Omni API surface).
|
||||
ApiKey,
|
||||
|
||||
/// Requests authenticated via an `Authorization` header MCP session credential.
|
||||
Mcp,
|
||||
|
||||
// ========== Other clients ==========
|
||||
|
||||
Curl,
|
||||
@@ -46,6 +49,7 @@ impl PlatformType {
|
||||
Self::Web => "web",
|
||||
Self::DesktopApp => "desktop_app",
|
||||
Self::ApiKey => "api_key",
|
||||
Self::Mcp => "mcp",
|
||||
Self::Curl => "curl",
|
||||
Self::PythonRequests => "python_requests",
|
||||
Self::Postman => "postman",
|
||||
@@ -57,6 +61,7 @@ impl PlatformType {
|
||||
"web" => Ok(Self::Web),
|
||||
"desktop_app" => Ok(Self::DesktopApp),
|
||||
"api_key" => Ok(Self::ApiKey),
|
||||
"mcp" => Ok(Self::Mcp),
|
||||
"curl" => Ok(Self::Curl),
|
||||
"python_requests" => Ok(Self::PythonRequests),
|
||||
"postman" => Ok(Self::Postman),
|
||||
@@ -70,6 +75,7 @@ impl PlatformType {
|
||||
Self::Web,
|
||||
Self::DesktopApp,
|
||||
Self::ApiKey,
|
||||
Self::Mcp,
|
||||
Self::Curl,
|
||||
Self::PythonRequests,
|
||||
Self::Postman,
|
||||
@@ -91,6 +97,7 @@ mod tests {
|
||||
assert_serialization(PlatformType::Web, "web");
|
||||
assert_serialization(PlatformType::DesktopApp, "desktop_app");
|
||||
assert_serialization(PlatformType::ApiKey, "api_key");
|
||||
assert_serialization(PlatformType::Mcp, "mcp");
|
||||
assert_serialization(PlatformType::Curl, "curl");
|
||||
assert_serialization(PlatformType::PythonRequests, "python_requests");
|
||||
assert_serialization(PlatformType::Postman, "postman");
|
||||
@@ -101,6 +108,7 @@ mod tests {
|
||||
assert_eq!(PlatformType::Web.to_str(), "web");
|
||||
assert_eq!(PlatformType::DesktopApp.to_str(), "desktop_app");
|
||||
assert_eq!(PlatformType::ApiKey.to_str(), "api_key");
|
||||
assert_eq!(PlatformType::Mcp.to_str(), "mcp");
|
||||
assert_eq!(PlatformType::Curl.to_str(), "curl");
|
||||
assert_eq!(PlatformType::PythonRequests.to_str(), "python_requests");
|
||||
assert_eq!(PlatformType::Postman.to_str(), "postman");
|
||||
@@ -111,6 +119,7 @@ mod tests {
|
||||
assert_eq!(PlatformType::from_str("web").unwrap(), PlatformType::Web);
|
||||
assert_eq!(PlatformType::from_str("desktop_app").unwrap(), PlatformType::DesktopApp);
|
||||
assert_eq!(PlatformType::from_str("api_key").unwrap(), PlatformType::ApiKey);
|
||||
assert_eq!(PlatformType::from_str("mcp").unwrap(), PlatformType::Mcp);
|
||||
assert_eq!(PlatformType::from_str("curl").unwrap(), PlatformType::Curl);
|
||||
assert_eq!(PlatformType::from_str("python_requests").unwrap(), PlatformType::PythonRequests);
|
||||
assert_eq!(PlatformType::from_str("postman").unwrap(), PlatformType::Postman);
|
||||
@@ -130,10 +139,11 @@ mod tests {
|
||||
#[test]
|
||||
fn all_variants() {
|
||||
let mut variants = PlatformType::all_variants();
|
||||
assert_eq!(variants.len(), 6);
|
||||
assert_eq!(variants.len(), 7);
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::Web));
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::DesktopApp));
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::ApiKey));
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::Mcp));
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::Curl));
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::PythonRequests));
|
||||
assert_eq!(variants.pop_first(), Some(PlatformType::Postman));
|
||||
|
||||
@@ -37,6 +37,9 @@ pub(crate) enum TokenPrefix {
|
||||
Folder,
|
||||
GoogleSignInAccount,
|
||||
InferenceJob,
|
||||
McpSession,
|
||||
McpSessionPrivate, // The private credential for an MCP session; carries more entropy.
|
||||
McpSessionRefresh, // The rotating refresh credential for an MCP session; carries more entropy.
|
||||
MediaFile,
|
||||
MediaUpload,
|
||||
ModelWeight,
|
||||
@@ -130,6 +133,9 @@ impl PrefixGenerator for TokenPrefix {
|
||||
Self::Folder => "folder_",
|
||||
Self::GoogleSignInAccount => "gsi_",
|
||||
Self::InferenceJob => "jinf_",
|
||||
Self::McpSession => "mcp_",
|
||||
Self::McpSessionPrivate => "mcp_session_",
|
||||
Self::McpSessionRefresh => "mcp_refresh_",
|
||||
Self::MediaFile => "m_",
|
||||
Self::MediaUpload => "mu_",
|
||||
Self::ModelWeight => "weight_",
|
||||
@@ -361,6 +367,8 @@ mod tests {
|
||||
|| prefix == "pw_reset_"
|
||||
|| prefix == "tts_task_"
|
||||
|| prefix == "app_session_"
|
||||
|| prefix == "mcp_session_"
|
||||
|| prefix == "mcp_refresh_"
|
||||
{
|
||||
// TODO/FIXME: I'm too tired at 5AM to replacen from the left. Make this test valid.
|
||||
// These tokens are from the AIChatBot sidecar, so asserting their validity is less important.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::prefixes::TokenPrefix;
|
||||
|
||||
/// The private credential for an `mcp_sessions` row (the `private_session_token` column).
|
||||
///
|
||||
/// This is the secret an MCP client presents in the `Authorization` header to authenticate as a
|
||||
/// live session, so it carries more entropy than an ordinary token. Never list or display it
|
||||
/// after creation. The non-private management handle is `McpSessionToken` (see
|
||||
/// `mcp_sessions.rs`).
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[cfg_attr(feature = "database", derive(sqlx::Type))]
|
||||
#[cfg_attr(feature = "database", sqlx(transparent))]
|
||||
pub struct McpSessionPrivateToken(pub String);
|
||||
|
||||
impl_string_token!(McpSessionPrivateToken);
|
||||
impl_mysql_token_from_row!(McpSessionPrivateToken);
|
||||
impl_crockford_generator!(McpSessionPrivateToken, 64usize, TokenPrefix::McpSessionPrivate, CrockfordLower);
|
||||
@@ -0,0 +1,24 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::prefixes::TokenPrefix;
|
||||
|
||||
/// The rotating refresh credential for an `mcp_sessions` row (the `private_refresh_token`
|
||||
/// column).
|
||||
///
|
||||
/// An MCP client presents this on the session-refresh endpoint to extend its session; each
|
||||
/// successful refresh ROTATES the value, invalidating the previous one. Like the session
|
||||
/// credential, it is a secret with extra entropy — never list or display it after it is handed
|
||||
/// out. The session's other credentials are `McpSessionToken` (management handle) and
|
||||
/// `McpSessionPrivateToken` (the session secret).
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[cfg_attr(feature = "database", derive(sqlx::Type))]
|
||||
#[cfg_attr(feature = "database", sqlx(transparent))]
|
||||
pub struct McpSessionRefreshToken(pub String);
|
||||
|
||||
impl_string_token!(McpSessionRefreshToken);
|
||||
impl_mysql_token_from_row!(McpSessionRefreshToken);
|
||||
impl_crockford_generator!(McpSessionRefreshToken, 64usize, TokenPrefix::McpSessionRefresh, CrockfordLower);
|
||||
@@ -0,0 +1,21 @@
|
||||
use std::fmt::Debug;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::prefixes::TokenPrefix;
|
||||
|
||||
/// The primary key for `mcp_sessions`.
|
||||
///
|
||||
/// This is the NON-PRIVATE handle for a session — safe to list and display, and used to
|
||||
/// delete/manage sessions in the dashboard. It never authenticates anything; that is the job of
|
||||
/// `McpSessionPrivateToken` (see `mcp_session_private.rs`).
|
||||
#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize, Deserialize, ToSchema)]
|
||||
#[cfg_attr(feature = "database", derive(sqlx::Type))]
|
||||
#[cfg_attr(feature = "database", sqlx(transparent))]
|
||||
pub struct McpSessionToken(pub String);
|
||||
|
||||
impl_string_token!(McpSessionToken);
|
||||
impl_mysql_token_from_row!(McpSessionToken);
|
||||
impl_crockford_generator!(McpSessionToken, 32usize, TokenPrefix::McpSession, CrockfordLower);
|
||||
@@ -15,6 +15,9 @@ pub mod folders;
|
||||
pub mod generic_download_jobs;
|
||||
pub mod generic_inference_jobs;
|
||||
pub mod google_sign_in_accounts;
|
||||
pub mod mcp_session_private;
|
||||
pub mod mcp_session_refresh;
|
||||
pub mod mcp_sessions;
|
||||
pub mod media_files;
|
||||
pub mod media_uploads;
|
||||
pub mod model_categories;
|
||||
|
||||
@@ -122,6 +122,19 @@ 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::*;
|
||||
|
||||
// MCP session endpoints
|
||||
use crate::http_server::endpoints::mcp_sessions::create_mcp_session_handler::*;
|
||||
use crate::http_server::endpoints::mcp_sessions::refresh_mcp_session_handler::*;
|
||||
use crate::http_server::endpoints::mcp_sessions::revoke_mcp_session_handler::*;
|
||||
use crate::http_server::endpoints::mcp_sessions::delete_mcp_session_handler::*;
|
||||
use crate::http_server::endpoints::mcp_sessions::list_mcp_sessions_handler::*;
|
||||
use artcraft_api_defs::mcp_sessions::common::*;
|
||||
use artcraft_api_defs::mcp_sessions::create_mcp_session::*;
|
||||
use artcraft_api_defs::mcp_sessions::refresh_mcp_session::*;
|
||||
use artcraft_api_defs::mcp_sessions::revoke_mcp_session::*;
|
||||
use artcraft_api_defs::mcp_sessions::delete_mcp_session::*;
|
||||
use artcraft_api_defs::mcp_sessions::list_mcp_sessions::*;
|
||||
|
||||
// Folder endpoints
|
||||
use crate::http_server::endpoints::folders::folder::color_code_folder_handler::*;
|
||||
use crate::http_server::endpoints::folders::folder::cover_image_folder_handler::*;
|
||||
@@ -683,6 +696,13 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
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,
|
||||
|
||||
// MCP sessions
|
||||
crate::http_server::endpoints::mcp_sessions::create_mcp_session_handler::create_mcp_session_handler,
|
||||
crate::http_server::endpoints::mcp_sessions::refresh_mcp_session_handler::refresh_mcp_session_handler,
|
||||
crate::http_server::endpoints::mcp_sessions::revoke_mcp_session_handler::revoke_mcp_session_handler,
|
||||
crate::http_server::endpoints::mcp_sessions::delete_mcp_session_handler::delete_mcp_session_handler,
|
||||
crate::http_server::endpoints::mcp_sessions::list_mcp_sessions_handler::list_mcp_sessions_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,
|
||||
@@ -1484,6 +1504,18 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
UpdateApiKeyRequest,
|
||||
UpdateApiKeySuccessResponse,
|
||||
|
||||
// MCP sessions
|
||||
McpSessionInfo,
|
||||
McpSessionPathInfo,
|
||||
CreateMcpSessionRequest,
|
||||
CreateMcpSessionSuccessResponse,
|
||||
RefreshMcpSessionRequest,
|
||||
RefreshMcpSessionSuccessResponse,
|
||||
RevokeMcpSessionSuccessResponse,
|
||||
DeleteMcpSessionSuccessResponse,
|
||||
ListMcpSessionsQueryParams,
|
||||
ListMcpSessionsSuccessResponse,
|
||||
|
||||
// Folders
|
||||
FolderInfo,
|
||||
FolderThumbnail,
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
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::mcp_sessions::create_mcp_session::{
|
||||
CreateMcpSessionRequest, CreateMcpSessionSuccessResponse,
|
||||
};
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::mcp_sessions::insert_mcp_session::{
|
||||
insert_mcp_session, InsertMcpSessionArgs,
|
||||
};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const MAX_CLIENT_FIELD_LEN: usize = 255;
|
||||
|
||||
/// Create a new MCP session for the authenticated user (API key or web session). The secret
|
||||
/// `private_session_token` is returned exactly once in this response — it can never be
|
||||
/// retrieved again afterward.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "MCP Sessions",
|
||||
path = "/v1/mcp/session/create",
|
||||
request_body = CreateMcpSessionRequest,
|
||||
responses(
|
||||
(status = 200, body = CreateMcpSessionSuccessResponse),
|
||||
(status = 400, body = CommonWebError),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn create_mcp_session_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<CreateMcpSessionRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<CreateMcpSessionSuccessResponse>, 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_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *conn,
|
||||
).await?;
|
||||
|
||||
let maybe_client_name = validated_client_field("client name", &request.maybe_mcp_client_name)?;
|
||||
let maybe_client_version = validated_client_field("client version", &request.maybe_mcp_client_version)?;
|
||||
let maybe_client_vendor = validated_client_field("client vendor", &request.maybe_mcp_client_vendor)?;
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
let inserted = insert_mcp_session(InsertMcpSessionArgs {
|
||||
user_token: &user_session.user_token,
|
||||
ip_address: &ip_address,
|
||||
maybe_mcp_client_name: maybe_client_name.as_deref(),
|
||||
maybe_mcp_client_version: maybe_client_version.as_deref(),
|
||||
maybe_mcp_client_vendor: maybe_client_vendor.as_deref(),
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("insert_mcp_session failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
Ok(Json(CreateMcpSessionSuccessResponse {
|
||||
success: true,
|
||||
// The only time the session credential is ever exposed. The refresh token is exposed again
|
||||
// only as the rotated replacement in refresh responses.
|
||||
private_session_token: inserted.private_session_token,
|
||||
private_refresh_token: inserted.private_refresh_token,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Trim an optional client-supplied field; an empty (or whitespace-only) value becomes `None`,
|
||||
/// and an overlong value is a 400.
|
||||
fn validated_client_field(
|
||||
field_name: &str,
|
||||
maybe_value: &Option<String>,
|
||||
) -> Result<Option<String>, CommonWebError> {
|
||||
let trimmed = match maybe_value {
|
||||
Some(value) => value.trim(),
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if trimmed.len() > MAX_CLIENT_FIELD_LEN {
|
||||
return Err(CommonWebError::BadInputWithSimpleMessage(
|
||||
format!("{} too long (max {} chars)", field_name, MAX_CLIENT_FIELD_LEN),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(Some(trimmed.to_string()))
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
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::mcp_sessions::common::McpSessionPathInfo;
|
||||
use artcraft_api_defs::mcp_sessions::delete_mcp_session::DeleteMcpSessionSuccessResponse;
|
||||
use mysql_queries::queries::mcp_sessions::delete_mcp_session::{
|
||||
delete_mcp_session, DeleteMcpSessionArgs,
|
||||
};
|
||||
use mysql_queries::queries::mcp_sessions::get_mcp_session_by_token::{
|
||||
get_mcp_session_by_token, GetMcpSessionByTokenArgs,
|
||||
};
|
||||
use tokens::tokens::mcp_sessions::McpSessionToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Terminate an MCP session by its management `token`, scoped to the authenticated user (API
|
||||
/// key or web session). This is the dashboard path; a session revoking ITSELF uses
|
||||
/// `/v1/mcp/session/revoke` with its private credential instead.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "MCP Sessions",
|
||||
path = "/v1/mcp/session/{token}/delete",
|
||||
params(("token" = McpSessionToken, description = "The session's management token (not the private credential)")),
|
||||
responses(
|
||||
(status = 200, body = DeleteMcpSessionSuccessResponse),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 404, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn delete_mcp_session_handler(
|
||||
http_request: HttpRequest,
|
||||
path: Path<McpSessionPathInfo>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<DeleteMcpSessionSuccessResponse>, 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_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *conn,
|
||||
).await?;
|
||||
|
||||
// The by-token delete query isn't owner-scoped, so first look the session up and confirm
|
||||
// ownership. Return NotFound for missing OR other-owned sessions, so we never leak the
|
||||
// existence of another user's session.
|
||||
let row = get_mcp_session_by_token(GetMcpSessionByTokenArgs {
|
||||
token: &path.token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("get_mcp_session_by_token failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?
|
||||
.filter(|row| row.user_token == user_session.user_token)
|
||||
.ok_or(CommonWebError::NotFound)?;
|
||||
|
||||
// Only issue the delete if the session is still live. If it's already terminated the desired
|
||||
// end state already holds, so treat it as success.
|
||||
if row.maybe_deleted_at.is_none() {
|
||||
delete_mcp_session(DeleteMcpSessionArgs {
|
||||
token: &path.token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("delete_mcp_session failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(Json(DeleteMcpSessionSuccessResponse { success: true }))
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
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::mcp_sessions::list_mcp_sessions::{
|
||||
ListMcpSessionsQueryParams, ListMcpSessionsSuccessResponse,
|
||||
};
|
||||
use mysql_queries::queries::mcp_sessions::list_mcp_sessions_for_user::{
|
||||
list_mcp_sessions_for_user, ListMcpSessionsForUserArgs,
|
||||
};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::mcp_sessions::mcp_session_info_conversion::mcp_session_row_to_info;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::require_api_or_web_session;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
const DEFAULT_LIMIT: u32 = 100;
|
||||
const MAX_LIMIT: u32 = 1000;
|
||||
|
||||
/// List the authenticated user's MCP sessions (API key or web session), newest first,
|
||||
/// **including terminated** sessions. Paginated via `limit`/`offset`. Never returns the secret
|
||||
/// `private_session_token` — only the management `token`.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
tag = "MCP Sessions",
|
||||
path = "/v1/mcp/session/list",
|
||||
params(ListMcpSessionsQueryParams),
|
||||
responses(
|
||||
(status = 200, body = ListMcpSessionsSuccessResponse),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn list_mcp_sessions_handler(
|
||||
http_request: HttpRequest,
|
||||
query: Query<ListMcpSessionsQueryParams>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<ListMcpSessionsSuccessResponse>, 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_api_or_web_session(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut *conn,
|
||||
).await?;
|
||||
|
||||
let limit = query.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let rows = list_mcp_sessions_for_user(ListMcpSessionsForUserArgs {
|
||||
user_token: &user_session.user_token,
|
||||
limit,
|
||||
offset,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("list_mcp_sessions_for_user failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let mcp_sessions = rows.into_iter().map(mcp_session_row_to_info).collect();
|
||||
|
||||
Ok(Json(ListMcpSessionsSuccessResponse {
|
||||
success: true,
|
||||
mcp_sessions,
|
||||
}))
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
use artcraft_api_defs::mcp_sessions::common::McpSessionInfo;
|
||||
use mysql_queries::queries::mcp_sessions::mcp_session_row::McpSessionRow;
|
||||
|
||||
/// Map a database row onto the wire shape. The row never contains the secret
|
||||
/// `private_session_token`, so nothing here can leak it.
|
||||
pub fn mcp_session_row_to_info(row: McpSessionRow) -> McpSessionInfo {
|
||||
McpSessionInfo {
|
||||
token: row.token,
|
||||
maybe_mcp_client_name: row.maybe_mcp_client_name,
|
||||
maybe_mcp_client_version: row.maybe_mcp_client_version,
|
||||
maybe_mcp_client_vendor: row.maybe_mcp_client_vendor,
|
||||
ip_address_creation: row.ip_address_creation,
|
||||
ip_address_update: row.ip_address_update,
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
expires_at: row.expires_at,
|
||||
maybe_deleted_at: row.maybe_deleted_at,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod create_mcp_session_handler;
|
||||
pub mod delete_mcp_session_handler;
|
||||
pub mod list_mcp_sessions_handler;
|
||||
pub mod mcp_session_info_conversion;
|
||||
pub mod refresh_mcp_session_handler;
|
||||
pub mod revoke_mcp_session_handler;
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
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::mcp_sessions::refresh_mcp_session::{
|
||||
RefreshMcpSessionRequest, RefreshMcpSessionSuccessResponse,
|
||||
};
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use mysql_queries::queries::mcp_sessions::refresh_mcp_session::{
|
||||
refresh_mcp_session, RefreshMcpSessionArgs,
|
||||
};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::get_authorization_header_mcp_private_session_token::get_authorization_header_mcp_private_session_token;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Refresh the calling MCP session, pushing its expiry two weeks out from now. Authenticated
|
||||
/// by the session's `private_session_token` in the `Authorization` header PLUS its current
|
||||
/// refresh token in the body — a session cannot bump itself without the refresh token.
|
||||
///
|
||||
/// Each success ROTATES the refresh token: the response carries the new one and the one just
|
||||
/// used becomes invalid. This is deliberately NOT idempotent — a retried refresh (or a stale,
|
||||
/// wrong, terminated, or expired credential) is a 401, never a replay.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "MCP Sessions",
|
||||
path = "/v1/mcp/session/refresh",
|
||||
request_body = RefreshMcpSessionRequest,
|
||||
responses(
|
||||
(status = 200, body = RefreshMcpSessionSuccessResponse),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn refresh_mcp_session_handler(
|
||||
http_request: HttpRequest,
|
||||
request: Json<RefreshMcpSessionRequest>,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<RefreshMcpSessionSuccessResponse>, CommonWebError> {
|
||||
let private_session_token =
|
||||
get_authorization_header_mcp_private_session_token(&http_request)
|
||||
.ok_or_else(|| {
|
||||
warn!("MCP session refresh without a usable MCP session credential");
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let maybe_new_refresh_token = refresh_mcp_session(RefreshMcpSessionArgs {
|
||||
private_session_token: &private_session_token,
|
||||
private_refresh_token: &request.private_refresh_token,
|
||||
ip_address: &ip_address,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("refresh_mcp_session failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// An unknown, terminated, or expired session — or a stale/wrong refresh token — is a 401,
|
||||
// not a leak of which case occurred.
|
||||
let new_refresh_token = match maybe_new_refresh_token {
|
||||
Some(new_refresh_token) => new_refresh_token,
|
||||
None => {
|
||||
warn!("No refreshable MCP session for presented credentials");
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Json(RefreshMcpSessionSuccessResponse {
|
||||
success: true,
|
||||
private_refresh_token: new_refresh_token,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! DATABASE TESTS: these connect to the guarded MySQL test database (see the `mysql_testing`
|
||||
//! crate — never production, never the local dev database) and drive the real handler with
|
||||
//! dummy Actix HTTP requests. They RUN BY DEFAULT under `cargo test`; on machines without a
|
||||
//! local MySQL, skip them with:
|
||||
//!
|
||||
//! ```bash
|
||||
//! SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests
|
||||
//! ```
|
||||
|
||||
use actix_web::test::TestRequest;
|
||||
use actix_web::web::Data;
|
||||
|
||||
use mysql_queries::queries::mcp_sessions::insert_mcp_session::InsertedMcpSession;
|
||||
use mysql_testing::fixtures::mcp_sessions::create_test_mcp_session;
|
||||
use tokens::tokens::mcp_session_refresh::McpSessionRefreshToken;
|
||||
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::TestHarness;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn valid_refresh_extends_expiry_and_rotates_the_refresh_token() {
|
||||
let harness = TestHarness::create().await;
|
||||
let mcp_session = create_session(&harness).await;
|
||||
|
||||
let expiry_before = session_expiry(&harness, &mcp_session).await;
|
||||
|
||||
let response = post_refresh(&harness, &mcp_session, &mcp_session.private_refresh_token)
|
||||
.await
|
||||
.expect("valid refresh should succeed");
|
||||
assert!(response.success);
|
||||
assert_ne!(
|
||||
response.private_refresh_token,
|
||||
mcp_session.private_refresh_token,
|
||||
"a successful refresh must rotate the refresh token",
|
||||
);
|
||||
|
||||
let expiry_after = session_expiry(&harness, &mcp_session).await;
|
||||
assert!(expiry_after >= expiry_before, "refresh must not shrink the expiry");
|
||||
|
||||
// The rotation invalidates the old token (non-idempotent by design)...
|
||||
let replay = post_refresh(&harness, &mcp_session, &mcp_session.private_refresh_token).await;
|
||||
assert!(
|
||||
matches!(replay, Err(CommonWebError::NotAuthorized)),
|
||||
"a used refresh token must not be replayable",
|
||||
);
|
||||
|
||||
// ...while the newly issued token works, rotating again.
|
||||
let second = post_refresh(&harness, &mcp_session, &response.private_refresh_token)
|
||||
.await
|
||||
.expect("the rotated refresh token should work");
|
||||
assert_ne!(second.private_refresh_token, response.private_refresh_token);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn wrong_refresh_token_is_rejected() {
|
||||
let harness = TestHarness::create().await;
|
||||
let mcp_session = create_session(&harness).await;
|
||||
|
||||
let wrong_token = McpSessionRefreshToken::generate();
|
||||
let result = post_refresh(&harness, &mcp_session, &wrong_token).await;
|
||||
|
||||
assert!(matches!(result, Err(CommonWebError::NotAuthorized)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn refresh_without_a_session_credential_is_rejected() {
|
||||
let harness = TestHarness::create().await;
|
||||
let mcp_session = create_session(&harness).await;
|
||||
|
||||
// No Authorization header at all: the refresh token alone must not be enough.
|
||||
let http_request = TestRequest::post()
|
||||
.uri("/v1/mcp/session/refresh")
|
||||
.peer_addr("127.0.0.1:9999".parse().expect("peer addr"))
|
||||
.to_http_request();
|
||||
let request = RefreshMcpSessionRequest {
|
||||
private_refresh_token: mcp_session.private_refresh_token.clone(),
|
||||
};
|
||||
|
||||
let result = refresh_mcp_session_handler(
|
||||
http_request,
|
||||
Json(request),
|
||||
Data::new(harness.server_state.clone()),
|
||||
).await;
|
||||
|
||||
assert!(matches!(result, Err(CommonWebError::NotAuthorized)));
|
||||
}
|
||||
|
||||
async fn create_session(harness: &TestHarness) -> InsertedMcpSession {
|
||||
let user = harness.create_funded_user(0).await;
|
||||
create_test_mcp_session(&harness.pool, &user.user_token)
|
||||
.await
|
||||
.expect("create mcp session")
|
||||
}
|
||||
|
||||
/// Drive the real handler as the session, presenting `refresh_token` in the body.
|
||||
async fn post_refresh(
|
||||
harness: &TestHarness,
|
||||
mcp_session: &InsertedMcpSession,
|
||||
refresh_token: &McpSessionRefreshToken,
|
||||
) -> Result<RefreshMcpSessionSuccessResponse, CommonWebError> {
|
||||
let http_request = TestRequest::post()
|
||||
.uri("/v1/mcp/session/refresh")
|
||||
.insert_header((
|
||||
"Authorization",
|
||||
format!("Bearer {}", mcp_session.private_session_token.as_str()),
|
||||
))
|
||||
.peer_addr("127.0.0.1:9999".parse().expect("peer addr"))
|
||||
.to_http_request();
|
||||
|
||||
let request = RefreshMcpSessionRequest {
|
||||
private_refresh_token: refresh_token.clone(),
|
||||
};
|
||||
|
||||
refresh_mcp_session_handler(
|
||||
http_request,
|
||||
Json(request),
|
||||
Data::new(harness.server_state.clone()),
|
||||
)
|
||||
.await
|
||||
.map(Json::into_inner)
|
||||
}
|
||||
|
||||
async fn session_expiry(
|
||||
harness: &TestHarness,
|
||||
mcp_session: &InsertedMcpSession,
|
||||
) -> chrono::DateTime<chrono::Utc> {
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use mysql_queries::queries::mcp_sessions::get_mcp_session_by_token::{
|
||||
get_mcp_session_by_token, GetMcpSessionByTokenArgs,
|
||||
};
|
||||
|
||||
let mut connection = harness.pool.acquire().await.expect("acquire for expiry read");
|
||||
get_mcp_session_by_token(GetMcpSessionByTokenArgs {
|
||||
token: &mcp_session.token,
|
||||
mysql_executor: &mut *connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
.expect("read session")
|
||||
.expect("session row exists")
|
||||
.expires_at
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
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::mcp_sessions::revoke_mcp_session::RevokeMcpSessionSuccessResponse;
|
||||
use mysql_queries::queries::mcp_sessions::revoke_mcp_session::{
|
||||
revoke_mcp_session, RevokeMcpSessionArgs,
|
||||
};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::web_utils::get_authorization_header_mcp_private_session_token::get_authorization_header_mcp_private_session_token;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Terminate the calling MCP session (authenticated ONLY by its `private_session_token` in the
|
||||
/// `Authorization` header), marking it invalid for all future use. Sessions are not
|
||||
/// recoverable.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "MCP Sessions",
|
||||
path = "/v1/mcp/session/revoke",
|
||||
responses(
|
||||
(status = 200, body = RevokeMcpSessionSuccessResponse),
|
||||
(status = 401, body = CommonWebError),
|
||||
(status = 500, body = CommonWebError),
|
||||
),
|
||||
)]
|
||||
pub async fn revoke_mcp_session_handler(
|
||||
http_request: HttpRequest,
|
||||
server_state: web::Data<Arc<ServerState>>,
|
||||
) -> Result<Json<RevokeMcpSessionSuccessResponse>, CommonWebError> {
|
||||
let private_session_token =
|
||||
get_authorization_header_mcp_private_session_token(&http_request)
|
||||
.ok_or_else(|| {
|
||||
warn!("MCP session revoke without a usable MCP session credential");
|
||||
CommonWebError::NotAuthorized
|
||||
})?;
|
||||
|
||||
let mut conn = server_state.mysql_pool.acquire().await.map_err(|err| {
|
||||
warn!("MySQL pool error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let revoked_rows = revoke_mcp_session(RevokeMcpSessionArgs {
|
||||
private_session_token: &private_session_token,
|
||||
mysql_executor: &mut *conn,
|
||||
phantom: PhantomData,
|
||||
}).await.map_err(|err| {
|
||||
warn!("revoke_mcp_session failed: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// An unknown or already-terminated session is a 401, not a leak of which case occurred.
|
||||
if revoked_rows == 0 {
|
||||
warn!("No revocable MCP session for presented credential");
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
|
||||
Ok(Json(RevokeMcpSessionSuccessResponse { success: true }))
|
||||
}
|
||||
+1
@@ -1 +1,2 @@
|
||||
pub mod read_upload_session;
|
||||
pub mod try_parse_generation_provider;
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
use actix_web::http::header;
|
||||
use actix_web::HttpRequest;
|
||||
use log::{error, warn};
|
||||
use sqlx::pool::PoolConnection;
|
||||
use sqlx::MySql;
|
||||
|
||||
use enums::common::visibility::Visibility;
|
||||
use mysql_queries::queries::users::user_sessions::get_user_session_by_token::SessionUserRecord;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{
|
||||
require_any_session_or_key, AnySession,
|
||||
};
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// The optionally-authenticated identity behind a media file upload.
|
||||
///
|
||||
/// Uploads historically allow anonymous (cookie-less) callers, so the cookie path stays
|
||||
/// OPTIONAL. Header credentials (API keys and MCP sessions) are different: presenting one is an
|
||||
/// explicit claim of identity, so a bad credential is a 401, never an anonymous fallthrough.
|
||||
pub struct UploadSessionAuth {
|
||||
/// Present when the request authenticated via an `Authorization` header credential (an API
|
||||
/// key or an MCP session).
|
||||
pub maybe_header_session: Option<AnySession>,
|
||||
|
||||
/// Present when the request carried a valid web-session cookie (and no `Authorization`
|
||||
/// header).
|
||||
pub maybe_cookie_session: Option<SessionUserRecord>,
|
||||
}
|
||||
|
||||
impl UploadSessionAuth {
|
||||
/// The uploading user, whichever auth path identified them. `None` = anonymous upload.
|
||||
pub fn maybe_user_token(&self) -> Option<&UserToken> {
|
||||
self.maybe_header_session
|
||||
.as_ref()
|
||||
.map(|session| &session.user_token)
|
||||
.or_else(|| {
|
||||
self.maybe_cookie_session
|
||||
.as_ref()
|
||||
.map(|session| session.get_user_token())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_logged_in(&self) -> bool {
|
||||
self.maybe_header_session.is_some() || self.maybe_cookie_session.is_some()
|
||||
}
|
||||
|
||||
/// The user's preferred result visibility. Only web-session lookups carry preferences;
|
||||
/// header-authenticated (API key / MCP) uploads fall back to the caller's default.
|
||||
pub fn maybe_preferred_visibility(&self) -> Option<Visibility> {
|
||||
self.maybe_cookie_session
|
||||
.as_ref()
|
||||
.map(|session| session.preferred_tts_result_visibility)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the upload's identity: a REQUIRED `Authorization` header credential (API key or MCP
|
||||
/// session) when one is present, otherwise an OPTIONAL web-session cookie. Banned users are
|
||||
/// rejected on both paths.
|
||||
pub async fn read_upload_session(
|
||||
http_request: &HttpRequest,
|
||||
server_state: &ServerState,
|
||||
mysql_connection: &mut PoolConnection<MySql>,
|
||||
) -> Result<UploadSessionAuth, MediaFileUploadError> {
|
||||
if http_request.headers().contains_key(header::AUTHORIZATION) {
|
||||
let session = require_any_session_or_key(
|
||||
http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
&mut **mysql_connection,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
CommonWebError::NotAuthorized => MediaFileUploadError::NotAuthorized,
|
||||
other => {
|
||||
error!("Header credential lookup error: {:?}", other);
|
||||
MediaFileUploadError::ServerError
|
||||
}
|
||||
})?;
|
||||
|
||||
return Ok(UploadSessionAuth {
|
||||
maybe_header_session: Some(session),
|
||||
maybe_cookie_session: None,
|
||||
});
|
||||
}
|
||||
|
||||
let maybe_cookie_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(http_request, mysql_connection)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Session checker error: {:?}", err);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
if let Some(ref user) = maybe_cookie_session {
|
||||
if user.is_banned {
|
||||
warn!("user is banned: {:?}", user.get_user_token().as_str());
|
||||
return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(UploadSessionAuth {
|
||||
maybe_header_session: None,
|
||||
maybe_cookie_session,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! DATABASE TESTS: these connect to the guarded MySQL test database (see the `mysql_testing`
|
||||
//! crate — never production, never the local dev database) and drive the real helper with
|
||||
//! dummy Actix HTTP requests against a test `ServerState`. They RUN BY DEFAULT under
|
||||
//! `cargo test`; on machines without a local MySQL, skip them with:
|
||||
//!
|
||||
//! ```bash
|
||||
//! SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests
|
||||
//! ```
|
||||
//!
|
||||
//! They run in PARALLEL: every test creates its own users, API keys, and MCP sessions.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use actix_web::cookie::Cookie;
|
||||
use actix_web::test::TestRequest;
|
||||
use actix_web::HttpRequest;
|
||||
|
||||
use mysql_queries::queries::mcp_sessions::revoke_mcp_session::{
|
||||
revoke_mcp_session, RevokeMcpSessionArgs,
|
||||
};
|
||||
use mysql_testing::fixtures::users::{create_test_user, TestUser};
|
||||
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::TestHarness;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::AnySessionType;
|
||||
|
||||
use super::*;
|
||||
|
||||
mod anonymous_and_cookie_lookups {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn no_credentials_is_an_anonymous_upload() {
|
||||
let harness = TestHarness::create().await;
|
||||
|
||||
let request = TestRequest::default().to_http_request();
|
||||
let session_auth = read(&harness, &request).await.expect("anonymous should be allowed");
|
||||
|
||||
assert!(!session_auth.is_logged_in());
|
||||
assert!(session_auth.maybe_user_token().is_none());
|
||||
assert!(session_auth.maybe_header_session.is_none());
|
||||
assert!(session_auth.maybe_cookie_session.is_none());
|
||||
assert!(session_auth.maybe_preferred_visibility().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn web_session_cookie_identifies_the_user() {
|
||||
let harness = TestHarness::create().await;
|
||||
let user = create_user(&harness).await;
|
||||
|
||||
let request = request_with_session_cookie(&harness, &user);
|
||||
let session_auth = read(&harness, &request).await.expect("cookie session should be read");
|
||||
|
||||
assert!(session_auth.is_logged_in());
|
||||
assert_eq!(session_auth.maybe_user_token(), Some(&user.user_token));
|
||||
assert!(session_auth.maybe_header_session.is_none());
|
||||
// Cookie sessions carry the user's preferences.
|
||||
assert!(session_auth.maybe_preferred_visibility().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
mod header_credential_lookups {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn api_key_identifies_the_user() {
|
||||
let harness = TestHarness::create().await;
|
||||
let user = create_user(&harness).await;
|
||||
let api_key = harness.create_api_key(&user).await;
|
||||
|
||||
let request = request_with_bearer(api_key.as_str_be_careful());
|
||||
let session_auth = read(&harness, &request).await.expect("API key should authenticate");
|
||||
|
||||
assert!(session_auth.is_logged_in());
|
||||
assert_eq!(session_auth.maybe_user_token(), Some(&user.user_token));
|
||||
let header_session = session_auth.maybe_header_session.as_ref().expect("header session");
|
||||
assert_eq!(header_session.session_type, AnySessionType::Api);
|
||||
assert!(header_session.maybe_api_key_token.is_some());
|
||||
// Header lookups don't carry user preferences.
|
||||
assert!(session_auth.maybe_preferred_visibility().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn mcp_session_identifies_the_user() {
|
||||
let harness = TestHarness::create().await;
|
||||
let user = create_user(&harness).await;
|
||||
let credential = harness.create_mcp_session(&user).await;
|
||||
|
||||
let request = request_with_bearer(credential.as_str());
|
||||
let session_auth = read(&harness, &request).await.expect("MCP session should authenticate");
|
||||
|
||||
assert!(session_auth.is_logged_in());
|
||||
assert_eq!(session_auth.maybe_user_token(), Some(&user.user_token));
|
||||
let header_session = session_auth.maybe_header_session.as_ref().expect("header session");
|
||||
assert_eq!(header_session.session_type, AnySessionType::McpSession);
|
||||
assert!(header_session.maybe_mcp_session_token.is_some());
|
||||
assert!(session_auth.maybe_preferred_visibility().is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn header_credential_wins_over_a_session_cookie() {
|
||||
let harness = TestHarness::create().await;
|
||||
let cookie_user = create_user(&harness).await;
|
||||
let api_key_user = create_user(&harness).await;
|
||||
let api_key = harness.create_api_key(&api_key_user).await;
|
||||
|
||||
let cookie = session_cookie(&harness, &cookie_user);
|
||||
let request = TestRequest::default()
|
||||
.insert_header(("Authorization", format!("Bearer {}", api_key.as_str_be_careful())))
|
||||
.cookie(cookie)
|
||||
.to_http_request();
|
||||
let session_auth = read(&harness, &request).await.expect("header credential should win");
|
||||
|
||||
assert_eq!(session_auth.maybe_user_token(), Some(&api_key_user.user_token));
|
||||
assert!(session_auth.maybe_cookie_session.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn unknown_header_credentials_are_rejected_not_anonymous() {
|
||||
let harness = TestHarness::create().await;
|
||||
|
||||
for credential in [
|
||||
"artcraft_api_no_such_key_0000000000000000",
|
||||
"mcp_session_no_such_session_00000000000000000000000000000000",
|
||||
] {
|
||||
let request = request_with_bearer(credential);
|
||||
let result = read(&harness, &request).await;
|
||||
assert!(
|
||||
matches!(result, Err(MediaFileUploadError::NotAuthorized)),
|
||||
"bad credential {credential:?} must be a 401, not an anonymous fallthrough",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn revoked_mcp_session_is_rejected() {
|
||||
let harness = TestHarness::create().await;
|
||||
let user = create_user(&harness).await;
|
||||
let credential = harness.create_mcp_session(&user).await;
|
||||
|
||||
let mut connection = harness.pool.acquire().await.expect("acquire for revoke");
|
||||
let revoked = revoke_mcp_session(RevokeMcpSessionArgs {
|
||||
private_session_token: &credential,
|
||||
mysql_executor: &mut *connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
.expect("revoke mcp session");
|
||||
assert_eq!(revoked, 1);
|
||||
|
||||
let request = request_with_bearer(credential.as_str());
|
||||
let result = read(&harness, &request).await;
|
||||
|
||||
assert!(matches!(result, Err(MediaFileUploadError::NotAuthorized)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the helper under test against a fresh connection from the test pool.
|
||||
async fn read(
|
||||
harness: &TestHarness,
|
||||
request: &HttpRequest,
|
||||
) -> Result<UploadSessionAuth, MediaFileUploadError> {
|
||||
let mut connection = harness.pool.acquire().await.expect("acquire for lookup");
|
||||
read_upload_session(request, &harness.server_state, &mut connection).await
|
||||
}
|
||||
|
||||
async fn create_user(harness: &TestHarness) -> TestUser {
|
||||
create_test_user(&harness.pool).await.expect("create test user")
|
||||
}
|
||||
|
||||
/// A dummy HTTP request presenting `credential` as a Bearer Authorization header.
|
||||
fn request_with_bearer(credential: &str) -> HttpRequest {
|
||||
TestRequest::default()
|
||||
.insert_header(("Authorization", format!("Bearer {credential}")))
|
||||
.to_http_request()
|
||||
}
|
||||
|
||||
/// A dummy HTTP request carrying the user's session cookie and no Authorization header.
|
||||
fn request_with_session_cookie(harness: &TestHarness, user: &TestUser) -> HttpRequest {
|
||||
TestRequest::default()
|
||||
.cookie(session_cookie(harness, user))
|
||||
.to_http_request()
|
||||
}
|
||||
|
||||
/// The user's session cookie, owned so TestRequest can take it.
|
||||
fn session_cookie(harness: &TestHarness, user: &TestUser) -> Cookie<'static> {
|
||||
let cookie = harness
|
||||
.server_state
|
||||
.session_cookie_manager
|
||||
.create_cookie(&user.session_token, &user.user_token)
|
||||
.expect("create session cookie");
|
||||
Cookie::new("session", cookie.value().to_string())
|
||||
}
|
||||
}
|
||||
+10
-27
@@ -25,6 +25,7 @@ use mysql_queries::queries::idepotency_tokens::insert_idempotency_token::insert_
|
||||
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
@@ -124,36 +125,22 @@ pub async fn upload_audio_media_file_handler(
|
||||
|
||||
// ==================== READ SESSION ==================== //
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Session checker error: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
// API-key / MCP-session (Authorization header) or optional web-session (cookie) callers;
|
||||
// banned users are rejected inside.
|
||||
let session_auth = read_upload_session(&http_request, &server_state, &mut mysql_connection).await?;
|
||||
|
||||
let maybe_user_token = maybe_user_session
|
||||
.as_ref()
|
||||
.map(|session| session.get_user_token());
|
||||
let maybe_user_token = session_auth.maybe_user_token();
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
|
||||
// ==================== BANNED USERS ==================== //
|
||||
|
||||
if let Some(ref user) = maybe_user_session {
|
||||
if user.is_banned {
|
||||
return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RATE LIMIT ==================== //
|
||||
|
||||
let rate_limiter = match maybe_user_session {
|
||||
None => &server_state.redis_rate_limiters.file_upload_logged_out,
|
||||
Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in,
|
||||
let rate_limiter = if session_auth.is_logged_in() {
|
||||
&server_state.redis_rate_limiters.file_upload_logged_in
|
||||
} else {
|
||||
&server_state.redis_rate_limiters.file_upload_logged_out
|
||||
};
|
||||
|
||||
if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await {
|
||||
@@ -184,11 +171,7 @@ pub async fn upload_audio_media_file_handler(
|
||||
|
||||
let creator_set_visibility = form.maybe_visibility
|
||||
.map(|visibility| visibility.0)
|
||||
.or_else(|| {
|
||||
maybe_user_session
|
||||
.as_ref()
|
||||
.map(|user_session| user_session.preferred_tts_result_visibility)
|
||||
})
|
||||
.or_else(|| session_auth.maybe_preferred_visibility())
|
||||
.unwrap_or(Visibility::default());
|
||||
|
||||
// ==================== USER DATA ==================== //
|
||||
|
||||
+10
-27
@@ -20,6 +20,7 @@ use mysql_queries::queries::media_files::create::insert_builder::media_file_inse
|
||||
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_generic::drain_multipart_request::{drain_multipart_request, MediaFileUploadSource};
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
@@ -65,32 +66,20 @@ pub async fn process_upload_media_file(
|
||||
|
||||
// ==================== READ SESSION ==================== //
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Session checker error: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
// API-key / MCP-session (Authorization header) or optional web-session (cookie) callers;
|
||||
// banned users are rejected inside.
|
||||
let session_auth = read_upload_session(http_request, server_state, &mut mysql_connection).await?;
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
|
||||
// ==================== BANNED USERS ==================== //
|
||||
|
||||
if let Some(ref user) = maybe_user_session {
|
||||
if user.is_banned {
|
||||
return Err(MediaFileUploadError::NotAuthorized);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RATE LIMIT ==================== //
|
||||
|
||||
let rate_limiter = match maybe_user_session {
|
||||
None => &server_state.redis_rate_limiters.file_upload_logged_out,
|
||||
Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in,
|
||||
let rate_limiter = if session_auth.is_logged_in() {
|
||||
&server_state.redis_rate_limiters.file_upload_logged_in
|
||||
} else {
|
||||
&server_state.redis_rate_limiters.file_upload_logged_out
|
||||
};
|
||||
|
||||
if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await {
|
||||
@@ -132,18 +121,12 @@ pub async fn process_upload_media_file(
|
||||
error!("Invalid visibility: {:?}", err);
|
||||
MediaFileUploadError::BadInput("invalid visibility".to_string())
|
||||
})?
|
||||
.or_else(|| {
|
||||
maybe_user_session
|
||||
.as_ref()
|
||||
.map(|user_session| user_session.preferred_tts_result_visibility)
|
||||
})
|
||||
.or_else(|| session_auth.maybe_preferred_visibility())
|
||||
.unwrap_or(Visibility::default());
|
||||
|
||||
let ip_address = get_request_ip(&http_request);
|
||||
|
||||
let maybe_user_token = maybe_user_session
|
||||
.as_ref()
|
||||
.map(|session| session.get_user_token());
|
||||
let maybe_user_token = session_auth.maybe_user_token();
|
||||
|
||||
let maybe_file_size_bytes = upload_media_request.file_bytes
|
||||
.as_ref()
|
||||
|
||||
+10
-27
@@ -12,6 +12,7 @@ use log::{error, info, warn};
|
||||
use once_cell::sync::Lazy;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
@@ -134,38 +135,24 @@ pub async fn upload_image_media_file_handler(
|
||||
|
||||
// ==================== READ SESSION ==================== //
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Session checker error: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
// API-key / MCP-session (Authorization header) or optional web-session (cookie) callers;
|
||||
// banned users are rejected inside.
|
||||
let session_auth = read_upload_session(&http_request, &server_state, &mut mysql_connection).await?;
|
||||
|
||||
let maybe_user_token = maybe_user_session
|
||||
.as_ref()
|
||||
.map(|session| session.get_user_token());
|
||||
let maybe_user_token = session_auth.maybe_user_token();
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
|
||||
// ==================== BANNED USERS ==================== //
|
||||
|
||||
if let Some(ref user) = maybe_user_session {
|
||||
if user.is_banned {
|
||||
return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RATE LIMIT ==================== //
|
||||
|
||||
// NB: We upload batches of images now, so a rate limiter doesn't make much sense anymore.
|
||||
//
|
||||
// let rate_limiter = match maybe_user_session {
|
||||
// None => &server_state.redis_rate_limiters.file_upload_logged_out,
|
||||
// Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in,
|
||||
// let rate_limiter = if session_auth.is_logged_in() {
|
||||
// &server_state.redis_rate_limiters.file_upload_logged_in
|
||||
// } else {
|
||||
// &server_state.redis_rate_limiters.file_upload_logged_out
|
||||
// };
|
||||
//
|
||||
// if let Err(_err) = rate_limiter.rate_limit_request(&http_request) {
|
||||
@@ -194,11 +181,7 @@ pub async fn upload_image_media_file_handler(
|
||||
|
||||
let creator_set_visibility = form.maybe_visibility
|
||||
.map(|visibility| visibility.0)
|
||||
.or_else(|| {
|
||||
maybe_user_session
|
||||
.as_ref()
|
||||
.map(|user_session| user_session.preferred_tts_result_visibility)
|
||||
})
|
||||
.or_else(|| session_auth.maybe_preferred_visibility())
|
||||
.unwrap_or(Visibility::default());
|
||||
|
||||
// ==================== USER DATA ==================== //
|
||||
|
||||
+10
-27
@@ -23,6 +23,7 @@ use mysql_queries::queries::media_files::create::insert_builder::media_file_inse
|
||||
use mysql_queries::queries::media_files::create::specialized_insert::insert_media_file_from_file_upload::{insert_media_file_from_file_upload, InsertMediaFileFromUploadArgs, UploadType};
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
|
||||
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
@@ -108,36 +109,22 @@ pub async fn upload_spz_media_file_handler(
|
||||
|
||||
// ==================== READ SESSION ==================== //
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Session checker error: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
// API-key / MCP-session (Authorization header) or optional web-session (cookie) callers;
|
||||
// banned users are rejected inside.
|
||||
let session_auth = read_upload_session(&http_request, &server_state, &mut mysql_connection).await?;
|
||||
|
||||
let maybe_user_token = maybe_user_session
|
||||
.as_ref()
|
||||
.map(|session| session.get_user_token());
|
||||
let maybe_user_token = session_auth.maybe_user_token();
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
|
||||
// ==================== BANNED USERS ==================== //
|
||||
|
||||
if let Some(ref user) = maybe_user_session {
|
||||
if user.is_banned {
|
||||
return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RATE LIMIT ==================== //
|
||||
|
||||
let rate_limiter = match maybe_user_session {
|
||||
None => &server_state.redis_rate_limiters.file_upload_logged_out,
|
||||
Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in,
|
||||
let rate_limiter = if session_auth.is_logged_in() {
|
||||
&server_state.redis_rate_limiters.file_upload_logged_in
|
||||
} else {
|
||||
&server_state.redis_rate_limiters.file_upload_logged_out
|
||||
};
|
||||
|
||||
if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await {
|
||||
@@ -167,11 +154,7 @@ pub async fn upload_spz_media_file_handler(
|
||||
|
||||
let creator_set_visibility = form.maybe_visibility
|
||||
.map(|visibility| visibility.0)
|
||||
.or_else(|| {
|
||||
maybe_user_session
|
||||
.as_ref()
|
||||
.map(|user_session| user_session.preferred_tts_result_visibility)
|
||||
})
|
||||
.or_else(|| session_auth.maybe_preferred_visibility())
|
||||
.unwrap_or(Visibility::default());
|
||||
|
||||
// ==================== USER DATA ==================== //
|
||||
|
||||
+10
-27
@@ -32,6 +32,7 @@ use thumbnail_generator::task_client::thumbnail_task::{ThumbnailTaskBuilder, Thu
|
||||
use tokens::tokens::media_files::MediaFileToken;
|
||||
use tokens::tokens::prompts::PromptToken;
|
||||
|
||||
use crate::http_server::endpoints::media_files::upload::common_utils::read_upload_session::read_upload_session;
|
||||
use crate::http_server::endpoints::media_files::upload::upload_error::MediaFileUploadError;
|
||||
use crate::http_server::endpoints::media_files::upload::common_utils::try_parse_generation_provider::try_parse_generation_provider;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
@@ -166,36 +167,22 @@ pub async fn upload_new_video_media_file_handler(
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
|
||||
let maybe_user_session = server_state
|
||||
.session_checker
|
||||
.maybe_get_user_session_from_connection(&http_request, &mut mysql_connection)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
error!("Session checker error: {:?}", e);
|
||||
MediaFileUploadError::ServerError
|
||||
})?;
|
||||
// API-key / MCP-session (Authorization header) or optional web-session (cookie) callers;
|
||||
// banned users are rejected inside.
|
||||
let session_auth = read_upload_session(&http_request, &server_state, &mut mysql_connection).await?;
|
||||
|
||||
let maybe_user_token = maybe_user_session
|
||||
.as_ref()
|
||||
.map(|session| session.get_user_token());
|
||||
let maybe_user_token = session_auth.maybe_user_token();
|
||||
|
||||
let maybe_avt_token = server_state
|
||||
.avt_cookie_manager
|
||||
.get_avt_token_from_request(&http_request);
|
||||
|
||||
// ==================== BANNED USERS ==================== //
|
||||
|
||||
if let Some(ref user) = maybe_user_session {
|
||||
if user.is_banned {
|
||||
return Err(MediaFileUploadError::NotAuthorizedVerbose("user is banned".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== RATE LIMIT ==================== //
|
||||
|
||||
let rate_limiter = match maybe_user_session {
|
||||
None => &server_state.redis_rate_limiters.file_upload_logged_out,
|
||||
Some(ref _session) => &server_state.redis_rate_limiters.file_upload_logged_in,
|
||||
let rate_limiter = if session_auth.is_logged_in() {
|
||||
&server_state.redis_rate_limiters.file_upload_logged_in
|
||||
} else {
|
||||
&server_state.redis_rate_limiters.file_upload_logged_out
|
||||
};
|
||||
|
||||
if let Err(_err) = rate_limiter.rate_limit_request(&http_request).await {
|
||||
@@ -226,11 +213,7 @@ pub async fn upload_new_video_media_file_handler(
|
||||
|
||||
let creator_set_visibility = form.maybe_visibility
|
||||
.map(|visibility| visibility.0)
|
||||
.or_else(|| {
|
||||
maybe_user_session
|
||||
.as_ref()
|
||||
.map(|user_session| user_session.preferred_tts_result_visibility)
|
||||
})
|
||||
.or_else(|| session_auth.maybe_preferred_visibility())
|
||||
.unwrap_or(Visibility::default());
|
||||
|
||||
// ==================== USER DATA ==================== //
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod generate;
|
||||
pub mod image_studio;
|
||||
pub mod inference_job;
|
||||
pub mod internal;
|
||||
pub mod mcp_sessions;
|
||||
pub mod media_files;
|
||||
pub mod misc;
|
||||
pub mod model_download;
|
||||
|
||||
+9
-7
@@ -39,14 +39,15 @@ use crate::http_server::endpoints::omni_gen::generate::audio::pipeline_v2::run_p
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::audio::validate_audio_request::validate_audio_request;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{require_any_session_or_key, AnySessionType};
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
|
||||
|
||||
/// Generate audio using the omni-gen unified endpoint.
|
||||
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
|
||||
/// Authenticates as a web-session (cookie) user, an API-key (`Authorization` header) user, or
|
||||
/// an MCP-session (`Authorization` header) user.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni Gen",
|
||||
@@ -84,8 +85,8 @@ pub async fn omni_gen_audio_generate_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// Either an API-key user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_api_or_web_session(
|
||||
// An API-key or MCP-session user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_any_session_or_key(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
@@ -94,7 +95,7 @@ pub async fn omni_gen_audio_generate_handler(
|
||||
|
||||
let user_token = &session.user_token;
|
||||
|
||||
// AVT tokens are web-session only; API-key sessions never carry one.
|
||||
// AVT tokens are web-session only; API-key and MCP sessions never carry one.
|
||||
let maybe_avt_token = session.maybe_avt_token.clone();
|
||||
|
||||
// ==================== IDEMPOTENCY ==================== //
|
||||
@@ -222,8 +223,9 @@ server_state.maybe_media_cdn_override_url.as_deref(),
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let maybe_platform_type = match session.session_type {
|
||||
SessionType::Api => Some(PlatformType::ApiKey),
|
||||
SessionType::WebSession => get_request_platform_type(&http_request),
|
||||
AnySessionType::Api => Some(PlatformType::ApiKey),
|
||||
AnySessionType::McpSession => Some(PlatformType::Mcp),
|
||||
AnySessionType::WebSession => get_request_platform_type(&http_request),
|
||||
};
|
||||
|
||||
let mut transaction = mysql_connection.begin().await.map_err(|err| {
|
||||
|
||||
+9
-7
@@ -37,7 +37,7 @@ use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::ins
|
||||
use crate::http_server::endpoints::omni_gen::generate::image::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::generate::image::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::kinovi_account::KinoviAccount;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{require_any_session_or_key, AnySessionType};
|
||||
use crate::http_server::user_lookup::user_session::session_utils::lookup::user_session_feature_flags::UserSessionFeatureFlags;
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
|
||||
@@ -45,7 +45,8 @@ use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
|
||||
|
||||
/// Generate an image using the omni-gen unified endpoint.
|
||||
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
|
||||
/// Authenticates as a web-session (cookie) user, an API-key (`Authorization` header) user, or
|
||||
/// an MCP-session (`Authorization` header) user.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni Gen",
|
||||
@@ -79,8 +80,8 @@ pub async fn omni_gen_image_generate_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// Either an API-key user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_api_or_web_session(
|
||||
// An API-key or MCP-session user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_any_session_or_key(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
@@ -89,7 +90,7 @@ pub async fn omni_gen_image_generate_handler(
|
||||
|
||||
let user_token = &session.user_token;
|
||||
|
||||
// AVT tokens are web-session only; API-key sessions never carry one.
|
||||
// AVT tokens are web-session only; API-key and MCP sessions never carry one.
|
||||
let maybe_avt_token = session.maybe_avt_token.clone();
|
||||
|
||||
// ==================== MODEL ACCESS CHECK ==================== //
|
||||
@@ -213,8 +214,9 @@ server_state.maybe_media_cdn_override_url.as_deref(),
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let maybe_platform_type = match session.session_type {
|
||||
SessionType::Api => Some(PlatformType::ApiKey),
|
||||
SessionType::WebSession => get_request_platform_type(&http_request),
|
||||
AnySessionType::Api => Some(PlatformType::ApiKey),
|
||||
AnySessionType::McpSession => Some(PlatformType::Mcp),
|
||||
AnySessionType::WebSession => get_request_platform_type(&http_request),
|
||||
};
|
||||
|
||||
let mut transaction = mysql_connection
|
||||
|
||||
+9
-7
@@ -36,14 +36,15 @@ use crate::http_server::endpoints::omni_gen::generate::mesh::insert_db_job::inse
|
||||
use crate::http_server::endpoints::omni_gen::generate::mesh::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::mesh::validate_mesh_request::validate_mesh_request;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{require_any_session_or_key, AnySessionType};
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
|
||||
|
||||
/// Generate a mesh (3D object) using the omni-gen unified endpoint.
|
||||
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
|
||||
/// Authenticates as a web-session (cookie) user, an API-key (`Authorization` header) user, or
|
||||
/// an MCP-session (`Authorization` header) user.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni Gen",
|
||||
@@ -81,8 +82,8 @@ pub async fn omni_gen_mesh_generate_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// Either an API-key user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_api_or_web_session(
|
||||
// An API-key or MCP-session user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_any_session_or_key(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
@@ -91,7 +92,7 @@ pub async fn omni_gen_mesh_generate_handler(
|
||||
|
||||
let user_token = &session.user_token;
|
||||
|
||||
// AVT tokens are web-session only; API-key sessions never carry one.
|
||||
// AVT tokens are web-session only; API-key and MCP sessions never carry one.
|
||||
let maybe_avt_token = session.maybe_avt_token.clone();
|
||||
|
||||
// ==================== IDEMPOTENCY ==================== //
|
||||
@@ -209,8 +210,9 @@ server_state.maybe_media_cdn_override_url.as_deref(),
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let maybe_platform_type = match session.session_type {
|
||||
SessionType::Api => Some(PlatformType::ApiKey),
|
||||
SessionType::WebSession => get_request_platform_type(&http_request),
|
||||
AnySessionType::Api => Some(PlatformType::ApiKey),
|
||||
AnySessionType::McpSession => Some(PlatformType::Mcp),
|
||||
AnySessionType::WebSession => get_request_platform_type(&http_request),
|
||||
};
|
||||
|
||||
let mut transaction = mysql_connection.begin().await.map_err(|err| {
|
||||
|
||||
+9
-7
@@ -37,14 +37,15 @@ use crate::http_server::endpoints::omni_gen::generate::splat::insert_db_job::ins
|
||||
use crate::http_server::endpoints::omni_gen::generate::splat::pipeline_v2::run_pipeline_v2::{run_pipeline_v2, RunPipelineV2Args};
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::insert_db_job::shared_job_args::SharedJobArgs;
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::splat::validate_splat_request::validate_splat_request;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{require_any_session_or_key, AnySessionType};
|
||||
use crate::http_server::validations::validate_idempotency_token_format::validate_idempotency_token_format;
|
||||
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::util::lookup::lookup_media_files_as_cdn_url_list_and_map::lookup_media_files_as_cdn_url_list_and_map;
|
||||
|
||||
/// Generate a splat (3D world) using the omni-gen unified endpoint.
|
||||
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
|
||||
/// Authenticates as a web-session (cookie) user, an API-key (`Authorization` header) user, or
|
||||
/// an MCP-session (`Authorization` header) user.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
tag = "Omni Gen",
|
||||
@@ -82,8 +83,8 @@ pub async fn omni_gen_splat_generate_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// Either an API-key user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_api_or_web_session(
|
||||
// An API-key or MCP-session user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_any_session_or_key(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
@@ -92,7 +93,7 @@ pub async fn omni_gen_splat_generate_handler(
|
||||
|
||||
let user_token = &session.user_token;
|
||||
|
||||
// AVT tokens are web-session only; API-key sessions never carry one.
|
||||
// AVT tokens are web-session only; API-key and MCP sessions never carry one.
|
||||
let maybe_avt_token = session.maybe_avt_token.clone();
|
||||
|
||||
// ==================== IDEMPOTENCY ==================== //
|
||||
@@ -214,8 +215,9 @@ server_state.maybe_media_cdn_override_url.as_deref(),
|
||||
// ==================== WRITE RESULT ==================== //
|
||||
|
||||
let maybe_platform_type = match session.session_type {
|
||||
SessionType::Api => Some(PlatformType::ApiKey),
|
||||
SessionType::WebSession => get_request_platform_type(&http_request),
|
||||
AnySessionType::Api => Some(PlatformType::ApiKey),
|
||||
AnySessionType::McpSession => Some(PlatformType::Mcp),
|
||||
AnySessionType::WebSession => get_request_platform_type(&http_request),
|
||||
};
|
||||
|
||||
let mut transaction = mysql_connection.begin().await.map_err(|err| {
|
||||
|
||||
+2
-1
@@ -8,5 +8,6 @@ pub mod insert_db_job;
|
||||
// Database fixture tests. Always compiled with `cfg(test)` so IDEs discover
|
||||
// them and they can't bit-rot, but each test is `ignore`d unless the
|
||||
// `database_tests` feature is enabled (see tests/mod.rs).
|
||||
// `pub(crate)` so other modules' database tests can reuse `support::TestHarness`.
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub(crate) mod tests;
|
||||
+9
-7
@@ -14,12 +14,13 @@ use crate::http_server::endpoints::omni_gen::generate::video::shared_video_gener
|
||||
run_authenticated_video_generation, VideoGenerationAuth,
|
||||
};
|
||||
use crate::http_server::endpoints::omni_gen::shared_utils::video::validate_video_request::validate_video_request;
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_api_or_web_session::{require_api_or_web_session, SessionType};
|
||||
use crate::http_server::user_lookup::api_or_web_session::require_any_session_or_key::{require_any_session_or_key, AnySessionType};
|
||||
use crate::http_server::web_utils::get_request_platform_type::get_request_platform_type;
|
||||
use crate::state::server_state::ServerState;
|
||||
|
||||
/// Generate a video using the omni-gen unified endpoint.
|
||||
/// Authenticates as either a web-session (cookie) user or an API-key (`Authorization` header) user.
|
||||
/// Authenticates as a web-session (cookie) user, an API-key (`Authorization` header) user, or
|
||||
/// an MCP-session (`Authorization` header) user.
|
||||
///
|
||||
/// Razor-thin handler: authenticate, then delegate to the shared
|
||||
/// generation core in `shared_video_generation` (which is authoritative for
|
||||
@@ -55,8 +56,8 @@ pub async fn omni_gen_video_generate_handler(
|
||||
|
||||
let mut mysql_connection = server_state.mysql_pool.acquire().await?;
|
||||
|
||||
// Either an API-key user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_api_or_web_session(
|
||||
// An API-key or MCP-session user (Authorization header) or a web-session (cookie) user.
|
||||
let session = require_any_session_or_key(
|
||||
&http_request,
|
||||
&server_state.session_checker,
|
||||
&server_state.avt_cookie_manager,
|
||||
@@ -64,13 +65,14 @@ pub async fn omni_gen_video_generate_handler(
|
||||
).await?;
|
||||
|
||||
let maybe_platform_type = match session.session_type {
|
||||
SessionType::Api => Some(PlatformType::ApiKey),
|
||||
SessionType::WebSession => get_request_platform_type(&http_request),
|
||||
AnySessionType::Api => Some(PlatformType::ApiKey),
|
||||
AnySessionType::McpSession => Some(PlatformType::Mcp),
|
||||
AnySessionType::WebSession => get_request_platform_type(&http_request),
|
||||
};
|
||||
|
||||
let auth = VideoGenerationAuth {
|
||||
user_token: &session.user_token,
|
||||
// AVT tokens are web-session only; API-key sessions never carry one.
|
||||
// AVT tokens are web-session only; API-key and MCP sessions never carry one.
|
||||
maybe_avt_token: session.maybe_avt_token.clone(),
|
||||
maybe_platform_type,
|
||||
};
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
//! MCP-session authentication tests for the omni_gen video generate endpoint.
|
||||
//!
|
||||
//! The endpoint accepts three credentials: web-session cookies, API keys, and
|
||||
//! MCP session credentials (`mcp_session_…` in the Authorization header).
|
||||
//! These tests pin the MCP path: a live session generates and bills exactly
|
||||
//! like the other paths, and a revoked session is rejected before any
|
||||
//! billable work.
|
||||
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use enums::common::generation::common_resolution::CommonResolution;
|
||||
use enums::common::generation::common_video_model::CommonVideoModel;
|
||||
use mysql_queries::queries::mcp_sessions::revoke_mcp_session::{
|
||||
revoke_mcp_session, RevokeMcpSessionArgs,
|
||||
};
|
||||
|
||||
use crate::http_server::endpoints::omni_gen::generate::video::tests::support::{
|
||||
base_generate_request, TestHarness, STARTING_CREDITS,
|
||||
};
|
||||
|
||||
mod mcp_session_auth {
|
||||
use super::*;
|
||||
|
||||
/// Base Seedance 2.0 at 720p 5s via an MCP session credential: the same 93
|
||||
/// credits the parity tests pin for the cookie and API-key paths.
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn mcp_session_generates_and_bills_exactly() {
|
||||
const EXPECTED_CREDITS: u64 = 93;
|
||||
|
||||
let harness = TestHarness::create().await;
|
||||
let user = harness.create_funded_user(STARTING_CREDITS).await;
|
||||
let credential = harness.create_mcp_session(&user).await;
|
||||
|
||||
let mut request = base_generate_request(CommonVideoModel::Seedance2p0);
|
||||
request.resolution = Some(CommonResolution::SevenTwentyP);
|
||||
request.duration_seconds = Some(5);
|
||||
|
||||
let response = harness
|
||||
.post_generate_via_mcp_session(&credential, request)
|
||||
.await
|
||||
.expect("MCP-session generation should succeed");
|
||||
assert!(response.success);
|
||||
|
||||
assert_eq!(
|
||||
STARTING_CREDITS - harness.wallet_balance(&user).await,
|
||||
EXPECTED_CREDITS,
|
||||
"MCP-session generation billed the wrong amount",
|
||||
);
|
||||
}
|
||||
|
||||
/// A revoked session must be rejected up front — no generation, no charge.
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn revoked_mcp_session_is_rejected_and_charges_nothing() {
|
||||
let harness = TestHarness::create().await;
|
||||
let user = harness.create_funded_user(STARTING_CREDITS).await;
|
||||
let credential = harness.create_mcp_session(&user).await;
|
||||
|
||||
let mut connection = harness.pool.acquire().await.expect("acquire for revoke");
|
||||
let revoked = revoke_mcp_session(RevokeMcpSessionArgs {
|
||||
private_session_token: &credential,
|
||||
mysql_executor: &mut *connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
.expect("revoke mcp session");
|
||||
assert_eq!(revoked, 1);
|
||||
|
||||
let mut request = base_generate_request(CommonVideoModel::Seedance2p0);
|
||||
request.resolution = Some(CommonResolution::SevenTwentyP);
|
||||
request.duration_seconds = Some(5);
|
||||
|
||||
let result = harness.post_generate_via_mcp_session(&credential, request).await;
|
||||
assert!(result.is_err(), "revoked MCP session must be rejected");
|
||||
|
||||
assert_eq!(
|
||||
harness.wallet_balance(&user).await,
|
||||
STARTING_CREDITS,
|
||||
"rejected MCP-session generation must not charge",
|
||||
);
|
||||
}
|
||||
}
|
||||
+1
@@ -25,6 +25,7 @@
|
||||
|
||||
pub mod support;
|
||||
|
||||
mod mcp_session_tests;
|
||||
mod omni_api_parity_tests;
|
||||
mod seedance_2p0;
|
||||
mod seedance_2p5;
|
||||
+31
@@ -41,6 +41,7 @@ use mysql_queries::mediators::badge_granter::BadgeGranter;
|
||||
use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs};
|
||||
use mysql_queries::mediators::firehose_publisher::FirehosePublisher;
|
||||
use mysql_testing::fixtures::users::TestUser;
|
||||
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
|
||||
use redis_caching::redis_ttl_cache::RedisTtlCache;
|
||||
use server_environment::ServerEnvironment;
|
||||
use url_config::third_party_url_redirector::ThirdPartyUrlRedirector;
|
||||
@@ -174,6 +175,36 @@ impl TestHarness {
|
||||
api_key
|
||||
}
|
||||
|
||||
/// Mint a live MCP session for `user` and return its private credential.
|
||||
pub async fn create_mcp_session(&self, user: &TestUser) -> McpSessionPrivateToken {
|
||||
mysql_testing::fixtures::mcp_sessions::create_test_mcp_session(&self.pool, &user.user_token)
|
||||
.await
|
||||
.expect("create mcp session")
|
||||
.private_session_token
|
||||
}
|
||||
|
||||
/// POST the request through the omni_gen generate handler, authenticating
|
||||
/// with an MCP session credential in the Authorization header.
|
||||
pub async fn post_generate_via_mcp_session(
|
||||
&self,
|
||||
private_session_token: &McpSessionPrivateToken,
|
||||
request: OmniGenVideoCostAndGenerateRequest,
|
||||
) -> Result<OmniGenVideoGenerateResponse, CommonWebError> {
|
||||
let http_request = TestRequest::post()
|
||||
.uri("/v1/omni_gen/generate/video")
|
||||
.insert_header(("Authorization", format!("Bearer {}", private_session_token.as_str())))
|
||||
.peer_addr("127.0.0.1:9999".parse().expect("peer addr"))
|
||||
.to_http_request();
|
||||
|
||||
omni_gen_video_generate_handler(
|
||||
http_request,
|
||||
Json(request),
|
||||
Data::new(self.server_state.clone()),
|
||||
)
|
||||
.await
|
||||
.map(Json::into_inner)
|
||||
}
|
||||
|
||||
/// POST the request through the omni_api (API-key only) generate handler,
|
||||
/// authenticating with `api_key` in the Authorization header. Token-based
|
||||
/// fields are mapped onto the omni_api request shape; URL fields stay None.
|
||||
|
||||
+2
@@ -10,6 +10,7 @@ use crate::http_server::routes::application_routes::folders_routes::add_folders_
|
||||
use crate::http_server::routes::application_routes::generate_routes::add_generate_routes;
|
||||
use crate::http_server::routes::application_routes::internal_routes::add_internal_routes;
|
||||
use crate::http_server::routes::application_routes::job_routes::add_job_routes;
|
||||
use crate::http_server::routes::application_routes::mcp_session_routes::add_mcp_session_routes;
|
||||
use crate::http_server::routes::application_routes::media_files_routes::add_media_file_routes;
|
||||
use crate::http_server::routes::application_routes::moderation_routes::add_moderator_routes;
|
||||
use crate::http_server::routes::application_routes::omni_api_routes::add_omni_api_routes;
|
||||
@@ -64,6 +65,7 @@ where
|
||||
app = add_folders_routes(app); // /v1/folders/...
|
||||
app = add_tag_routes(app); // /v1/tags/...
|
||||
app = add_api_keys_routes(app); // /v1/api_keys/...
|
||||
app = add_mcp_session_routes(app); // /v1/mcp/session/...
|
||||
app = add_featured_item_routes(app); // /v1/featured_item/...
|
||||
app = add_prompts_routes(app); // /v1/prompts/...
|
||||
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
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::mcp_sessions::create_mcp_session_handler::create_mcp_session_handler;
|
||||
use crate::http_server::endpoints::mcp_sessions::delete_mcp_session_handler::delete_mcp_session_handler;
|
||||
use crate::http_server::endpoints::mcp_sessions::list_mcp_sessions_handler::list_mcp_sessions_handler;
|
||||
use crate::http_server::endpoints::mcp_sessions::refresh_mcp_session_handler::refresh_mcp_session_handler;
|
||||
use crate::http_server::endpoints::mcp_sessions::revoke_mcp_session_handler::revoke_mcp_session_handler;
|
||||
|
||||
pub fn add_mcp_session_routes<T, B>(app: App<T>) -> App<T>
|
||||
where
|
||||
B: MessageBody,
|
||||
T: ServiceFactory<
|
||||
ServiceRequest,
|
||||
Config = (),
|
||||
Response = ServiceResponse<B>,
|
||||
Error = Error,
|
||||
InitError = (),
|
||||
>,
|
||||
{
|
||||
app.service(
|
||||
web::scope("/v1/mcp/session")
|
||||
// NB: Static routes are registered BEFORE the dynamic `/{token}/delete`
|
||||
// route so they take precedence. Session tokens are always `mcp_…`, so
|
||||
// they can never collide with these literals anyway.
|
||||
.service(
|
||||
web::resource("/create")
|
||||
.route(web::post().to(create_mcp_session_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/refresh")
|
||||
.route(web::post().to(refresh_mcp_session_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/revoke")
|
||||
.route(web::post().to(revoke_mcp_session_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/list")
|
||||
.route(web::get().to(list_mcp_sessions_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
)
|
||||
.service(
|
||||
web::resource("/{token}/delete")
|
||||
.route(web::post().to(delete_mcp_session_handler))
|
||||
.route(web::head().to(|| HttpResponse::Ok())),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod folders_routes;
|
||||
mod generate_routes;
|
||||
mod internal_routes;
|
||||
mod job_routes;
|
||||
mod mcp_session_routes;
|
||||
mod media_files_routes;
|
||||
mod moderation_routes;
|
||||
mod omni_api_routes;
|
||||
|
||||
+1
@@ -1 +1,2 @@
|
||||
pub mod require_any_session_or_key;
|
||||
pub mod require_api_or_web_session;
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use actix_web::http::header;
|
||||
use actix_web::HttpRequest;
|
||||
use log::warn;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
|
||||
use mysql_queries::queries::users::api_or_web_sessions::api_or_web_session_user_record::ApiOrWebSessionUserRecord;
|
||||
use mysql_queries::queries::users::api_or_web_sessions::get_api_or_web_session_user_by_api_key::{
|
||||
get_api_or_web_session_user_by_api_key, GetApiOrWebSessionUserByApiKeyArgs,
|
||||
};
|
||||
use mysql_queries::queries::users::api_or_web_sessions::get_api_or_web_session_user_by_mcp_private_session_token::{
|
||||
get_api_or_web_session_user_by_mcp_private_session_token,
|
||||
GetApiOrWebSessionUserByMcpPrivateSessionTokenArgs,
|
||||
};
|
||||
use mysql_queries::queries::users::api_or_web_sessions::get_api_or_web_session_user_by_session_token::{
|
||||
get_api_or_web_session_user_by_session_token, GetApiOrWebSessionUserBySessionTokenArgs,
|
||||
};
|
||||
use tokens::tokens::anonymous_visitor_tracking::AnonymousVisitorTrackingToken;
|
||||
use tokens::tokens::api_keys::ApiKeyToken;
|
||||
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
|
||||
use tokens::tokens::mcp_sessions::McpSessionToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker;
|
||||
use crate::http_server::web_utils::get_authorization_header_api_key::get_authorization_header_api_key;
|
||||
|
||||
const ADMIN_ROLE_SLUG: &str = "admin";
|
||||
|
||||
/// The authenticated identity behind a request that may come from an API client (`Authorization`
|
||||
/// header API key), an MCP client (`Authorization` header MCP session credential), or the
|
||||
/// website (session cookie). Fields common to all auth paths are populated uniformly, so
|
||||
/// handlers don't need to care which path authenticated the user.
|
||||
pub struct AnySession {
|
||||
pub session_type: AnySessionType,
|
||||
pub user_token: UserToken,
|
||||
pub username: String,
|
||||
pub display_name: String,
|
||||
pub email_address: String,
|
||||
|
||||
pub user_role_slug: String,
|
||||
pub can_ban_users: bool,
|
||||
|
||||
/// Optional comma-separated list of parseable `UserFeatureFlag` enum features.
|
||||
pub maybe_feature_flags: Option<String>,
|
||||
|
||||
/// Only present for API-key sessions.
|
||||
pub maybe_api_key_token: Option<ApiKeyToken>,
|
||||
|
||||
/// Only present for MCP sessions. This is the session's non-private management `token`, never
|
||||
/// the `private_session_token`.
|
||||
pub maybe_mcp_session_token: Option<McpSessionToken>,
|
||||
|
||||
/// Anonymous visitor tracking cookie. Only ever present for web sessions.
|
||||
pub maybe_avt_token: Option<AnonymousVisitorTrackingToken>,
|
||||
}
|
||||
|
||||
/// Which auth path authenticated the request.
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
pub enum AnySessionType {
|
||||
Api,
|
||||
WebSession,
|
||||
McpSession,
|
||||
}
|
||||
|
||||
impl AnySession {
|
||||
pub fn is_mod(&self) -> bool {
|
||||
self.can_ban_users || self.user_role_slug == ADMIN_ROLE_SLUG
|
||||
}
|
||||
}
|
||||
|
||||
/// Authenticate a request as an API-key user, an MCP-session user, or a web-session (cookie)
|
||||
/// user.
|
||||
///
|
||||
/// If an `Authorization` header is present, ONLY the header credential is attempted — a
|
||||
/// malformed or unknown credential is a 401, never a fallthrough to cookies. Header credentials
|
||||
/// with the `mcp_session_` prefix are looked up as MCP sessions (live and unexpired only);
|
||||
/// anything else in the header is looked up as an API key. Without the header, the session
|
||||
/// cookie is decoded and looked up, and the AVT (anonymous visitor tracking) cookie is captured
|
||||
/// alongside it.
|
||||
///
|
||||
/// No path is cached: every request performs a fresh MySQL lookup. `mysql_executor` can be any
|
||||
/// sqlx executor — prefer passing an already-open connection (`&mut *connection`) so the lookup
|
||||
/// reuses it rather than acquiring a fresh one from the pool.
|
||||
pub async fn require_any_session_or_key<'c, E>(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
avt_cookie_manager: &AvtCookieManager,
|
||||
mysql_executor: E,
|
||||
) -> Result<AnySession, CommonWebError>
|
||||
where E: 'c + Executor<'c, Database = MySql>
|
||||
{
|
||||
if http_request.headers().contains_key(header::AUTHORIZATION) {
|
||||
authenticate_by_header_credential(http_request, mysql_executor).await
|
||||
} else {
|
||||
authenticate_by_web_session(http_request, session_checker, avt_cookie_manager, mysql_executor).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn authenticate_by_header_credential<'c, E>(
|
||||
http_request: &HttpRequest,
|
||||
mysql_executor: E,
|
||||
) -> Result<AnySession, CommonWebError>
|
||||
where E: 'c + Executor<'c, Database = MySql>
|
||||
{
|
||||
let credential = match get_authorization_header_api_key(http_request) {
|
||||
Some(credential) => credential,
|
||||
None => {
|
||||
warn!("Authorization header present but not a usable credential");
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
// MCP session credentials live in the same header (and forms) as API keys; the token prefix
|
||||
// is what tells them apart. (If MCP credentials ever become JWTs, add heuristics here.)
|
||||
if credential.as_str_be_careful().starts_with(McpSessionPrivateToken::token_prefix()) {
|
||||
let private_session_token = McpSessionPrivateToken::new_from_str(credential.as_str_be_careful());
|
||||
return authenticate_by_mcp_session(&private_session_token, mysql_executor).await;
|
||||
}
|
||||
|
||||
let maybe_record = get_api_or_web_session_user_by_api_key(GetApiOrWebSessionUserByApiKeyArgs {
|
||||
api_key: &credential,
|
||||
mysql_executor,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("API key user lookup error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// A missing or soft-deleted key (or a key whose owner no longer exists) is a 401, not a leak
|
||||
// of which case occurred.
|
||||
let record = match maybe_record {
|
||||
Some(record) => record,
|
||||
None => {
|
||||
warn!("No live API key user for presented key: {:?}", credential);
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
into_session_rejecting_banned(record, AnySessionType::Api, None)
|
||||
}
|
||||
|
||||
async fn authenticate_by_mcp_session<'c, E>(
|
||||
private_session_token: &McpSessionPrivateToken,
|
||||
mysql_executor: E,
|
||||
) -> Result<AnySession, CommonWebError>
|
||||
where E: 'c + Executor<'c, Database = MySql>
|
||||
{
|
||||
let maybe_record = get_api_or_web_session_user_by_mcp_private_session_token(
|
||||
GetApiOrWebSessionUserByMcpPrivateSessionTokenArgs {
|
||||
private_session_token,
|
||||
mysql_executor,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("MCP session user lookup error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
// A missing, terminated, or expired session (or one whose owner no longer exists) is a 401,
|
||||
// not a leak of which case occurred.
|
||||
let record = match maybe_record {
|
||||
Some(record) => record,
|
||||
None => {
|
||||
warn!("No live MCP session for presented credential");
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
into_session_rejecting_banned(record, AnySessionType::McpSession, None)
|
||||
}
|
||||
|
||||
async fn authenticate_by_web_session<'c, E>(
|
||||
http_request: &HttpRequest,
|
||||
session_checker: &SessionChecker,
|
||||
avt_cookie_manager: &AvtCookieManager,
|
||||
mysql_executor: E,
|
||||
) -> Result<AnySession, CommonWebError>
|
||||
where E: 'c + Executor<'c, Database = MySql>
|
||||
{
|
||||
let maybe_session_token = session_checker
|
||||
.get_session_token(http_request)
|
||||
.map_err(|err| {
|
||||
warn!("Session cookie decode error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let session_token = match maybe_session_token {
|
||||
Some(session_token) => session_token,
|
||||
None => {
|
||||
warn!("not logged in");
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
let maybe_record = get_api_or_web_session_user_by_session_token(GetApiOrWebSessionUserBySessionTokenArgs {
|
||||
session_token: &session_token,
|
||||
mysql_executor,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
.map_err(|err| {
|
||||
warn!("Web session user lookup error: {:?}", err);
|
||||
CommonWebError::from_error(err)
|
||||
})?;
|
||||
|
||||
let record = match maybe_record {
|
||||
Some(record) => record,
|
||||
None => {
|
||||
warn!("Valid cookie; invalid session: {}", session_token);
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
};
|
||||
|
||||
let maybe_avt_token = avt_cookie_manager.get_avt_token_from_request(http_request);
|
||||
|
||||
into_session_rejecting_banned(record, AnySessionType::WebSession, maybe_avt_token)
|
||||
}
|
||||
|
||||
fn into_session_rejecting_banned(
|
||||
record: ApiOrWebSessionUserRecord,
|
||||
session_type: AnySessionType,
|
||||
maybe_avt_token: Option<AnonymousVisitorTrackingToken>,
|
||||
) -> Result<AnySession, CommonWebError> {
|
||||
if record.is_banned {
|
||||
warn!("user is banned: {:?}", record.user_token.as_str());
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
|
||||
Ok(AnySession {
|
||||
session_type,
|
||||
user_token: record.user_token,
|
||||
username: record.username,
|
||||
display_name: record.display_name,
|
||||
email_address: record.email_address,
|
||||
user_role_slug: record.user_role_slug,
|
||||
can_ban_users: record.can_ban_users,
|
||||
maybe_feature_flags: record.maybe_feature_flags,
|
||||
maybe_api_key_token: record.maybe_api_key_token,
|
||||
maybe_mcp_session_token: record.maybe_mcp_session_token,
|
||||
maybe_avt_token,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! DATABASE TESTS: these connect to the guarded MySQL test database (see the `mysql_testing`
|
||||
//! crate — never production, never the local dev database) and drive the real lookup with
|
||||
//! dummy Actix HTTP requests. They RUN BY DEFAULT under `cargo test`; on machines without a
|
||||
//! local MySQL, skip them with:
|
||||
//!
|
||||
//! ```bash
|
||||
//! SQLX_OFFLINE=true cargo test -p storyteller-web --features skip_database_tests
|
||||
//! ```
|
||||
//!
|
||||
//! They run in PARALLEL: every test creates its own users, API keys, and MCP sessions.
|
||||
|
||||
use actix_web::cookie::Cookie;
|
||||
use actix_web::test::TestRequest;
|
||||
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
|
||||
use artcraft_api_keys::ArtcraftApiKey;
|
||||
use mysql_queries::queries::api_keys::insert_api_key::{insert_api_key, InsertApiKeyArgs};
|
||||
use mysql_testing::fixtures::mcp_sessions::{create_test_mcp_session, force_expire_mcp_session};
|
||||
use mysql_testing::fixtures::users::{create_test_user, TestUser};
|
||||
use mysql_queries::queries::mcp_sessions::revoke_mcp_session::{
|
||||
revoke_mcp_session, RevokeMcpSessionArgs,
|
||||
};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use super::*;
|
||||
|
||||
const TEST_COOKIE_DOMAIN: &str = "localhost";
|
||||
const TEST_COOKIE_SECRET: &str = "test_cookie_secret";
|
||||
|
||||
mod web_session_lookups {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn web_session_cookie_authenticates() {
|
||||
let harness = LookupHarness::create().await;
|
||||
let user = harness.create_user().await;
|
||||
|
||||
let request = harness.request_with_session_cookie(&user);
|
||||
let session = harness.require(&request).await.expect("web session should authenticate");
|
||||
|
||||
assert_eq!(session.session_type, AnySessionType::WebSession);
|
||||
assert_eq!(session.user_token, user.user_token);
|
||||
assert_eq!(session.username, user.username);
|
||||
assert_eq!(session.email_address, user.email_address);
|
||||
assert!(session.maybe_api_key_token.is_none());
|
||||
assert!(session.maybe_mcp_session_token.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn no_credentials_at_all_is_rejected() {
|
||||
let harness = LookupHarness::create().await;
|
||||
|
||||
let request = TestRequest::default().to_http_request();
|
||||
let result = harness.require(&request).await;
|
||||
|
||||
assert!(matches!(result, Err(CommonWebError::NotAuthorized)));
|
||||
}
|
||||
}
|
||||
|
||||
mod api_key_lookups {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn api_key_authenticates() {
|
||||
let harness = LookupHarness::create().await;
|
||||
let user = harness.create_user().await;
|
||||
let api_key = harness.create_api_key(&user).await;
|
||||
|
||||
let request = request_with_bearer(api_key.as_str_be_careful());
|
||||
let session = harness.require(&request).await.expect("API key should authenticate");
|
||||
|
||||
assert_eq!(session.session_type, AnySessionType::Api);
|
||||
assert_eq!(session.user_token, user.user_token);
|
||||
assert!(session.maybe_api_key_token.is_some());
|
||||
assert!(session.maybe_mcp_session_token.is_none());
|
||||
assert!(session.maybe_avt_token.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn unknown_api_key_is_rejected() {
|
||||
let harness = LookupHarness::create().await;
|
||||
|
||||
let request = request_with_bearer("artcraft_api_no_such_key_0000000000000000");
|
||||
let result = harness.require(&request).await;
|
||||
|
||||
assert!(matches!(result, Err(CommonWebError::NotAuthorized)));
|
||||
}
|
||||
}
|
||||
|
||||
mod mcp_session_lookups {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn mcp_session_authenticates() {
|
||||
let harness = LookupHarness::create().await;
|
||||
let user = harness.create_user().await;
|
||||
let mcp_session = create_test_mcp_session(&harness.pool, &user.user_token)
|
||||
.await
|
||||
.expect("create mcp session");
|
||||
|
||||
let request = request_with_bearer(mcp_session.private_session_token.as_str());
|
||||
let session = harness.require(&request).await.expect("MCP session should authenticate");
|
||||
|
||||
assert_eq!(session.session_type, AnySessionType::McpSession);
|
||||
assert_eq!(session.user_token, user.user_token);
|
||||
assert_eq!(session.maybe_mcp_session_token, Some(mcp_session.token));
|
||||
assert!(session.maybe_api_key_token.is_none());
|
||||
assert!(session.maybe_avt_token.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn expired_mcp_session_is_rejected() {
|
||||
let harness = LookupHarness::create().await;
|
||||
let user = harness.create_user().await;
|
||||
let mcp_session = create_test_mcp_session(&harness.pool, &user.user_token)
|
||||
.await
|
||||
.expect("create mcp session");
|
||||
force_expire_mcp_session(&harness.pool, &mcp_session.token)
|
||||
.await
|
||||
.expect("expire mcp session");
|
||||
|
||||
let request = request_with_bearer(mcp_session.private_session_token.as_str());
|
||||
let result = harness.require(&request).await;
|
||||
|
||||
assert!(matches!(result, Err(CommonWebError::NotAuthorized)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn revoked_mcp_session_is_rejected() {
|
||||
let harness = LookupHarness::create().await;
|
||||
let user = harness.create_user().await;
|
||||
let mcp_session = create_test_mcp_session(&harness.pool, &user.user_token)
|
||||
.await
|
||||
.expect("create mcp session");
|
||||
|
||||
let mut connection = harness.pool.acquire().await.expect("acquire for revoke");
|
||||
let revoked = revoke_mcp_session(RevokeMcpSessionArgs {
|
||||
private_session_token: &mcp_session.private_session_token,
|
||||
mysql_executor: &mut *connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
.expect("revoke mcp session");
|
||||
assert_eq!(revoked, 1);
|
||||
|
||||
let request = request_with_bearer(mcp_session.private_session_token.as_str());
|
||||
let result = harness.require(&request).await;
|
||||
|
||||
assert!(matches!(result, Err(CommonWebError::NotAuthorized)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn unknown_mcp_credential_is_rejected() {
|
||||
let harness = LookupHarness::create().await;
|
||||
|
||||
let request = request_with_bearer("mcp_session_no_such_session_00000000000000000000000000000000");
|
||||
let result = harness.require(&request).await;
|
||||
|
||||
assert!(matches!(result, Err(CommonWebError::NotAuthorized)));
|
||||
}
|
||||
}
|
||||
|
||||
/// The guarded test pool plus the cookie machinery `require_any_session_or_key` needs.
|
||||
struct LookupHarness {
|
||||
pool: MySqlPool,
|
||||
session_cookie_manager: HttpUserSessionManager,
|
||||
session_checker: SessionChecker,
|
||||
avt_cookie_manager: AvtCookieManager,
|
||||
}
|
||||
|
||||
impl LookupHarness {
|
||||
async fn create() -> LookupHarness {
|
||||
let pool = mysql_testing::pool::create_test_pool().await;
|
||||
let session_cookie_manager = HttpUserSessionManager::new(TEST_COOKIE_DOMAIN, TEST_COOKIE_SECRET)
|
||||
.expect("session cookie manager");
|
||||
let session_checker = SessionChecker::new(&session_cookie_manager);
|
||||
let avt_cookie_manager =
|
||||
AvtCookieManager::new(TEST_COOKIE_DOMAIN, TEST_COOKIE_SECRET).expect("avt cookie manager");
|
||||
|
||||
LookupHarness {
|
||||
pool,
|
||||
session_cookie_manager,
|
||||
session_checker,
|
||||
avt_cookie_manager,
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_user(&self) -> TestUser {
|
||||
create_test_user(&self.pool).await.expect("create test user")
|
||||
}
|
||||
|
||||
/// Mint and insert an API key owned by `user`. The key value derives from the (unique)
|
||||
/// user token so parallel tests never collide on the `api_key` column.
|
||||
async fn create_api_key(&self, user: &TestUser) -> ArtcraftApiKey {
|
||||
let entropy = user.user_token.as_str().trim_start_matches("user_").to_string();
|
||||
let api_key = ArtcraftApiKey::new_from_str(&format!("artcraft_api_test{entropy}"));
|
||||
let mut connection = self.pool.acquire().await.expect("acquire for api key");
|
||||
insert_api_key(InsertApiKeyArgs {
|
||||
owner_user_token: &user.user_token,
|
||||
ip_address: "127.0.0.1",
|
||||
name: "test key",
|
||||
maybe_description: None,
|
||||
api_key: &api_key,
|
||||
mysql_executor: &mut *connection,
|
||||
phantom: PhantomData,
|
||||
})
|
||||
.await
|
||||
.expect("insert api key");
|
||||
api_key
|
||||
}
|
||||
|
||||
/// Run the lookup under test against a fresh connection from the test pool.
|
||||
async fn require(&self, request: &HttpRequest) -> Result<AnySession, CommonWebError> {
|
||||
let mut connection = self.pool.acquire().await.expect("acquire for lookup");
|
||||
require_any_session_or_key(
|
||||
request,
|
||||
&self.session_checker,
|
||||
&self.avt_cookie_manager,
|
||||
&mut *connection,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// A dummy HTTP request carrying the user's session cookie and no Authorization header.
|
||||
fn request_with_session_cookie(&self, user: &TestUser) -> HttpRequest {
|
||||
let cookie = self
|
||||
.session_cookie_manager
|
||||
.create_cookie(&user.session_token, &user.user_token)
|
||||
.expect("create session cookie");
|
||||
// TestRequest wants an owned cookie with a 'static-compatible lifetime.
|
||||
let cookie = Cookie::new("session", cookie.value().to_string());
|
||||
|
||||
TestRequest::default().cookie(cookie).to_http_request()
|
||||
}
|
||||
}
|
||||
|
||||
/// A dummy HTTP request presenting `credential` as a Bearer Authorization header.
|
||||
fn request_with_bearer(credential: &str) -> HttpRequest {
|
||||
TestRequest::default()
|
||||
.insert_header(("Authorization", format!("Bearer {credential}")))
|
||||
.to_http_request()
|
||||
}
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
use actix_web::HttpRequest;
|
||||
|
||||
use tokens::tokens::mcp_session_private::McpSessionPrivateToken;
|
||||
|
||||
use crate::http_server::web_utils::get_authorization_header_api_key::get_authorization_header_api_key;
|
||||
|
||||
/// Extract an MCP session's private credential from a request's `Authorization` header.
|
||||
///
|
||||
/// MCP session credentials live in the same header (and the same `Bearer <credential>`,
|
||||
/// `Key <credential>`, and bare `<credential>` forms) as API keys, so this reuses the API-key
|
||||
/// header parsing and then requires the `mcp_session_` token prefix. Returns `None` if the
|
||||
/// header is absent, unusable, or carries a credential that is not an MCP session token —
|
||||
/// callers fall back to (or reject as) other credential types.
|
||||
pub fn get_authorization_header_mcp_private_session_token(
|
||||
http_request: &HttpRequest,
|
||||
) -> Option<McpSessionPrivateToken> {
|
||||
let credential = get_authorization_header_api_key(http_request)?;
|
||||
|
||||
if !credential.as_str_be_careful().starts_with(McpSessionPrivateToken::token_prefix()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(McpSessionPrivateToken::new_from_str(credential.as_str_be_careful()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::test::TestRequest;
|
||||
|
||||
use super::*;
|
||||
|
||||
const SAMPLE_MCP_CREDENTIAL: &str =
|
||||
"mcp_session_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh3k9m2p4q6r8t";
|
||||
const SAMPLE_API_KEY: &str = "artcraft_api_55ax0zhd580m598r6n4n7szdwjb2b28sypapvawh";
|
||||
|
||||
#[test]
|
||||
fn bearer_scheme() {
|
||||
assert_eq!(
|
||||
parsed(&format!("Bearer {SAMPLE_MCP_CREDENTIAL}")),
|
||||
Some(SAMPLE_MCP_CREDENTIAL.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_scheme() {
|
||||
assert_eq!(
|
||||
parsed(&format!("Key {SAMPLE_MCP_CREDENTIAL}")),
|
||||
Some(SAMPLE_MCP_CREDENTIAL.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_credential_aws_style() {
|
||||
assert_eq!(parsed(SAMPLE_MCP_CREDENTIAL), Some(SAMPLE_MCP_CREDENTIAL.to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_key_is_not_an_mcp_credential() {
|
||||
assert_eq!(parsed(&format!("Bearer {SAMPLE_API_KEY}")), None);
|
||||
assert_eq!(parsed(SAMPLE_API_KEY), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_header_returns_none() {
|
||||
let http_request = TestRequest::default().to_http_request();
|
||||
assert_eq!(get_authorization_header_mcp_private_session_token(&http_request), None);
|
||||
}
|
||||
|
||||
fn parsed(header_value: &str) -> Option<String> {
|
||||
let http_request = TestRequest::default()
|
||||
.insert_header(("Authorization", header_value))
|
||||
.to_http_request();
|
||||
get_authorization_header_mcp_private_session_token(&http_request)
|
||||
.map(|token| token.as_str().to_string())
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod bucket_urls;
|
||||
pub mod filter_model_name;
|
||||
pub mod get_authorization_header_api_key;
|
||||
pub mod get_authorization_header_mcp_private_session_token;
|
||||
pub mod get_host_header;
|
||||
pub mod get_request_platform_type;
|
||||
pub mod handle_multipart_error;
|
||||
|
||||
Reference in new issue
Block a user