Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9169e4d662 | ||
|
|
4c7af51851 | ||
|
|
eadfb4f821 | ||
|
|
0c163d6c19 | ||
|
|
26484685ee | ||
|
|
5bceb33801 | ||
|
|
c6e2c4296a | ||
|
|
8551d58def | ||
|
|
807180bf22 | ||
|
|
f48d2beea7 | ||
|
|
dc72bbd5e6 | ||
|
|
b6a184bcb0 | ||
|
|
d0e1d113fe | ||
|
|
1facb312dc | ||
|
|
f2c8a0390f | ||
|
|
2e83d39d2b | ||
|
|
4f1c9134ee | ||
|
|
f0a877c988 | ||
|
|
f77f09018a |
No files matched your search
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "confirmation_code",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 32,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status: UserLoginChallengeStatus",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 64,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_deciding_user_token",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_redeemed_user_session_id",
|
||||
"ordinal": 4,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "UNIQUE_KEY",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_failure_type: UserLoginChallengeFailureType",
|
||||
"ordinal": 5,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ip_address_creation",
|
||||
"ordinal": 6,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 160,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "expires_at",
|
||||
"ordinal": 7,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"max_size": 19,
|
||||
"type": "Timestamp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "06dc6f19f013fe24ffd32a313b66dd10a87498bf17c9a63b1ece87e338b0dc74",
|
||||
"query": "\nSELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at\nFROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
}
|
||||
},
|
||||
"hash": "1af20e0d9a5630302f987aea4847b65a34d6c412245f7fbf9c6fb7b513d7324b",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at\nWHERE id = ? AND status IN (?, ?) AND expires_at <= NOW()\n"
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "expired",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | BINARY",
|
||||
"max_size": 1,
|
||||
"type": "LongLong"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "1c618e9816d6f3f7f217770f988a132d9521693da6474e945628d1b3efbc4b80",
|
||||
"query": "\nSELECT expires_at <= NOW() AS expired FROM user_login_challenges WHERE id = ?\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
}
|
||||
},
|
||||
"hash": "2ddd5589138c822385b39dde58fc0d44ffa003b09018f1799e243d8f09530981",
|
||||
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n maybe_impersonation_user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES (?, ?, ?, ?, ?, ?)\n "
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "session_token",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "459898b1b42a51097dcbc0f182bd0badce642b11bd33a7e1973a6cc25538ffbf",
|
||||
"query": "\nSELECT\n token as session_token,\n user_token\nFROM user_sessions\nWHERE token = ?\nAND deleted_at IS NULL\nAND expires_at > NOW()\n "
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"hash": "4d3666433b8100c9b5f351c2be0faa982127c5bb4431ef14e84ff11c5a2fd9ff",
|
||||
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, ?, NOW() + interval 1 year )\n "
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 3
|
||||
}
|
||||
},
|
||||
"hash": "705c952102e32df11ba98ec39a3c7943bdf633947c32d4e8f7d77a861560b1bd",
|
||||
"query": "\nSELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token\nWHERE s.id = ? AND s.user_token = ? AND s.maybe_creation_type = ?\n AND s.deleted_at IS NULL AND s.expires_at > NOW() AND s.maybe_impersonation_user_token IS NULL\n AND u.is_banned = FALSE AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 5
|
||||
}
|
||||
},
|
||||
"hash": "7e9bc784634f436a8bf898223a5ddeea57886b7c248d82e782dd3289c23cbaec",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_redeemed_user_session_id = ?,\n maybe_ip_address_redemption = ?, maybe_redeemed_at = NOW()\nWHERE id = ? AND status = ? AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL\n"
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "token",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "8bfbb8bef3bec235f37c5f4f5c2f3a8b0833241a63da122b8abd97cd9b71208f",
|
||||
"query": "\nSELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token\nWHERE s.token = ? AND s.deleted_at IS NULL AND s.expires_at > NOW()\n AND s.maybe_impersonation_user_token IS NULL AND u.is_banned = FALSE\n AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL\n"
|
||||
}
|
||||
+454
@@ -0,0 +1,454 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "user_token",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "display_name",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 80,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_address",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | UNIQUE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 1020,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_gravatar_hash",
|
||||
"ordinal": 4,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 128,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_confirmed",
|
||||
"ordinal": 5,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_confirmed_by_google",
|
||||
"ordinal": 6,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "email_is_synthetic",
|
||||
"ordinal": 7,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "is_without_password",
|
||||
"ordinal": 8,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "username_is_not_customized",
|
||||
"ordinal": 9,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_stripe_customer_id",
|
||||
"ordinal": 10,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 1020,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_loyalty_program_key",
|
||||
"ordinal": 11,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "disable_gravatar",
|
||||
"ordinal": 12,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auto_play_audio_preference",
|
||||
"ordinal": 13,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "auto_play_video_preference",
|
||||
"ordinal": 14,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_tts_result_visibility: enums::common::visibility::Visibility",
|
||||
"ordinal": 15,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"max_size": 28,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "preferred_w2l_result_visibility: enums::common::visibility::Visibility",
|
||||
"ordinal": 16,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | ENUM",
|
||||
"max_size": 28,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_role_slug",
|
||||
"ordinal": 17,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 64,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "is_banned",
|
||||
"ordinal": 18,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_access_studio",
|
||||
"ordinal": 19,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_feature_flags",
|
||||
"ordinal": 20,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 1020,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_use_tts",
|
||||
"ordinal": 21,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_use_w2l",
|
||||
"ordinal": 22,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_tts_results",
|
||||
"ordinal": 23,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_w2l_results",
|
||||
"ordinal": 24,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_account",
|
||||
"ordinal": 25,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_upload_tts_models",
|
||||
"ordinal": 26,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_upload_w2l_templates",
|
||||
"ordinal": 27,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_tts_models",
|
||||
"ordinal": 28,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_own_w2l_templates",
|
||||
"ordinal": 29,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_approve_w2l_templates",
|
||||
"ordinal": 30,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_edit_other_users_profiles",
|
||||
"ordinal": 31,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_edit_other_users_tts_models",
|
||||
"ordinal": 32,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_edit_other_users_w2l_templates",
|
||||
"ordinal": 33,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_tts_models",
|
||||
"ordinal": 34,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_tts_results",
|
||||
"ordinal": 35,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_w2l_templates",
|
||||
"ordinal": 36,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_other_users_w2l_results",
|
||||
"ordinal": 37,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_ban_users",
|
||||
"ordinal": 38,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "can_delete_users",
|
||||
"ordinal": 39,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "",
|
||||
"max_size": 1,
|
||||
"type": "Tiny"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "934aad794dcb0057e39c471e7b25e5e191a395a2bd16553cb58a483b91c68437",
|
||||
"query": "\nSELECT\n users.token as user_token,\n users.username,\n users.display_name,\n\n users.email_address,\n users.email_gravatar_hash,\n \n users.email_confirmed,\n users.email_confirmed_by_google,\n users.email_is_synthetic,\n users.is_without_password,\n users.username_is_not_customized,\n\n users.maybe_stripe_customer_id,\n users.maybe_loyalty_program_key,\n\n users.disable_gravatar,\n users.auto_play_audio_preference,\n users.auto_play_video_preference,\n users.preferred_tts_result_visibility as `preferred_tts_result_visibility: enums::common::visibility::Visibility`,\n users.preferred_w2l_result_visibility as `preferred_w2l_result_visibility: enums::common::visibility::Visibility`,\n\n users.user_role_slug,\n users.is_banned,\n\n users.can_access_studio,\n users.maybe_feature_flags,\n\n user_roles.can_use_tts,\n user_roles.can_use_w2l,\n user_roles.can_delete_own_tts_results,\n user_roles.can_delete_own_w2l_results,\n user_roles.can_delete_own_account,\n\n user_roles.can_upload_tts_models,\n user_roles.can_upload_w2l_templates,\n user_roles.can_delete_own_tts_models,\n user_roles.can_delete_own_w2l_templates,\n\n user_roles.can_approve_w2l_templates,\n user_roles.can_edit_other_users_profiles,\n user_roles.can_edit_other_users_tts_models,\n user_roles.can_edit_other_users_w2l_templates,\n user_roles.can_delete_other_users_tts_models,\n user_roles.can_delete_other_users_tts_results,\n user_roles.can_delete_other_users_w2l_templates,\n user_roles.can_delete_other_users_w2l_results,\n user_roles.can_ban_users,\n user_roles.can_delete_users\n\nFROM users\nLEFT OUTER JOIN user_sessions\n ON users.token = user_sessions.user_token\nLEFT OUTER JOIN user_roles\n ON users.user_role_slug = user_roles.slug\nWHERE user_sessions.token = ?\n AND user_sessions.deleted_at IS NULL\n AND user_sessions.expires_at > NOW()\n AND users.user_deleted_at IS NULL\n AND users.mod_deleted_at IS NULL\n "
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"hash": "ab9e5c27ce0c460aa6d5d1b202829c2082fa0701773582d22aae05a9d376d4a6",
|
||||
"query": "\nINSERT INTO user_sessions (\n token,\n user_token,\n ip_address_creation,\n maybe_creation_type,\n expires_at\n)\nVALUES ( ?, ?, ?, ?, NOW() + interval 1 year )\n "
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"name": "id",
|
||||
"ordinal": 0,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | PRIMARY_KEY | UNSIGNED | AUTO_INCREMENT",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "confirmation_code",
|
||||
"ordinal": 1,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 32,
|
||||
"type": "String"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "status: UserLoginChallengeStatus",
|
||||
"ordinal": 2,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 64,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_deciding_user_token",
|
||||
"ordinal": 3,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "MULTIPLE_KEY | BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_redeemed_user_session_id",
|
||||
"ordinal": 4,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "UNIQUE_KEY",
|
||||
"max_size": 20,
|
||||
"type": "LongLong"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "maybe_failure_type: UserLoginChallengeFailureType",
|
||||
"ordinal": 5,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "BINARY",
|
||||
"max_size": 128,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ip_address_creation",
|
||||
"ordinal": 6,
|
||||
"type_info": {
|
||||
"char_set": 224,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | NO_DEFAULT_VALUE",
|
||||
"max_size": 160,
|
||||
"type": "VarString"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "expires_at",
|
||||
"ordinal": 7,
|
||||
"type_info": {
|
||||
"char_set": 63,
|
||||
"flags": "NOT_NULL | MULTIPLE_KEY | BINARY | TIMESTAMP",
|
||||
"max_size": 19,
|
||||
"type": "Timestamp"
|
||||
}
|
||||
}
|
||||
],
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
],
|
||||
"parameters": {
|
||||
"Right": 1
|
||||
}
|
||||
},
|
||||
"hash": "c0c85c8c55fb494d44efa6f1f2d37491c740d0ac897dcfb7a7e737bb01b824d6",
|
||||
"query": "\nSELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,\n maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at\nFROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
}
|
||||
},
|
||||
"hash": "cc29378fa77e7f09ca2ca0803127d111703a4edca24acfe3ccecf79c22fc1a80",
|
||||
"query": "\nINSERT INTO user_login_challenges\n (token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, status, created_at, expires_at)\nVALUES (?, ?, ?, ?, ?, ?, NOW(), NOW() + INTERVAL 20 MINUTE)\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"hash": "d0067b5e1b3ef5dec5572eb2c0741d129ab2f706db4bb9f37a32bb89aa44fa4f",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at\nWHERE status IN (?, ?) AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 8
|
||||
}
|
||||
},
|
||||
"hash": "da64968638e564d28ed180270df407157f6c9b3f27c3443417f30c12c158ab89",
|
||||
"query": "\nUPDATE user_login_challenges SET status = ?, maybe_deciding_user_token = ?,\n maybe_ip_address_decision = ?, maybe_decided_at = NOW(), maybe_failure_type = ?,\n maybe_ip_address_failure = ?, maybe_failed_at = IF(?, NULL, NOW())\nWHERE id = ? AND status = ? AND expires_at > NOW()\n"
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "MySQL",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"nullable": [],
|
||||
"parameters": {
|
||||
"Right": 4
|
||||
}
|
||||
},
|
||||
"hash": "eb5fdd0e93978edfbe97c58238ea81f0c21dba047975beb53a39224894e9d41c",
|
||||
"query": "\nINSERT INTO user_sessions (token, user_token, ip_address_creation, maybe_creation_type, expires_at)\nSELECT ?, token, ?, ?, NOW() + INTERVAL 1 YEAR FROM users\nWHERE token = ? AND is_banned = FALSE AND user_deleted_at IS NULL AND mod_deleted_at IS NULL\n"
|
||||
}
|
||||
@@ -32,12 +32,45 @@ artcraft-services/
|
||||
│ ├── plugins/ # Shared billing and service components
|
||||
│ └── web/storyteller_web/ # Main Actix Web HTTP API
|
||||
├── frontend/
|
||||
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
|
||||
│ ├── apps/artcraft-website/ # Product website at getartcraft.com
|
||||
│ └── libs/ # Shared React components and TypeScript libraries
|
||||
│ ├── apps/artcraft-webapp/ # Browser application at app.getartcraft.com
|
||||
│ ├── apps/artcraft-website-next/ # Current public website at getartcraft.com (Next.js)
|
||||
│ ├── apps/artcraft-website/ # Legacy product website (Vite)
|
||||
│ └── libs/ # Shared React components and TypeScript libraries
|
||||
└── Cargo.toml # Rust workspace
|
||||
```
|
||||
|
||||
## Updating Desktop Download Links
|
||||
|
||||
Desktop release links are configured independently in **three places**. When
|
||||
updating the advertised ArtCraft release, update both `WINDOWS_VERSION` and
|
||||
`MAC_VERSION` in all three unless the request explicitly targets one platform or
|
||||
app:
|
||||
|
||||
1. **Current public website (Next.js):**
|
||||
[frontend/apps/artcraft-website-next/src/lib/download-links.ts](frontend/apps/artcraft-website-next/src/lib/download-links.ts).
|
||||
This controls the installer links and visible version at
|
||||
`https://getartcraft.com/download`. Add the release to `DOWNLOAD_HISTORY`
|
||||
(newest first), then update both version selectors.
|
||||
2. **Legacy website (Vite):**
|
||||
[frontend/apps/artcraft-website/src/config/github_download_links.ts](frontend/apps/artcraft-website/src/config/github_download_links.ts).
|
||||
Add the release to `DOWNLOAD_HISTORY` and update both version selectors.
|
||||
Updating only this file does **not** update the current public website.
|
||||
3. **Browser webapp:**
|
||||
[frontend/apps/artcraft-webapp/src/config/github_download_links.ts](frontend/apps/artcraft-webapp/src/config/github_download_links.ts).
|
||||
Update both version constants; these links are used by the welcome and
|
||||
checkout-success pages at `app.getartcraft.com`.
|
||||
|
||||
Keep previous releases for rollback. The Next.js download page reads its visible
|
||||
version from `DOWNLOAD_VERSIONS`, which uses the same selectors as the URLs; do
|
||||
not hardcode a separate version in the page component.
|
||||
|
||||
Check that all three configs produce the requested release tag and installer
|
||||
filenames for Windows (`ArtCraft_<version>_x64-setup.exe`) and macOS
|
||||
(`ArtCraft_<version>_universal.dmg`). Run `npm run typecheck` from
|
||||
`frontend/apps/artcraft-website-next` after changing its config. Repository edits
|
||||
require deployment of the affected apps before the public pages change; do not
|
||||
report a local update as a deployed update.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Rust with no minimum supported version
|
||||
|
||||
Generated
+2
@@ -8764,6 +8764,7 @@ dependencies = [
|
||||
"redis_common",
|
||||
"redis_schema",
|
||||
"regex",
|
||||
"reqwest 0.12.23",
|
||||
"resend-rs",
|
||||
"reusable_types",
|
||||
"rootly_client",
|
||||
@@ -8775,6 +8776,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"server_environment",
|
||||
"sha1_hash",
|
||||
"sha2 0.11.0-rc.5",
|
||||
"shared_env_var_config",
|
||||
"speculoos",
|
||||
"sqlx",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
DROP TABLE IF EXISTS user_login_challenges;
|
||||
@@ -0,0 +1,93 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- One approval request for a new cookie-based user session on another device.
|
||||
-- See _database/sql/user_login_challenges.md for transitions and API requirements.
|
||||
-- This table does not authorize MCP sessions or API keys.
|
||||
CREATE TABLE user_login_challenges (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Non-secret identifier for logs/support. Never sufficient to approve or redeem.
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- SHA-256 digests of TWO INDEPENDENT cryptographically random 256-bit tokens.
|
||||
-- Hash the canonical unpadded base64url token text; never store the raw tokens.
|
||||
-- The approval token goes in the browser/QR URL and only permits reviewing a
|
||||
-- request and submitting an authenticated user's explicit approve/decline choice.
|
||||
approval_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Only the initiating device receives this token. Required for polling/redeeming.
|
||||
-- Never include it in the approval URL, QR code, browser page, or application logs.
|
||||
device_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Eight random uppercase consonants, displayed as e.g. WDJB-MJHT on BOTH screens.
|
||||
-- Comparison aid only: not unique, not a lookup key, not an authentication factor.
|
||||
confirmation_code CHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
|
||||
-- UserLoginChallengeStatus: pending, approved, redeemed, failed.
|
||||
-- Only pending may be approved/declined. Only approved may mint a session.
|
||||
-- Expiry is checked independently of status on EVERY approval/redemption request.
|
||||
-- Application inserts explicitly bind UserLoginChallengeStatus::Pending.
|
||||
status VARCHAR(16) NOT NULL,
|
||||
|
||||
-- The authenticated web user who approved OR declined. NULL until a decision.
|
||||
-- On approval this becomes the immutable owner of the session to be minted.
|
||||
maybe_deciding_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Set atomically with status=redeemed and session insertion, under a row lock.
|
||||
-- Numeric reference avoids copying a session credential into this audit table.
|
||||
-- Intentionally signed to match the existing user_sessions.id column.
|
||||
-- At most one session per challenge; retries may only return that SAME live session.
|
||||
maybe_redeemed_user_session_id BIGINT(20) DEFAULT NULL,
|
||||
|
||||
-- UserLoginChallengeFailureType: user_declined, expired.
|
||||
-- Only failed rows have a failure type and maybe_failed_at.
|
||||
-- Transient errors / invalid credentials must not overwrite a valid challenge.
|
||||
maybe_failure_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- Server-observed requesting device IP, displayed on the approval screen.
|
||||
-- Canonical IPv4/IPv6 strings; never accept an IP supplied in a request body.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Browser/mobile IP for approval or denial; may differ from the device IP.
|
||||
maybe_ip_address_decision VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- Device IP at FIRST successful redemption. Retries do not overwrite it.
|
||||
maybe_ip_address_redemption VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- For user_declined this is the browser IP. For expiry there is no actor/IP.
|
||||
-- ip_address_creation is retained on ALL failures, including timeouts.
|
||||
maybe_ip_address_failure VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Immutable deadline: insert with created_at=NOW(), expires_at=NOW()+INTERVAL 20 MINUTE.
|
||||
-- The default deliberately fails closed if a writer forgets to set the deadline.
|
||||
-- Approval, polling, and retrying must NEVER extend it. NOW() >= expires_at is expired.
|
||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
maybe_decided_at TIMESTAMP NULL DEFAULT NULL,
|
||||
maybe_redeemed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- Decision time for user_declined; expires_at for expired (even if recorded later).
|
||||
maybe_failed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (approval_token_sha256),
|
||||
UNIQUE KEY (device_token_sha256),
|
||||
UNIQUE KEY (maybe_redeemed_user_session_id),
|
||||
KEY index_status_expires_at (status, expires_at),
|
||||
KEY index_expires_at (expires_at),
|
||||
KEY index_deciding_user_created_at (maybe_deciding_user_token, created_at),
|
||||
KEY index_creation_ip_created_at (ip_address_creation, created_at),
|
||||
KEY index_failure_ip_failed_at (maybe_ip_address_failure, maybe_failed_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
ALTER TABLE user_sessions
|
||||
DROP COLUMN maybe_creation_type;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- UserWebSessionCreationType: direct_login, device_approval, impersonation.
|
||||
-- NULL means unknown / legacy writer, not direct_login. Do not backfill guesses.
|
||||
-- Additive metadata only: does not change validation, expiry, or impersonation rules.
|
||||
-- MCP sessions and API keys are separate tables and do not use this field.
|
||||
ALTER TABLE user_sessions
|
||||
ADD COLUMN maybe_creation_type VARCHAR(32) DEFAULT NULL;
|
||||
@@ -24,6 +24,11 @@ These are created with the `diesel` tool, and in particular:
|
||||
|
||||
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
|
||||
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
|
||||
- Application-owned state/status/type columns must not have schema defaults. Use
|
||||
`NOT NULL` without `DEFAULT` when required, and have application inserts explicitly
|
||||
supply the value. Document the corresponding Rust enum; application queries bind its
|
||||
variants instead of hardcoding enum strings in SQL. See
|
||||
`crates/schema/database/mysql_queries/AGENTS.md` for the query rules.
|
||||
- All SQL files should start with the standard IntelliJ noinspection comments:
|
||||
```sql
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
@@ -33,6 +38,7 @@ These are created with the `diesel` tool, and in particular:
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep documentation in this directory as regular files, not symlinks. Diesel can treat a documentation symlink as a migration directory and reject the entire migration list.
|
||||
- Every `up.sql` must have a corresponding `down.sql` that cleanly reverses the migration.
|
||||
- For `CREATE TABLE` migrations, the `down.sql` is typically `DROP TABLE IF EXISTS table_name;`.
|
||||
- For `ALTER TABLE` migrations, the `down.sql` reverses the alteration (e.g. `DROP COLUMN`).
|
||||
@@ -1 +0,0 @@
|
||||
AGENTS.md
|
||||
@@ -0,0 +1,6 @@
|
||||
# SQL Migrations
|
||||
|
||||
Read and follow [AGENTS.md](AGENTS.md) in this directory.
|
||||
|
||||
This must remain a regular file. Diesel treats a symlink here as a migration
|
||||
directory and fails with `Invalid migration directory`.
|
||||
@@ -13,6 +13,9 @@ Update the corresponding squashed file whenever a migration changes a table's sc
|
||||
|
||||
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
|
||||
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
|
||||
- Application-owned state/status/type columns must not have schema defaults. Required
|
||||
values stay `NOT NULL` and are supplied explicitly by application code using the
|
||||
corresponding Rust enum. Keep these definitions consistent with the applied migrations.
|
||||
- All SQL files should start with the standard IntelliJ noinspection comments:
|
||||
```sql
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
-- noinspection SqlDialectInspectionForFile
|
||||
-- noinspection SqlNoDataSourceInspectionForFile
|
||||
-- noinspection SqlResolveForFile
|
||||
|
||||
-- One approval request for a new cookie-based user session on another device.
|
||||
-- See _database/sql/user_login_challenges.md for transitions and API requirements.
|
||||
-- This table does not authorize MCP sessions or API keys.
|
||||
CREATE TABLE user_login_challenges (
|
||||
id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
|
||||
-- Non-secret identifier for logs/support. Never sufficient to approve or redeem.
|
||||
token VARCHAR(32) NOT NULL,
|
||||
|
||||
-- SHA-256 digests of TWO INDEPENDENT cryptographically random 256-bit tokens.
|
||||
-- Hash the canonical unpadded base64url token text; never store the raw tokens.
|
||||
-- The approval token goes in the browser/QR URL and only permits reviewing a
|
||||
-- request and submitting an authenticated user's explicit approve/decline choice.
|
||||
approval_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Only the initiating device receives this token. Required for polling/redeeming.
|
||||
-- Never include it in the approval URL, QR code, browser page, or application logs.
|
||||
device_token_sha256 BINARY(32) NOT NULL,
|
||||
|
||||
-- Eight random uppercase consonants, displayed as e.g. WDJB-MJHT on BOTH screens.
|
||||
-- Comparison aid only: not unique, not a lookup key, not an authentication factor.
|
||||
confirmation_code CHAR(8) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
|
||||
-- UserLoginChallengeStatus: pending, approved, redeemed, failed.
|
||||
-- Only pending may be approved/declined. Only approved may mint a session.
|
||||
-- Expiry is checked independently of status on EVERY approval/redemption request.
|
||||
-- Application inserts explicitly bind UserLoginChallengeStatus::Pending.
|
||||
status VARCHAR(16) NOT NULL,
|
||||
|
||||
-- The authenticated web user who approved OR declined. NULL until a decision.
|
||||
-- On approval this becomes the immutable owner of the session to be minted.
|
||||
maybe_deciding_user_token VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- Set atomically with status=redeemed and session insertion, under a row lock.
|
||||
-- Numeric reference avoids copying a session credential into this audit table.
|
||||
-- Intentionally signed to match the existing user_sessions.id column.
|
||||
-- At most one session per challenge; retries may only return that SAME live session.
|
||||
maybe_redeemed_user_session_id BIGINT(20) DEFAULT NULL,
|
||||
|
||||
-- UserLoginChallengeFailureType: user_declined, expired.
|
||||
-- Only failed rows have a failure type and maybe_failed_at.
|
||||
-- Transient errors / invalid credentials must not overwrite a valid challenge.
|
||||
maybe_failure_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- ========== IP ADDRESSES ==========
|
||||
|
||||
-- Server-observed requesting device IP, displayed on the approval screen.
|
||||
-- Canonical IPv4/IPv6 strings; never accept an IP supplied in a request body.
|
||||
ip_address_creation VARCHAR(40) NOT NULL,
|
||||
|
||||
-- Browser/mobile IP for approval or denial; may differ from the device IP.
|
||||
maybe_ip_address_decision VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- Device IP at FIRST successful redemption. Retries do not overwrite it.
|
||||
maybe_ip_address_redemption VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- For user_declined this is the browser IP. For expiry there is no actor/IP.
|
||||
-- ip_address_creation is retained on ALL failures, including timeouts.
|
||||
maybe_ip_address_failure VARCHAR(40) DEFAULT NULL,
|
||||
|
||||
-- ========== TIMESTAMPS ==========
|
||||
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
-- Immutable deadline: insert with created_at=NOW(), expires_at=NOW()+INTERVAL 20 MINUTE.
|
||||
-- The default deliberately fails closed if a writer forgets to set the deadline.
|
||||
-- Approval, polling, and retrying must NEVER extend it. NOW() >= expires_at is expired.
|
||||
expires_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
maybe_decided_at TIMESTAMP NULL DEFAULT NULL,
|
||||
maybe_redeemed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- Decision time for user_declined; expires_at for expired (even if recorded later).
|
||||
maybe_failed_at TIMESTAMP NULL DEFAULT NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
UNIQUE KEY (approval_token_sha256),
|
||||
UNIQUE KEY (device_token_sha256),
|
||||
UNIQUE KEY (maybe_redeemed_user_session_id),
|
||||
KEY index_status_expires_at (status, expires_at),
|
||||
KEY index_expires_at (expires_at),
|
||||
KEY index_deciding_user_created_at (maybe_deciding_user_token, created_at),
|
||||
KEY index_creation_ip_created_at (ip_address_creation, created_at),
|
||||
KEY index_failure_ip_failed_at (maybe_ip_address_failure, maybe_failed_at)
|
||||
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;
|
||||
@@ -41,6 +41,12 @@ CREATE TABLE user_sessions (
|
||||
-- designed to be recoverable.
|
||||
deleted_at TIMESTAMP NULL,
|
||||
|
||||
-- UserWebSessionCreationType: direct_login, device_approval, impersonation.
|
||||
-- NULL means unknown / legacy writer, not direct_login. Do not backfill guesses.
|
||||
-- Additive metadata only: does not change validation, expiry, or impersonation rules.
|
||||
-- MCP sessions and API keys are separate tables and do not use this field.
|
||||
maybe_creation_type VARCHAR(32) DEFAULT NULL,
|
||||
|
||||
-- INDICES --
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY (token),
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
# Website-approved device login
|
||||
|
||||
Implemented across storyteller-web, the browser approval page, and the Tauri
|
||||
login dialog. Apply the two migrations before deploying the API; deploy the web
|
||||
approval route before releasing the desktop buttons. No production migrations or
|
||||
deployments were performed while developing this change.
|
||||
|
||||
Call the flow **device approval**, and each attempt a **login challenge**. It works
|
||||
for a desktop browser link or a phone scanning a QR code, regardless of how the
|
||||
approving account signed in. It creates an ordinary cookie-based web session.
|
||||
|
||||
The design borrows separate device/browser credentials, polling, explicit consent,
|
||||
and code comparison from [OAuth device authorization (RFC 8628)](https://www.rfc-editor.org/rfc/rfc8628.html#section-3.3.1).
|
||||
It is an ArtCraft session bridge, not an OAuth token endpoint implementation.
|
||||
|
||||
## Two migrations
|
||||
|
||||
1. `2026-09-25-120000-0000_create_user_login_challenges` creates the challenge and
|
||||
its terminal outcome audit record together. A separate failure table is not
|
||||
needed for one immutable outcome per attempt. Status is `NOT NULL` without a
|
||||
default; inserts explicitly bind `UserLoginChallengeStatus::Pending`.
|
||||
2. `2026-09-25-120000-0001_alter_user_sessions_add_maybe_creation_type` adds nullable
|
||||
provenance to cookie sessions. Existing rows and older writers retain NULL.
|
||||
|
||||
Both have reverse migrations and matching materialized schemas under
|
||||
`migrations_squashed/user_tables/`. Deploy the schema before new writers. Roll
|
||||
back feature code before dropping the column/table; rollback removes bridge audit
|
||||
records but does not revoke sessions already issued.
|
||||
|
||||
`UserWebSessionCreationType` values:
|
||||
|
||||
| Rust variant | Stored value | Meaning |
|
||||
|------------------|-------------------|-------------------------------------------------------------------|
|
||||
| DirectLogin | direct_login | Password or SSO login/signup, including desktop password login. |
|
||||
| DeviceApproval | device_approval | A new session minted from an approved login challenge. |
|
||||
| Impersonation | impersonation | Existing consented staff impersonation flow. |
|
||||
| None | NULL | Unknown provenance, including existing rows and older writers. |
|
||||
|
||||
`DirectLogin` describes the creation mechanism more accurately than `Web`, since
|
||||
ordinary password login is also available in the desktop app. This field does not
|
||||
replace `maybe_impersonation_user_token` or influence authentication/authorization.
|
||||
MCP and API credentials continue using `mcp_sessions` and `api_keys` respectively.
|
||||
Existing password/SSO and impersonation session writers now record provenance.
|
||||
There is no historical backfill or default that would invent it.
|
||||
|
||||
## Credentials and confirmation
|
||||
|
||||
Generate two independent 32-byte CSPRNG values per challenge. Encode each as
|
||||
unpadded base64url (43 ASCII characters). Store only the SHA-256 digest of that
|
||||
canonical ASCII representation in the two uniquely indexed `BINARY(32)` columns.
|
||||
Reject noncanonical input instead of accepting multiple representations. These
|
||||
are random tokens, not passwords, so a fast cryptographic hash is appropriate.
|
||||
|
||||
| Value | Who receives it | What it permits |
|
||||
|---------------------|-------------------------------|--------------------------------------------------------|
|
||||
| token | Internal logs/support | Identifying a row; no authentication. |
|
||||
| approval token | Desktop, browser link, QR | Review and authenticated approval/decline only. |
|
||||
| device token | Initiating desktop only | Polling and collecting the newly authorized session. |
|
||||
| confirmation code | Desktop and approval screen | Visual comparison only; no lookup or authentication. |
|
||||
|
||||
Use eight uniformly random characters from `BCDFGHJKLMNPQRSTVWXZ` for the code.
|
||||
Store `WDJBMJHT`, display `WDJB-MJHT`, and ask the user to verify the match. It need
|
||||
not be unique because it is never used to locate or authorize a challenge. Manual
|
||||
short-code entry is outside this initial flow; both entry points carry the full
|
||||
approval token. The private device token must never be in the URL or QR code.
|
||||
|
||||
The website displays the requesting IP, the account being authorized, the matching
|
||||
code, and explicit **Approve desktop login** / **Decline** actions. An existing
|
||||
website session skips only the login screen, never consent. If login is needed,
|
||||
keep the pending approval context in first-party browser state and resume the
|
||||
confirmation page after password or Google SSO login. Recheck expiry after login.
|
||||
Do not send the bridge tokens to Google or another identity provider. Allow only
|
||||
the fixed first-party return route, not an arbitrary caller-supplied redirect URL.
|
||||
|
||||
The approval page is a read-only GET; link scanners and previews cannot decide
|
||||
the challenge. Decisions require a valid, non-impersonated user web session plus
|
||||
CSRF/origin protection. Never accept an API key or MCP credential as consent.
|
||||
Require the user to have initiated the request on a device in their possession;
|
||||
the visible code and IP help detect phishing but are not authentication factors.
|
||||
|
||||
The approval URL uses HTTPS and carries the browser credential in its fragment.
|
||||
The page captures and removes that fragment before referral collection or React
|
||||
providers initialize, skips analytics/referral tracking on direct entry, and sets
|
||||
a no-referrer policy. API responses send `Cache-Control: no-store` and
|
||||
`Referrer-Policy: no-referrer`; API credentials travel in JSON bodies, never query
|
||||
strings. Shared fonts and the Google sign-in provider still load. Keep request and
|
||||
response bodies out of gateway logs. Invalid credentials reveal no account or
|
||||
challenge details. The IP follows the existing trusted-gateway forwarding policy;
|
||||
the gateway must replace untrusted incoming forwarding headers.
|
||||
|
||||
## State and the 20-minute deadline
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending
|
||||
pending --> approved: authenticated approval
|
||||
pending --> failed: user_declined
|
||||
pending --> failed: expired
|
||||
approved --> failed: expired before redemption
|
||||
approved --> redeemed: atomically mint one session
|
||||
redeemed --> [*]
|
||||
failed --> [*]
|
||||
```
|
||||
|
||||
Set `created_at = NOW()` and `expires_at = NOW() + INTERVAL 20 MINUTE` in the same
|
||||
INSERT using the database's clock. The default expiry is immediate so a writer
|
||||
that omits it fails closed. Neither approval nor polling extends this deadline.
|
||||
All eligibility checks require `expires_at > NOW()`; equality means expired.
|
||||
The create query enforces this exact lifetime; these migrations do not add
|
||||
triggers or database CHECK constraints. Keep database connections in UTC.
|
||||
|
||||
The persisted status alone is insufficient to decide validity. A pending or
|
||||
approved row whose deadline passed is already expired even before a sweeper
|
||||
marks it `failed`. Approvals and first redemptions must check the deadline again
|
||||
under the row lock. Polls are normally read-only and do not touch `updated_at`.
|
||||
|
||||
| Status | Required accompanying data |
|
||||
|------------|-------------------------------------------------------------------------------------|
|
||||
| pending | No decision, failure, redemption, or session reference. |
|
||||
| approved | Deciding user, decision IP/time; no failure, redemption, or session reference. |
|
||||
| redeemed | Approval data plus session ID, first redemption IP/time; no failure. |
|
||||
| failed | Failure type/time; no redemption or session reference; retain any prior approval. |
|
||||
|
||||
`UserLoginChallengeFailureType` initially has two values:
|
||||
|
||||
- `user_declined`: record the deciding user, browser IP in both decision/failure
|
||||
IP fields, and the decision time in both decision/failure timestamps. Keep the
|
||||
original desktop IP. Polling with the valid device token reports the decline.
|
||||
- `expired`: record `maybe_failed_at = expires_at`. Retain original desktop IP and
|
||||
any prior approval, but leave the failure IP NULL: a timeout has no actor. A
|
||||
scheduled sweep records abandoned requests even when nobody polls again.
|
||||
|
||||
Failed challenges are unusable for approval or redemption. Their digests and
|
||||
audit metadata remain for diagnosing failures; do not physically delete the row
|
||||
when a user declines. A new attempt creates a new row and fresh tokens. Closing
|
||||
the desktop dialog stops polling; unused requests expire normally.
|
||||
|
||||
Invalid tokens, failed website sign-ins, throttling, and transient infrastructure
|
||||
errors do not terminally fail a valid challenge. Record those requests in the
|
||||
existing operational/abuse logging with their IP and failure category, without
|
||||
credentials. A second decision cannot replace the first actor or outcome.
|
||||
|
||||
## Atomic redemption and response loss
|
||||
|
||||
The website's approval records consent; it never creates or receives the desktop
|
||||
cookie. The desktop's authenticated poll performs redemption after approval:
|
||||
|
||||
1. Begin an InnoDB transaction on the primary and find the challenge by the device
|
||||
token digest with `SELECT ... FOR UPDATE`.
|
||||
2. Validate the immutable deadline and state after taking the lock. Handle
|
||||
failed/pending states without creating a session. Revalidate that the approved
|
||||
user exists and is permitted to log in under the ordinary session rules.
|
||||
3. For `approved`, insert a fresh `user_sessions` row owned by the deciding user,
|
||||
with `maybe_creation_type = 'device_approval'`, no impersonation, the current
|
||||
desktop redemption IP, and the ordinary web-session lifetime.
|
||||
4. Store its numeric ID, redemption IP/time, and `status = 'redeemed'` on the
|
||||
challenge in that same transaction. Generate the signed cookie using
|
||||
`HttpUserSessionManager::create_cookie` before commit, so signing failure rolls
|
||||
back too. Commit successfully before returning any credential to the caller.
|
||||
|
||||
Both approval/decline and redemption must serialize on the same challenge row;
|
||||
conditional updates include the expected state and the unexpired deadline, and
|
||||
must check rows affected. SQL for this flow belongs in `mysql_queries`, with
|
||||
executor/transaction support, not in the web handler. All three Rust enums in this
|
||||
change are database enums; separate client-facing status/failure enums include
|
||||
an unknown-value fallback. Desktop callers fail closed on an unknown state.
|
||||
|
||||
One-time means **at most one new session per challenge**. If the first successful
|
||||
HTTP response is lost, a retry with the same device token may return the same
|
||||
session until the original 20-minute deadline. For `redeemed`, read the linked
|
||||
session and verify its owner, creation type, absence of impersonation, expiry,
|
||||
deletion state, and the user's current eligibility before reconstructing its
|
||||
cookie. Never mint a replacement or resurrect a deleted session. After the
|
||||
deadline, return expiry without a cookie; the already-issued session keeps its
|
||||
normal lifetime, and the audit outcome remains `redeemed`.
|
||||
|
||||
The unique nullable session ID index prevents two challenges from referencing one
|
||||
session and supports retries without duplicating bearer credentials in this
|
||||
table. The reference is intentionally signed because the legacy `user_sessions.id`
|
||||
is signed. Like neighboring schemas, references are indexed logical links rather
|
||||
than cascading foreign keys; removal of a session must not erase the audit row.
|
||||
If the referenced session is missing, redemption fails closed.
|
||||
|
||||
## Implemented endpoints and clients
|
||||
|
||||
All endpoints are JSON POSTs under `/v1/login_challenges`:
|
||||
|
||||
| Endpoint | Authentication | Effect |
|
||||
|------------|---------------------------------------------|--------------------------------------------------------------|
|
||||
| create | None; limited by requesting IP | Returns device token, verification URL, code, expiry. |
|
||||
| review | Approval token + web session + Origin | Returns code, requesting IP, account, state, expiry. |
|
||||
| decide | Approval token + web session + Origin | Records the first Yes/No; never creates a desktop session. |
|
||||
| poll | Private device token | Reports state or atomically redeems approved consent. |
|
||||
|
||||
The API advertises five-second polling. Per-process IP limits are ten creates and
|
||||
120 other requests per minute, shared across Actix workers, with bounded memory.
|
||||
Production gateways can additionally enforce aggregate limits across replicas.
|
||||
A worker expires up to 1,000 abandoned challenges every 30 seconds. Audit retention
|
||||
is intentionally not an automatic purge policy in this change.
|
||||
|
||||
Both browser applications implement `/login/desktop`: `https://getartcraft.com`
|
||||
(including `www`) and `https://app.getartcraft.com`. They share the approval UI in
|
||||
`frontend/libs/login`. The API currently advertises the webapp URL. Local API
|
||||
requests advertise `http://localhost:4201/login/desktop`; the website also handles
|
||||
the route on port 4200. Run either frontend with `USE_LOCAL_API=1` so approval
|
||||
reaches the same local API; local origins cannot approve production challenges.
|
||||
The website signs in inline to preserve its origin-scoped pending token and
|
||||
mobile session header. The webapp preserves the return destination through login,
|
||||
signup, and password reset, bypassing billing redirects for desktop consent.
|
||||
Tokens stay in tab-scoped session storage, are removed from URL fragments before
|
||||
tracking, and are cleared on terminal results. Returning from authentication
|
||||
always requires explicit consent. The code stays visible for comparison; a single
|
||||
Approve or Decline click records the choice, without an extra checkbox.
|
||||
The desktop displays the QR code, code comparison, countdown, and status. Opening
|
||||
the website invokes the native system browser. Network errors back off up to 30
|
||||
seconds; closing the dialog stops polling and cancels the native handle. A response
|
||||
arriving after cancellation cannot install credentials.
|
||||
|
||||
Successful polling returns the signed value and the normal `Set-Cookie` header.
|
||||
Tauri commands use the Rust `artcraft_client` bindings for creation, polling, and
|
||||
ordinary session verification, all against `AppEnvConfigs.storyteller_host`. Rust
|
||||
keeps the private device token and signed session out of IPC/JavaScript; the UI
|
||||
holds only a local handle and public display data. After verifying the downstream
|
||||
session, Rust installs and persists the cookie in the native HTTP jar and updates
|
||||
the credential manager. No browser session is copied into the desktop.
|
||||
The desktop login/signup modal also uses Rust commands for its initial session
|
||||
check and password forms. Website/QR buttons appear only in login mode. Unknown
|
||||
optional feature flags in the session response are ignored by older desktops;
|
||||
malformed authentication data still fails verification. A stale initial session
|
||||
check cannot reset a pending or completed login.
|
||||
|
||||
## Integration tests
|
||||
|
||||
The backend harness uses the fixed local Unix socket `/tmp/mysql.sock` and local
|
||||
root account. It never reads `DATABASE_URL`, any environment database URL, DNS,
|
||||
or a TCP endpoint (including localhost SSH forwarding). Each test creates a new
|
||||
randomly named `artcraft_test_bridge_*` database, replays all migrations, and drops
|
||||
only its own database on success. Existing `storyteller` and `artcraft_test` data
|
||||
are not used. A failed test leaves its isolated database for diagnosis. The local
|
||||
MySQL instance must be running; there is no fallback to a remote database.
|
||||
|
||||
The HTTP endpoint suite is
|
||||
`crates/service/web/storyteller_web/src/http_server/endpoints/login_challenges/endpoint_integration_tests.rs`.
|
||||
It starts the real challenge handlers and `/v1/session` handler on an ephemeral
|
||||
`127.0.0.1` port. Separate browser and desktop HTTP clients maintain their own
|
||||
cookie jars; requests cannot follow redirects or use system proxies. Fixtures in
|
||||
`mysql_testing::fixtures::login_challenges` create passwordless users, Google email
|
||||
confirmation, feature flags (including `use_qt`), and signed browser sessions.
|
||||
The desktop cookie comes only from the real redemption response and is then sent
|
||||
to the real session endpoint. HTTP responses and SQL queries are not mocked.
|
||||
|
||||
From the services repository:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges
|
||||
cd frontend
|
||||
npm exec vitest -- run --config apps/artcraft-webapp/vite.config.ts src/pages/login/desktop-login.spec.tsx src/pages/login/login-bridge-continuation.spec.tsx
|
||||
npm exec vitest -- run --config apps/artcraft-website/vite.config.ts src/pages/login/desktop-login.spec.tsx
|
||||
```
|
||||
|
||||
`SQLX_OFFLINE` controls compile-time query metadata only. These tests still execute
|
||||
real SQL against local MySQL. Do not enable `skip_database_tests` for this run.
|
||||
|
||||
To run only the four HTTP endpoint tests with database fixtures:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test --offline -p storyteller-web --bin storyteller-web login_challenges::endpoint_integration_tests
|
||||
```
|
||||
|
||||
From the desktop repository:
|
||||
|
||||
```sh
|
||||
SQLX_OFFLINE=true cargo test --offline -p artcraft --lib login_bridge_tests
|
||||
cd frontend
|
||||
npm exec vitest -- run --config libs/components/login-modal/vite.config.ts src/lib/DesktopLoginBridge.spec.tsx src/lib/login-modal.spec.tsx
|
||||
```
|
||||
|
||||
Coverage:
|
||||
|
||||
- Four HTTP endpoint integration tests with database fixtures: the complete
|
||||
create/review/approve/redeem/session flow, independent browser/desktop cookies,
|
||||
passwordless onboarding and `use_qt` serialization, concurrent redemption,
|
||||
lost-response recovery with a fresh client, no sessions before redemption,
|
||||
audit ownership/IPs/timestamps, decline, pending/approved expiry, downstream
|
||||
session revocation/expiry/bans, and immutable approval ownership across accounts.
|
||||
- Seven additional backend handler integration tests: both production browser origins, passwordless account approval, zero sessions
|
||||
before redemption, rejection/audit IPs, token separation, CSRF, MCP rejection,
|
||||
invalid approving sessions, concurrent decisions/redemptions, response-loss
|
||||
retries, abandoned expiry, expiry during lock contention, revocation, bans,
|
||||
signed-cookie authentication through the production session guard, and an issued
|
||||
session remaining valid after the challenge expires.
|
||||
- Twenty-five browser tests: API/consent interaction, code/IP display, decline,
|
||||
expiry, terminal/unknown states, lost-response retries, safe return URLs,
|
||||
website inline Google/password sign-in, and webapp login/signup/password-reset
|
||||
continuation without subscription gating or bridge credentials in SSO requests.
|
||||
- Thirteen desktop UI tests: IPC-only transport (JavaScript HTTP is forbidden),
|
||||
native browser invocation, QR/code values, polling, verified user results,
|
||||
decline, timeout, network backoff, cancellation, host/status diagnostics,
|
||||
login-only button placement, native password forms, and stale session checks.
|
||||
- Thirteen native tests: real API bindings against ephemeral loopback HTTP servers,
|
||||
downstream verification before cookie installation, persistence, retries,
|
||||
rejection, timeout, cancellation during HTTP, unknown states, no redirects,
|
||||
redacted errors, local/production origin isolation, visitor-only telemetry,
|
||||
forward-compatible feature flags, malformed session data, and native password
|
||||
login/signup/session recheck. Desktop tests do not connect to MySQL.
|
||||
|
||||
Frontend tests replace external Google/native boundaries; they do not
|
||||
perform a live Google OAuth exchange or drive a packaged Tauri GUI. Backend tests
|
||||
use real MySQL and the production cookie signer/session guard with Redis disabled;
|
||||
the HTTP suite also exercises the real session endpoint and its serialized body.
|
||||
These endpoint tests do not drive a live Google OAuth exchange or the Tauri GUI.
|
||||
Existing session-cache TTL behavior is unchanged. SQL lookups now reject expired
|
||||
sessions; bridge consent/redemption always checks MySQL directly.
|
||||
|
||||
Validation also covered both migration directions, nullable compatibility with
|
||||
legacy session writers, uniqueness constraints, fail-closed expiry defaults, and
|
||||
matching materialized schemas. The focused web TypeScript check and the production
|
||||
builds of both browser apps (including 53 dependency tasks) passed. Broad workspace TypeScript
|
||||
checks have existing errors. The desktop Nx build is blocked by the existing
|
||||
`api -> tauri-api -> api` cycle; its direct Vite login bundle builds but declaration
|
||||
checks report unavailable dependency outputs. A source-only desktop check reports
|
||||
four existing `model-list` imports of `src/index.js`, with no bridge-file errors.
|
||||
The full native Rust test target compiled successfully.
|
||||
@@ -0,0 +1,73 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct CreateLoginChallengeRequest {}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct ReviewLoginChallengeRequest {
|
||||
pub approval_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct DecideLoginChallengeRequest {
|
||||
pub approval_token: String,
|
||||
pub approve: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct PollLoginChallengeRequest {
|
||||
pub device_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct CreateLoginChallengeResponse {
|
||||
pub success: bool,
|
||||
pub device_token: String,
|
||||
pub verification_url: String,
|
||||
pub confirmation_code: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub poll_interval_seconds: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct ReviewLoginChallengeResponse {
|
||||
pub success: bool,
|
||||
pub status: LoginChallengeState,
|
||||
pub maybe_failure_type: Option<LoginChallengeFailure>,
|
||||
pub confirmation_code: String,
|
||||
pub requesting_ip: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, ToSchema)]
|
||||
pub struct LoginChallengeResponse {
|
||||
pub success: bool,
|
||||
pub status: LoginChallengeState,
|
||||
pub maybe_failure_type: Option<LoginChallengeFailure>,
|
||||
/// Present only to the device after the redemption transaction commits.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub maybe_signed_session: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoginChallengeState {
|
||||
Pending,
|
||||
Approved,
|
||||
Redeemed,
|
||||
Failed,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Deserialize, Serialize, ToSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LoginChallengeFailure {
|
||||
UserDeclined,
|
||||
Expired,
|
||||
#[serde(other)]
|
||||
Unknown,
|
||||
}
|
||||
@@ -2,4 +2,5 @@ pub mod change_password;
|
||||
pub mod edit_email;
|
||||
pub mod edit_username;
|
||||
pub mod login;
|
||||
pub mod session_info;
|
||||
pub mod login_challenges;
|
||||
pub mod session_info;
|
||||
@@ -56,24 +56,72 @@ schema metadata. For **new tables**, you may need to:
|
||||
|
||||
- Primary key `id` columns must be `BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT`. Do not use signed IDs.
|
||||
- Always create tables with `ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin`.
|
||||
- Do not give application-owned state/status/type columns schema defaults. Keep required
|
||||
columns `NOT NULL` without a `DEFAULT`; application code must explicitly bind their
|
||||
initial value on every insert. Keep migrations and materialized schema docs in sync.
|
||||
|
||||
### Database enum values
|
||||
|
||||
- Use the Rust enums in `enums::by_table` for database state, status, failure, and type
|
||||
values. Do not hardcode their string representations in SQL or Rust business logic.
|
||||
- Bind enum values (or their `to_str()` representation) through SQL parameters, including
|
||||
`SET`, `WHERE`, `IN`, and `INSERT` values. For example, bind
|
||||
`UserLoginChallengeStatus::Approved.to_str()` to `status = ?`.
|
||||
- Query args and returned rows must use the enum type rather than `String`/`&str` for
|
||||
enum columns. Use SQLx column type overrides and the enum's MySQL decoder on reads;
|
||||
use exhaustive variant matches in application logic. Preserve existing stored values.
|
||||
|
||||
### Error Types
|
||||
|
||||
New queries should return `Result<T, sqlx::Error>` instead of the legacy `AnyhowResult<T>`.
|
||||
The `AnyhowResult` pattern is being phased out.
|
||||
|
||||
### Transaction Support
|
||||
### Named arguments and generic transactors
|
||||
|
||||
Use the executor-generic `Args` pattern for queries that need to work inside transactions:
|
||||
Every new or refactored query must take a single named `<QueryName>Args` struct containing
|
||||
all inputs, including `mysql_executor`. Do not use positional parameter lists, even for
|
||||
small queries. Keep the args struct in the same file as its query.
|
||||
|
||||
Use a generic transactor `T` bounded by `Executor<'c, Database = MySql>`. Do not constrain
|
||||
query functions to `MySqlConnection`, `MySqlPool`, a concrete transaction, or the legacy
|
||||
`Transactor` enum. This applies to all queries, not only ones currently used in transactions.
|
||||
Put the executor bound on the function; the args struct need not carry executor lifetimes
|
||||
or `PhantomData` when it simply stores `T`.
|
||||
|
||||
```rust
|
||||
pub struct MyQueryArgs<'a, 'c: 'a, E>
|
||||
where E: 'a + Executor<'c, Database = MySql>
|
||||
{
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct MyQueryArgs<'a, T> {
|
||||
pub field: &'a str,
|
||||
pub mysql_executor: E,
|
||||
pub phantom: PhantomData<&'c E>,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
pub async fn my_query<'c, T>(args: MyQueryArgs<'_, T>) -> Result<(), sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query!("UPDATE my_table SET name = ?", args.field)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
This allows the same query function to accept both `&MySqlPool` and `&mut Transaction`.
|
||||
Call with `mysql_executor: &pool` for a pool, or `mysql_executor: &mut *tx` /
|
||||
`mysql_executor: &mut *conn` to reborrow the connection held by an owned SQLx transaction /
|
||||
pool connection. SQLx transactions do not themselves implement `Executor`; dereference
|
||||
them at the call site. Reuse the caller's transaction when atomicity or row locks matter.
|
||||
Do not acquire a new connection or begin/commit a transaction inside a query helper.
|
||||
|
||||
### One query per file, grouped by table
|
||||
|
||||
- Put each query in its own descriptively named `.rs` file under the table's directory
|
||||
module, e.g. `queries/user_login_challenges/decide_challenge.rs` or the existing
|
||||
`queries/users/user_sessions/find_redeemed_session.rs`.
|
||||
- Each query file contains one SQL statement/query function, its args struct, and any
|
||||
result type used only by that query. Put shared result types in a separate type file.
|
||||
- Keep `mod.rs` for module declarations; do not collect SQL in it or in catch-all files
|
||||
such as `challenge_queries.rs` or `session_queries.rs`.
|
||||
- Split multi-query operations into individual query files. Keep transaction sequencing
|
||||
and business decisions in the caller/service layer, with every statement using the
|
||||
same transaction when required.
|
||||
@@ -41,6 +41,7 @@ pub mod unsubscribe_reason;
|
||||
pub mod uploaded_video_notes;
|
||||
pub mod uploaded_videos;
|
||||
pub mod user_impersonation_requests;
|
||||
pub mod user_login_challenges;
|
||||
pub mod user_referral_codes;
|
||||
pub mod user_referrals;
|
||||
pub mod user_daily_spends;
|
||||
@@ -52,4 +53,4 @@ pub mod voice_conversion;
|
||||
pub mod voice_designer;
|
||||
pub mod wallet_ledger_entries;
|
||||
pub mod wallets;
|
||||
pub mod web_referrals;
|
||||
pub mod web_referrals;
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
use tokens::tokens::user_login_challenges::UserLoginChallengeToken;
|
||||
|
||||
pub struct CreateChallengeArgs<'a, T> {
|
||||
pub token: &'a UserLoginChallengeToken,
|
||||
pub approval_hash: &'a [u8],
|
||||
pub device_hash: &'a [u8],
|
||||
pub confirmation_code: &'a str,
|
||||
pub ip_address: &'a str,
|
||||
pub status: UserLoginChallengeStatus,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
pub async fn create_challenge<'c, T>(args: CreateChallengeArgs<'_, T>) -> Result<(), sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO user_login_challenges
|
||||
(token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, status, created_at, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, NOW(), NOW() + INTERVAL 20 MINUTE)
|
||||
"#,
|
||||
args.token.as_str(),
|
||||
args.approval_hash,
|
||||
args.device_hash,
|
||||
args.confirmation_code,
|
||||
args.ip_address,
|
||||
args.status.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct DecideChallengeArgs<'a, T> {
|
||||
pub challenge_id: u64,
|
||||
pub user_token: &'a str,
|
||||
pub ip_address: &'a str,
|
||||
pub approve: bool,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
pub async fn decide_challenge<'c, T>(args: DecideChallengeArgs<'_, T>) -> Result<bool, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let status = if args.approve { UserLoginChallengeStatus::Approved } else { UserLoginChallengeStatus::Failed };
|
||||
let failure = if args.approve { None } else { Some(UserLoginChallengeFailureType::UserDeclined) };
|
||||
let failure_ip = if args.approve { None } else { Some(args.ip_address) };
|
||||
let affected = sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_deciding_user_token = ?,
|
||||
maybe_ip_address_decision = ?, maybe_decided_at = NOW(), maybe_failure_type = ?,
|
||||
maybe_ip_address_failure = ?, maybe_failed_at = IF(?, NULL, NOW())
|
||||
WHERE id = ? AND status = ? AND expires_at > NOW()
|
||||
"#,
|
||||
status.to_str(),
|
||||
args.user_token,
|
||||
args.ip_address,
|
||||
failure.map(|value| value.to_str()),
|
||||
failure_ip,
|
||||
args.approve,
|
||||
args.challenge_id,
|
||||
UserLoginChallengeStatus::Pending.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(affected == 1)
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct ExpireAbandonedChallengesArgs<T> {
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Bounded sweep also audits abandoned attempts that are never polled again.
|
||||
pub async fn expire_abandoned_challenges<'c, T>(args: ExpireAbandonedChallengesArgs<T>) -> Result<u64, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at
|
||||
WHERE status IN (?, ?) AND expires_at <= NOW() ORDER BY expires_at LIMIT 1000
|
||||
"#,
|
||||
UserLoginChallengeStatus::Failed.to_str(),
|
||||
UserLoginChallengeFailureType::Expired.to_str(),
|
||||
UserLoginChallengeStatus::Pending.to_str(),
|
||||
UserLoginChallengeStatus::Approved.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct ExpireChallengeArgs<T> {
|
||||
pub challenge_id: u64,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Audit expiration of a pending/approved challenge in the caller's transaction.
|
||||
pub async fn expire_challenge<'c, T>(args: ExpireChallengeArgs<T>) -> Result<(), sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_failure_type = ?, maybe_failed_at = expires_at
|
||||
WHERE id = ? AND status IN (?, ?) AND expires_at <= NOW()
|
||||
"#,
|
||||
UserLoginChallengeStatus::Failed.to_str(),
|
||||
UserLoginChallengeFailureType::Expired.to_str(),
|
||||
args.challenge_id,
|
||||
UserLoginChallengeStatus::Pending.to_str(),
|
||||
UserLoginChallengeStatus::Approved.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct IsChallengeExpiredArgs<T> {
|
||||
pub challenge_id: u64,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Run AFTER acquiring the row lock: NOW() on the locking statement can precede
|
||||
/// time spent waiting for another transaction. Never extend the original deadline.
|
||||
pub async fn is_challenge_expired<'c, T>(args: IsChallengeExpiredArgs<T>) -> Result<bool, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT expires_at <= NOW() AS expired FROM user_login_challenges WHERE id = ?
|
||||
"#,
|
||||
args.challenge_id
|
||||
)
|
||||
.fetch_one(args.mysql_executor)
|
||||
.await?;
|
||||
Ok(result.expired != 0)
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use super::login_challenge::LoginChallenge;
|
||||
|
||||
pub struct LockByApprovalArgs<'a, T> {
|
||||
pub approval_hash: &'a [u8],
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Use the caller's transaction to retain the row lock for subsequent decisions.
|
||||
/// The approval token cannot be used in device lookups.
|
||||
pub async fn lock_by_approval<'c, T>(args: LockByApprovalArgs<'_, T>) -> Result<Option<LoginChallenge>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
LoginChallenge,
|
||||
r#"
|
||||
SELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,
|
||||
maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at
|
||||
FROM user_login_challenges WHERE approval_token_sha256 = ? FOR UPDATE
|
||||
"#,
|
||||
args.approval_hash
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use super::login_challenge::LoginChallenge;
|
||||
|
||||
pub struct LockByDeviceArgs<'a, T> {
|
||||
pub device_hash: &'a [u8],
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Use the caller's transaction to retain the row lock throughout redemption.
|
||||
pub async fn lock_by_device<'c, T>(args: LockByDeviceArgs<'_, T>) -> Result<Option<LoginChallenge>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
LoginChallenge,
|
||||
r#"
|
||||
SELECT id, confirmation_code, status AS `status: UserLoginChallengeStatus`, maybe_deciding_user_token, maybe_redeemed_user_session_id,
|
||||
maybe_failure_type AS `maybe_failure_type: UserLoginChallengeFailureType`, ip_address_creation, expires_at
|
||||
FROM user_login_challenges WHERE device_token_sha256 = ? FOR UPDATE
|
||||
"#,
|
||||
args.device_hash
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
|
||||
pub struct LoginChallenge {
|
||||
pub id: u64,
|
||||
pub confirmation_code: String,
|
||||
pub status: UserLoginChallengeStatus,
|
||||
pub maybe_deciding_user_token: Option<String>,
|
||||
pub maybe_redeemed_user_session_id: Option<i64>,
|
||||
pub maybe_failure_type: Option<UserLoginChallengeFailureType>,
|
||||
pub ip_address_creation: String,
|
||||
pub expires_at: DateTime<Utc>,
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
pub struct MarkRedeemedArgs<'a, T> {
|
||||
pub challenge_id: u64,
|
||||
pub session_id: i64,
|
||||
pub ip_address: &'a str,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
pub async fn mark_redeemed<'c, T>(args: MarkRedeemedArgs<'_, T>) -> Result<bool, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let affected = sqlx::query!(
|
||||
r#"
|
||||
UPDATE user_login_challenges SET status = ?, maybe_redeemed_user_session_id = ?,
|
||||
maybe_ip_address_redemption = ?, maybe_redeemed_at = NOW()
|
||||
WHERE id = ? AND status = ? AND expires_at > NOW() AND maybe_redeemed_user_session_id IS NULL
|
||||
"#,
|
||||
UserLoginChallengeStatus::Redeemed.to_str(),
|
||||
args.session_id,
|
||||
args.ip_address,
|
||||
args.challenge_id,
|
||||
UserLoginChallengeStatus::Approved.to_str()
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?
|
||||
.rows_affected();
|
||||
Ok(affected == 1)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
pub mod create_challenge;
|
||||
pub mod decide_challenge;
|
||||
pub mod expire_abandoned_challenges;
|
||||
pub mod expire_challenge;
|
||||
pub mod is_challenge_expired;
|
||||
pub mod lock_by_approval;
|
||||
pub mod lock_by_device;
|
||||
pub mod login_challenge;
|
||||
pub mod mark_redeemed;
|
||||
@@ -0,0 +1,6 @@
|
||||
pub struct BridgeSession {
|
||||
pub id: i64,
|
||||
pub token: String,
|
||||
pub user_token: String,
|
||||
pub username: String,
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use sqlx::{Executor, MySql};
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
|
||||
pub struct CreateBridgeSessionArgs<'a, T> {
|
||||
pub user_token: &'a str,
|
||||
pub ip_address: &'a str,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Returns the inserted session ID, or None if the user cannot sign in.
|
||||
/// Must share the transaction holding the challenge lock. A failed/expired
|
||||
/// redemption rolls back this INSERT; no session is minted when merely approving.
|
||||
pub async fn create_bridge_session<'c, T>(args: CreateBridgeSessionArgs<'_, T>) -> Result<Option<i64>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let token = UserSessionToken::generate();
|
||||
let inserted = sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO user_sessions (token, user_token, ip_address_creation, maybe_creation_type, expires_at)
|
||||
SELECT ?, token, ?, ?, NOW() + INTERVAL 1 YEAR FROM users
|
||||
WHERE token = ? AND is_banned = FALSE AND user_deleted_at IS NULL AND mod_deleted_at IS NULL
|
||||
"#,
|
||||
token.as_str(),
|
||||
args.ip_address,
|
||||
UserWebSessionCreationType::DeviceApproval.to_str(),
|
||||
args.user_token
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
.await?;
|
||||
if inserted.rows_affected() != 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(inserted.last_insert_id() as i64))
|
||||
}
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
@@ -36,14 +37,16 @@ INSERT INTO user_sessions (
|
||||
user_token,
|
||||
maybe_impersonation_user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
"#,
|
||||
session_token.as_str(),
|
||||
args.user_token.as_str(),
|
||||
args.impersonator_user_token.as_str(),
|
||||
args.ip_address,
|
||||
UserWebSessionCreationType::Impersonation.to_str(),
|
||||
args.expires_at,
|
||||
)
|
||||
.execute(args.mysql_executor)
|
||||
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
use anyhow::anyhow;
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use log::info;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
@@ -17,13 +18,15 @@ INSERT INTO user_sessions (
|
||||
token,
|
||||
user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES ( ?, ?, ?, NOW() + interval 1 year )
|
||||
VALUES ( ?, ?, ?, ?, NOW() + interval 1 year )
|
||||
"#,
|
||||
session_token,
|
||||
user_token.to_string(),
|
||||
ip_address.to_string(),
|
||||
UserWebSessionCreationType::DirectLogin.to_str(),
|
||||
)
|
||||
.execute(mysql_pool)
|
||||
.await;
|
||||
|
||||
+4
-1
@@ -1,3 +1,4 @@
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use log::info;
|
||||
use sqlx::{Executor, MySql};
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
@@ -19,13 +20,15 @@ INSERT INTO user_sessions (
|
||||
token,
|
||||
user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES ( ?, ?, ?, NOW() + interval 1 year )
|
||||
VALUES ( ?, ?, ?, ?, NOW() + interval 1 year )
|
||||
"#,
|
||||
session_token.as_str(),
|
||||
user_token.as_str(),
|
||||
ip_address,
|
||||
UserWebSessionCreationType::DirectLogin.to_str(),
|
||||
)
|
||||
.execute(mysql_executor)
|
||||
.await?;
|
||||
|
||||
+4
-1
@@ -1,4 +1,5 @@
|
||||
use anyhow::anyhow;
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use log::info;
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
@@ -22,13 +23,15 @@ INSERT INTO user_sessions (
|
||||
token,
|
||||
user_token,
|
||||
ip_address_creation,
|
||||
maybe_creation_type,
|
||||
expires_at
|
||||
)
|
||||
VALUES ( ?, ?, ?, NOW() + interval 1 year )
|
||||
VALUES ( ?, ?, ?, ?, NOW() + interval 1 year )
|
||||
"#,
|
||||
session_token.as_str(),
|
||||
user_token.as_str(),
|
||||
ip_address,
|
||||
UserWebSessionCreationType::DirectLogin.to_str(),
|
||||
);
|
||||
|
||||
let query_result = transactor.execute(query).await;
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use super::bridge_session::BridgeSession;
|
||||
|
||||
pub struct FindApprovingSessionArgs<'a, T> {
|
||||
pub session_token: &'a str,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Fresh database lookup, not a cached session. Impersonated sessions cannot consent.
|
||||
pub async fn find_approving_session<'c, T>(args: FindApprovingSessionArgs<'_, T>) -> Result<Option<BridgeSession>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
BridgeSession,
|
||||
r#"
|
||||
SELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token
|
||||
WHERE s.token = ? AND s.deleted_at IS NULL AND s.expires_at > NOW()
|
||||
AND s.maybe_impersonation_user_token IS NULL AND u.is_banned = FALSE
|
||||
AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL
|
||||
"#,
|
||||
args.session_token
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use sqlx::{Executor, MySql};
|
||||
|
||||
use super::bridge_session::BridgeSession;
|
||||
|
||||
pub struct FindRedeemedSessionArgs<'a, T> {
|
||||
pub session_id: i64,
|
||||
pub user_token: &'a str,
|
||||
pub mysql_executor: T,
|
||||
}
|
||||
|
||||
/// Returns the one previously minted session, only while it and its owner are live.
|
||||
pub async fn find_redeemed_session<'c, T>(args: FindRedeemedSessionArgs<'_, T>) -> Result<Option<BridgeSession>, sqlx::Error>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
sqlx::query_as!(
|
||||
BridgeSession,
|
||||
r#"
|
||||
SELECT s.id, s.token, s.user_token, u.username FROM user_sessions s JOIN users u ON u.token = s.user_token
|
||||
WHERE s.id = ? AND s.user_token = ? AND s.maybe_creation_type = ?
|
||||
AND s.deleted_at IS NULL AND s.expires_at > NOW() AND s.maybe_impersonation_user_token IS NULL
|
||||
AND u.is_banned = FALSE AND u.user_deleted_at IS NULL AND u.mod_deleted_at IS NULL
|
||||
"#,
|
||||
args.session_id,
|
||||
args.user_token,
|
||||
UserWebSessionCreationType::DeviceApproval.to_str()
|
||||
)
|
||||
.fetch_optional(args.mysql_executor)
|
||||
.await
|
||||
}
|
||||
+1
@@ -169,6 +169,7 @@ LEFT OUTER JOIN user_roles
|
||||
ON users.user_role_slug = user_roles.slug
|
||||
WHERE user_sessions.token = ?
|
||||
AND user_sessions.deleted_at IS NULL
|
||||
AND user_sessions.expires_at > NOW()
|
||||
AND users.user_deleted_at IS NULL
|
||||
AND users.mod_deleted_at IS NULL
|
||||
"#,
|
||||
|
||||
+1
@@ -26,6 +26,7 @@ SELECT
|
||||
FROM user_sessions
|
||||
WHERE token = ?
|
||||
AND deleted_at IS NULL
|
||||
AND expires_at > NOW()
|
||||
"#,
|
||||
session_token,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
pub mod bridge_session;
|
||||
pub mod create_bridge_session;
|
||||
pub mod create_impersonated_user_session;
|
||||
pub mod create_user_session;
|
||||
pub mod create_user_session_with_executor;
|
||||
pub mod create_user_session_with_transactor;
|
||||
pub mod delete_user_session;
|
||||
pub mod find_approving_session;
|
||||
pub mod find_redeemed_session;
|
||||
pub mod get_user_session_by_token;
|
||||
pub mod get_user_session_by_token_light;
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Mutators/inspection for disposable bridge integration databases only.
|
||||
use sqlx::{MySqlPool, Row};
|
||||
use chrono::{DateTime, Utc};
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use enums::by_table::user_sessions::user_web_session_creation_type::UserWebSessionCreationType;
|
||||
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
|
||||
use tokens::tokens::user_login_challenges::UserLoginChallengeToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use super::users::{create_test_user, TestUser};
|
||||
|
||||
pub struct ChallengeAudit {
|
||||
pub status: UserLoginChallengeStatus,
|
||||
pub failure: Option<UserLoginChallengeFailureType>,
|
||||
pub creation_ip: String,
|
||||
pub failure_ip: Option<String>,
|
||||
pub session_id: Option<i64>,
|
||||
pub lifetime_seconds: i64,
|
||||
pub deciding_user: Option<String>,
|
||||
pub decision_ip: Option<String>,
|
||||
pub redemption_ip: Option<String>,
|
||||
pub decided_at: Option<DateTime<Utc>>,
|
||||
pub redeemed_at: Option<DateTime<Utc>>,
|
||||
pub failed_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
pub async fn create_passwordless_user(pool: &MySqlPool, flags: &[UserFeatureFlag]) -> TestUser {
|
||||
let user = create_test_user(pool).await.expect("create passwordless fixture");
|
||||
set_passwordless(pool, &user.user_token).await;
|
||||
let flags = flags.iter().map(UserFeatureFlag::to_str).collect::<Vec<_>>().join(",");
|
||||
sqlx::query("UPDATE users SET maybe_feature_flags = ? WHERE token = ?")
|
||||
.bind(flags).bind(user.user_token.as_str()).execute(pool).await.expect("set fixture feature flags");
|
||||
user
|
||||
}
|
||||
|
||||
pub async fn audit(pool: &MySqlPool, device_hash: &[u8]) -> ChallengeAudit {
|
||||
let row = sqlx::query("SELECT status, maybe_failure_type, ip_address_creation, maybe_ip_address_failure, maybe_redeemed_user_session_id, TIMESTAMPDIFF(SECOND, created_at, expires_at) AS lifetime, maybe_deciding_user_token, maybe_ip_address_decision, maybe_ip_address_redemption, maybe_decided_at, maybe_redeemed_at, maybe_failed_at FROM user_login_challenges WHERE device_token_sha256 = ?").bind(device_hash).fetch_one(pool).await.expect("challenge audit");
|
||||
ChallengeAudit {
|
||||
status: row.get("status"), failure: row.get("maybe_failure_type"), creation_ip: row.get("ip_address_creation"),
|
||||
failure_ip: row.get("maybe_ip_address_failure"), session_id: row.get("maybe_redeemed_user_session_id"), lifetime_seconds: row.get("lifetime"),
|
||||
deciding_user: row.get("maybe_deciding_user_token"), decision_ip: row.get("maybe_ip_address_decision"),
|
||||
redemption_ip: row.get("maybe_ip_address_redemption"), decided_at: row.get("maybe_decided_at"),
|
||||
redeemed_at: row.get("maybe_redeemed_at"), failed_at: row.get("maybe_failed_at"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn bridge_session_count(pool: &MySqlPool) -> i64 {
|
||||
sqlx::query_scalar("SELECT COUNT(*) FROM user_sessions WHERE maybe_creation_type = ?")
|
||||
.bind(UserWebSessionCreationType::DeviceApproval.to_str()).fetch_one(pool).await.expect("bridge session count")
|
||||
}
|
||||
|
||||
pub async fn assert_challenge_status_is_explicit(pool: &MySqlPool) {
|
||||
let column = sqlx::query("SELECT COLUMN_DEFAULT, IS_NULLABLE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'user_login_challenges' AND COLUMN_NAME = 'status'")
|
||||
.fetch_one(pool).await.expect("inspect migrated status column");
|
||||
assert!(column.get::<Option<String>, _>("COLUMN_DEFAULT").is_none());
|
||||
assert_eq!(column.get::<String, _>("IS_NULLABLE"), "NO");
|
||||
|
||||
// Every other required field is supplied; MySQL must reject the omitted status.
|
||||
let token = UserLoginChallengeToken::generate();
|
||||
let error = sqlx::query("INSERT INTO user_login_challenges (token, approval_token_sha256, device_token_sha256, confirmation_code, ip_address_creation, expires_at) VALUES (?, ?, ?, ?, ?, NOW() + INTERVAL 20 MINUTE)")
|
||||
.bind(token.as_str()).bind(&[0u8; 32][..]).bind(&[1u8; 32][..])
|
||||
.bind("BCDFGHJK").bind("192.0.2.1").execute(pool).await.expect_err("status must be explicit");
|
||||
let error = error.as_database_error().expect("MySQL missing-column error");
|
||||
assert_eq!(error.try_downcast_ref::<sqlx::mysql::MySqlDatabaseError>().unwrap().number(), 1364);
|
||||
}
|
||||
|
||||
pub async fn set_challenge_deadline(pool: &MySqlPool, device_hash: &[u8], seconds_from_now: i32) {
|
||||
sqlx::query("UPDATE user_login_challenges SET expires_at = TIMESTAMPADD(SECOND, ?, NOW()) WHERE device_token_sha256 = ?").bind(seconds_from_now).bind(device_hash).execute(pool).await.expect("change test deadline");
|
||||
}
|
||||
|
||||
pub async fn expire_session(pool: &MySqlPool, token: &str) {
|
||||
sqlx::query("UPDATE user_sessions SET expires_at = NOW() - INTERVAL 1 SECOND WHERE token = ?").bind(token).execute(pool).await.expect("expire test session");
|
||||
}
|
||||
|
||||
pub async fn revoke_session(pool: &MySqlPool, token: &str) {
|
||||
sqlx::query("UPDATE user_sessions SET deleted_at = NOW() WHERE token = ?").bind(token).execute(pool).await.expect("revoke test session");
|
||||
}
|
||||
|
||||
pub async fn set_passwordless(pool: &MySqlPool, user: &UserToken) {
|
||||
sqlx::query("UPDATE users SET is_without_password = TRUE, email_confirmed_by_google = TRUE WHERE token = ?").bind(user.as_str()).execute(pool).await.expect("passwordless test account");
|
||||
}
|
||||
|
||||
pub async fn ban_user(pool: &MySqlPool, user: &UserToken) {
|
||||
sqlx::query("UPDATE users SET is_banned = TRUE WHERE token = ?").bind(user.as_str()).execute(pool).await.expect("ban test account");
|
||||
}
|
||||
|
||||
pub async fn impersonate_session(pool: &MySqlPool, token: &str, actor: &UserToken) {
|
||||
sqlx::query("UPDATE user_sessions SET maybe_impersonation_user_token = ? WHERE token = ?").bind(actor.as_str()).bind(token).execute(pool).await.expect("test impersonation");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod mcp_sessions;
|
||||
pub mod login_challenges;
|
||||
pub mod media_files;
|
||||
pub mod users;
|
||||
pub mod wallets;
|
||||
@@ -0,0 +1,36 @@
|
||||
//! Disposable databases on the local MySQL instance only. No application or test
|
||||
//! database URL is read, so production configuration cannot redirect these tests.
|
||||
|
||||
use sqlx::mysql::{MySqlConnectOptions, MySqlPoolOptions, MySqlSslMode};
|
||||
use sqlx::MySqlPool;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::schema::ensure_schema;
|
||||
|
||||
pub struct IsolatedTestDatabase {
|
||||
pub pool: MySqlPool,
|
||||
admin_pool: MySqlPool,
|
||||
name: String,
|
||||
}
|
||||
|
||||
impl IsolatedTestDatabase {
|
||||
pub async fn create() -> Self {
|
||||
// Deliberately no URL/env parsing, DNS, TCP (including forwarded local ports),
|
||||
// or socket overrides. This is the local Homebrew MySQL socket. Only a new
|
||||
// randomly named database can be created/dropped; existing schemas are unused.
|
||||
let options = MySqlConnectOptions::new().socket("/tmp/mysql.sock").username("root").ssl_mode(MySqlSslMode::Disabled);
|
||||
let admin_pool = MySqlPoolOptions::new().max_connections(1).connect_with(options.clone()).await.expect("local MySQL test connection");
|
||||
let name = format!("artcraft_test_bridge_{}", UserToken::generate().as_str());
|
||||
assert!(name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
|
||||
sqlx::query(&format!("CREATE DATABASE `{name}`")).execute(&admin_pool).await.expect("create disposable database");
|
||||
let pool = MySqlPoolOptions::new().max_connections(8).connect_with(options.database(&name)).await.expect("connect disposable database");
|
||||
ensure_schema(&pool).await;
|
||||
Self { pool, admin_pool, name }
|
||||
}
|
||||
|
||||
pub async fn destroy(self) {
|
||||
self.pool.close().await;
|
||||
sqlx::query(&format!("DROP DATABASE `{}`", self.name)).execute(&self.admin_pool).await.expect("drop our disposable database");
|
||||
self.admin_pool.close().await;
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
pub mod fixtures;
|
||||
pub mod guard;
|
||||
pub mod isolated;
|
||||
pub mod pool;
|
||||
pub mod schema;
|
||||
pub mod serial;
|
||||
@@ -24,7 +24,9 @@ pub mod uploaded_video_notes;
|
||||
pub mod uploaded_videos;
|
||||
pub mod usages;
|
||||
pub mod user_bookmarks;
|
||||
pub mod user_login_challenges;
|
||||
pub mod user_ratings;
|
||||
pub mod user_sessions;
|
||||
pub mod user_spend_events;
|
||||
pub mod users;
|
||||
pub mod voice_conversion_models;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod user_login_challenge_failure_type;
|
||||
pub mod user_login_challenge_status;
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
use strum::{EnumCount, EnumIter};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Stored in `user_login_challenges.maybe_failure_type` (VARCHAR(32)).
|
||||
///
|
||||
/// Terminal outcome for a failed device approval request. Transient transport/database errors and invalid credentials are not challenge outcomes.
|
||||
///
|
||||
/// Add new values as needed, but never change stored values without a migration.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
pub enum UserLoginChallengeFailureType {
|
||||
/// An authenticated user explicitly declined the request.
|
||||
#[serde(rename = "user_declined")]
|
||||
UserDeclined,
|
||||
|
||||
/// The 20-minute deadline passed before the challenge was redeemed.
|
||||
#[serde(rename = "expired")]
|
||||
Expired,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UserLoginChallengeFailureType);
|
||||
impl_mysql_enum_coders!(UserLoginChallengeFailureType);
|
||||
impl_mysql_from_row!(UserLoginChallengeFailureType);
|
||||
|
||||
impl UserLoginChallengeFailureType {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::UserDeclined => "user_declined",
|
||||
Self::Expired => "expired",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"user_declined" => Ok(Self::UserDeclined),
|
||||
"expired" => Ok(Self::Expired),
|
||||
_ => Err(format!("invalid UserLoginChallengeFailureType value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([Self::UserDeclined, Self::Expired])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UserLoginChallengeFailureType;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
use strum::{EnumCount, IntoEnumIterator};
|
||||
|
||||
const STORED_VALUES: &[(UserLoginChallengeFailureType, &str)] = &[(UserLoginChallengeFailureType::UserDeclined, "user_declined"), (UserLoginChallengeFailureType::Expired, "expired")];
|
||||
const MAX_STORED_LENGTH: usize = 32;
|
||||
|
||||
#[test]
|
||||
fn stored_values_and_serialization_are_stable() {
|
||||
for &(variant, stored) in STORED_VALUES {
|
||||
assert_serialization(variant, stored);
|
||||
assert_eq!(variant.to_str(), stored);
|
||||
assert_eq!(UserLoginChallengeFailureType::from_str(stored).unwrap(), variant);
|
||||
assert_eq!(format!("{}", variant), stored);
|
||||
assert_eq!(format!("{:?}", variant), stored);
|
||||
assert!(!stored.is_empty());
|
||||
assert!(stored.len() <= MAX_STORED_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants_are_covered_and_round_trip() {
|
||||
let variants = UserLoginChallengeFailureType::all_variants();
|
||||
assert_eq!(variants.len(), UserLoginChallengeFailureType::COUNT);
|
||||
assert_eq!(variants.len(), STORED_VALUES.len());
|
||||
for variant in UserLoginChallengeFailureType::iter() {
|
||||
assert!(variants.contains(&variant));
|
||||
assert_eq!(UserLoginChallengeFailureType::from_str(variant.to_str()).unwrap(), variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stored_values_are_rejected() {
|
||||
for value in ["", "unknown", "USER_DECLINED"] {
|
||||
assert!(UserLoginChallengeFailureType::from_str(value).is_err());
|
||||
assert!(serde_json::from_str::<UserLoginChallengeFailureType>(&format!("{:?}", value)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
use strum::{EnumCount, EnumIter};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Stored in `user_login_challenges.status` (VARCHAR(16)).
|
||||
///
|
||||
/// Persisted state of a device approval request. Always check expires_at independently; a pending or approved row can already be expired.
|
||||
///
|
||||
/// Add new values as needed, but never change stored values without a migration.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
pub enum UserLoginChallengeStatus {
|
||||
/// Waiting for an authenticated user's explicit decision.
|
||||
#[serde(rename = "pending")]
|
||||
Pending,
|
||||
|
||||
/// Approved for the deciding user; no session has been minted yet.
|
||||
#[serde(rename = "approved")]
|
||||
Approved,
|
||||
|
||||
/// Exactly one session has been minted. Retries must never mint another.
|
||||
#[serde(rename = "redeemed")]
|
||||
Redeemed,
|
||||
|
||||
/// Terminal failure, described by maybe_failure_type.
|
||||
#[serde(rename = "failed")]
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UserLoginChallengeStatus);
|
||||
impl_mysql_enum_coders!(UserLoginChallengeStatus);
|
||||
impl_mysql_from_row!(UserLoginChallengeStatus);
|
||||
|
||||
impl UserLoginChallengeStatus {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Pending => "pending",
|
||||
Self::Approved => "approved",
|
||||
Self::Redeemed => "redeemed",
|
||||
Self::Failed => "failed",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"pending" => Ok(Self::Pending),
|
||||
"approved" => Ok(Self::Approved),
|
||||
"redeemed" => Ok(Self::Redeemed),
|
||||
"failed" => Ok(Self::Failed),
|
||||
_ => Err(format!("invalid UserLoginChallengeStatus value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([Self::Pending, Self::Approved, Self::Redeemed, Self::Failed])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UserLoginChallengeStatus;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
use strum::{EnumCount, IntoEnumIterator};
|
||||
|
||||
const STORED_VALUES: &[(UserLoginChallengeStatus, &str)] = &[(UserLoginChallengeStatus::Pending, "pending"), (UserLoginChallengeStatus::Approved, "approved"), (UserLoginChallengeStatus::Redeemed, "redeemed"), (UserLoginChallengeStatus::Failed, "failed")];
|
||||
const MAX_STORED_LENGTH: usize = 16;
|
||||
|
||||
#[test]
|
||||
fn stored_values_and_serialization_are_stable() {
|
||||
for &(variant, stored) in STORED_VALUES {
|
||||
assert_serialization(variant, stored);
|
||||
assert_eq!(variant.to_str(), stored);
|
||||
assert_eq!(UserLoginChallengeStatus::from_str(stored).unwrap(), variant);
|
||||
assert_eq!(format!("{}", variant), stored);
|
||||
assert_eq!(format!("{:?}", variant), stored);
|
||||
assert!(!stored.is_empty());
|
||||
assert!(stored.len() <= MAX_STORED_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants_are_covered_and_round_trip() {
|
||||
let variants = UserLoginChallengeStatus::all_variants();
|
||||
assert_eq!(variants.len(), UserLoginChallengeStatus::COUNT);
|
||||
assert_eq!(variants.len(), STORED_VALUES.len());
|
||||
for variant in UserLoginChallengeStatus::iter() {
|
||||
assert!(variants.contains(&variant));
|
||||
assert_eq!(UserLoginChallengeStatus::from_str(variant.to_str()).unwrap(), variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stored_values_are_rejected() {
|
||||
for value in ["", "unknown", "PENDING"] {
|
||||
assert!(UserLoginChallengeStatus::from_str(value).is_err());
|
||||
assert!(serde_json::from_str::<UserLoginChallengeStatus>(&format!("{:?}", value)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub mod user_web_session_creation_type;
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
use strum::{EnumCount, EnumIter};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
/// Stored in `user_sessions.maybe_creation_type` (VARCHAR(32)).
|
||||
///
|
||||
/// How a cookie-based user session was created; NULL in MySQL means unknown. This is provenance, not an authentication policy or an MCP/API session type.
|
||||
///
|
||||
/// Add new values as needed, but never change stored values without a migration.
|
||||
#[cfg_attr(test, derive(EnumIter, EnumCount))]
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize, Serialize, ToSchema)]
|
||||
pub enum UserWebSessionCreationType {
|
||||
/// A normal password or SSO login/signup, including direct desktop password login.
|
||||
#[serde(rename = "direct_login")]
|
||||
DirectLogin,
|
||||
|
||||
/// A new session authorized through a user_login_challenges device approval.
|
||||
#[serde(rename = "device_approval")]
|
||||
DeviceApproval,
|
||||
|
||||
/// An explicitly consented staff impersonation; existing impersonation rules still apply.
|
||||
#[serde(rename = "impersonation")]
|
||||
Impersonation,
|
||||
}
|
||||
|
||||
impl_enum_display_and_debug_using_to_str!(UserWebSessionCreationType);
|
||||
impl_mysql_enum_coders!(UserWebSessionCreationType);
|
||||
impl_mysql_from_row!(UserWebSessionCreationType);
|
||||
|
||||
impl UserWebSessionCreationType {
|
||||
pub fn to_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::DirectLogin => "direct_login",
|
||||
Self::DeviceApproval => "device_approval",
|
||||
Self::Impersonation => "impersonation",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str(value: &str) -> Result<Self, String> {
|
||||
match value {
|
||||
"direct_login" => Ok(Self::DirectLogin),
|
||||
"device_approval" => Ok(Self::DeviceApproval),
|
||||
"impersonation" => Ok(Self::Impersonation),
|
||||
_ => Err(format!("invalid UserWebSessionCreationType value: {:?}", value)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_variants() -> BTreeSet<Self> {
|
||||
BTreeSet::from([Self::DirectLogin, Self::DeviceApproval, Self::Impersonation])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::UserWebSessionCreationType;
|
||||
use crate::test_helpers::assert_serialization;
|
||||
use strum::{EnumCount, IntoEnumIterator};
|
||||
|
||||
const STORED_VALUES: &[(UserWebSessionCreationType, &str)] = &[(UserWebSessionCreationType::DirectLogin, "direct_login"), (UserWebSessionCreationType::DeviceApproval, "device_approval"), (UserWebSessionCreationType::Impersonation, "impersonation")];
|
||||
const MAX_STORED_LENGTH: usize = 32;
|
||||
|
||||
#[test]
|
||||
fn stored_values_and_serialization_are_stable() {
|
||||
for &(variant, stored) in STORED_VALUES {
|
||||
assert_serialization(variant, stored);
|
||||
assert_eq!(variant.to_str(), stored);
|
||||
assert_eq!(UserWebSessionCreationType::from_str(stored).unwrap(), variant);
|
||||
assert_eq!(format!("{}", variant), stored);
|
||||
assert_eq!(format!("{:?}", variant), stored);
|
||||
assert!(!stored.is_empty());
|
||||
assert!(stored.len() <= MAX_STORED_LENGTH);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_variants_are_covered_and_round_trip() {
|
||||
let variants = UserWebSessionCreationType::all_variants();
|
||||
assert_eq!(variants.len(), UserWebSessionCreationType::COUNT);
|
||||
assert_eq!(variants.len(), STORED_VALUES.len());
|
||||
for variant in UserWebSessionCreationType::iter() {
|
||||
assert!(variants.contains(&variant));
|
||||
assert_eq!(UserWebSessionCreationType::from_str(variant.to_str()).unwrap(), variant);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_stored_values_are_rejected() {
|
||||
for value in ["", "unknown", "DIRECT_LOGIN"] {
|
||||
assert!(UserWebSessionCreationType::from_str(value).is_err());
|
||||
assert!(serde_json::from_str::<UserWebSessionCreationType>(&format!("{:?}", value)).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ pub(crate) enum TokenPrefix {
|
||||
UserBookmark,
|
||||
UserReferralCode,
|
||||
UserSession,
|
||||
UserLoginChallenge,
|
||||
UserSpendEvent,
|
||||
UserSubscription,
|
||||
VoiceConversionModel,
|
||||
@@ -145,6 +146,7 @@ impl PrefixGenerator for TokenPrefix {
|
||||
Self::UserBookmark => "ub_",
|
||||
Self::UserReferralCode => "urc_",
|
||||
Self::UserSession => "session_",
|
||||
Self::UserLoginChallenge => "login_",
|
||||
Self::UserSpendEvent => "spend_",
|
||||
Self::UserSubscription => "subscription_", // NB: Previously "SUB:"
|
||||
Self::VoiceConversionModel => "vcm_",
|
||||
|
||||
@@ -40,6 +40,7 @@ pub mod user_bookmarks;
|
||||
pub mod user_impersonation_requests;
|
||||
pub mod user_referral_codes;
|
||||
pub mod user_sessions;
|
||||
pub mod user_login_challenges;
|
||||
pub mod user_spend_events;
|
||||
pub mod user_subscriptions;
|
||||
pub mod users;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::prefixes::TokenPrefix;
|
||||
|
||||
/// Non-secret identifier for a login challenge. This never grants access.
|
||||
#[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 UserLoginChallengeToken(pub String);
|
||||
|
||||
impl_string_token!(UserLoginChallengeToken);
|
||||
impl_mysql_token_from_row!(UserLoginChallengeToken);
|
||||
impl_crockford_generator!(UserLoginChallengeToken, 32usize, TokenPrefix::UserLoginChallenge, CrockfordLower);
|
||||
@@ -114,6 +114,7 @@ lexical-sort = "0.3.1"
|
||||
log.workspace = true
|
||||
once_cell.workspace = true
|
||||
rand.workspace = true
|
||||
sha2.workspace = true
|
||||
regex.workspace = true
|
||||
strum_macros = "0.26.4"
|
||||
time.workspace = true
|
||||
@@ -216,6 +217,7 @@ rustls = { version = "0.23", features = ["aws-lc-rs"] }
|
||||
test_utils = { path = "../../../testing/test_utils" }
|
||||
|
||||
# Testing/Dev Deps: External
|
||||
reqwest.workspace = true
|
||||
speculoos.workspace = true
|
||||
strum.workspace = true
|
||||
tempfile.workspace = true
|
||||
@@ -274,6 +274,7 @@ use crate::http_server::endpoints::users::edit_username_handler::*;
|
||||
use crate::http_server::endpoints::users::get_profile_handler::*;
|
||||
use crate::http_server::endpoints::users::google_sso::google_sso_handler::*;
|
||||
use artcraft_api_defs::users::login::{LoginRequest, LoginSuccessResponse, LoginErrorType};
|
||||
use artcraft_api_defs::users::login_challenges::{CreateLoginChallengeRequest, CreateLoginChallengeResponse, DecideLoginChallengeRequest, LoginChallengeFailure, LoginChallengeResponse, LoginChallengeState, PollLoginChallengeRequest, ReviewLoginChallengeRequest, ReviewLoginChallengeResponse};
|
||||
use crate::http_server::endpoints::users::login_handler::LoginErrorResponse;
|
||||
use crate::http_server::endpoints::users::logout_handler::*;
|
||||
use crate::http_server::endpoints::users::session_info_handler::*;
|
||||
@@ -570,6 +571,10 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
crate::http_server::endpoints::users::get_profile_handler::get_profile_handler,
|
||||
crate::http_server::endpoints::users::google_sso::google_sso_handler::google_sso_handler,
|
||||
crate::http_server::endpoints::users::login_handler::login_handler,
|
||||
crate::http_server::endpoints::login_challenges::handlers::create,
|
||||
crate::http_server::endpoints::login_challenges::handlers::review,
|
||||
crate::http_server::endpoints::login_challenges::handlers::decide,
|
||||
crate::http_server::endpoints::login_challenges::handlers::poll,
|
||||
crate::http_server::endpoints::users::logout_handler::logout_handler,
|
||||
crate::http_server::endpoints::users::session_info_handler::session_info_handler,
|
||||
crate::http_server::endpoints::users::session_token_info_handler::session_token_info_handler,
|
||||
@@ -917,9 +922,12 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
CreateCommentSuccessResponse,
|
||||
CreateFeaturedItemRequest,
|
||||
CreateFeaturedItemSuccessResponse,
|
||||
CreateLoginChallengeRequest,
|
||||
CreateLoginChallengeResponse,
|
||||
CreateUserBookmarkError,
|
||||
CreateUserBookmarkRequest,
|
||||
CreateUserBookmarkSuccessResponse,
|
||||
DecideLoginChallengeRequest,
|
||||
DeleteCommentError,
|
||||
DeleteCommentPathInfo,
|
||||
DeleteCommentRequest,
|
||||
@@ -1200,6 +1208,9 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
LogBrowserSessionError,
|
||||
LogBrowserSessionRequest,
|
||||
LogBrowserSessionSuccessResponse,
|
||||
LoginChallengeFailure,
|
||||
LoginChallengeResponse,
|
||||
LoginChallengeState,
|
||||
LoginErrorResponse,
|
||||
LoginErrorType,
|
||||
LoginRequest,
|
||||
@@ -1226,6 +1237,7 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
NewProjectMultipartForm,
|
||||
PinnedMediaFile,
|
||||
PinnedModelWeightForList,
|
||||
PollLoginChallengeRequest,
|
||||
ProjectMediaFileInfo,
|
||||
SessionMediaFileInfo,
|
||||
PromptInfo,
|
||||
@@ -1237,6 +1249,8 @@ use crate::http_server::endpoints::media_files::list::list_batch_generated_redux
|
||||
RenameMediaFileRequest,
|
||||
RequestDetailsResponse,
|
||||
ResultDetailsResponse,
|
||||
ReviewLoginChallengeRequest,
|
||||
ReviewLoginChallengeResponse,
|
||||
StaffAuditLogResponse,
|
||||
SearchFeaturedMediaFileListItem,
|
||||
SearchFeaturedMediaFilesQueryParams,
|
||||
|
||||
+350
@@ -0,0 +1,350 @@
|
||||
//! End-to-end HTTP endpoint tests: real TCP, production handlers/signers, real MySQL fixtures.
|
||||
//! The API binds only 127.0.0.1:0; MySQL uses the isolated fixed-local-socket harness.
|
||||
//! No mocks of queries, login handlers, session responses, or cookies.
|
||||
use std::net::TcpListener;
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_artcraft::sessions::anonymous_visitor_tracking::avt_cookie_manager::AvtCookieManager;
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
|
||||
use actix_web::dev::ServerHandle;
|
||||
use actix_web::{web, App, HttpServer};
|
||||
use artcraft_api_defs::users::login_challenges::{CreateLoginChallengeResponse, LoginChallengeFailure, LoginChallengeResponse, LoginChallengeState, ReviewLoginChallengeResponse};
|
||||
use enums::by_table::users::user_feature_flag::UserFeatureFlag;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use mysql_testing::fixtures::login_challenges as fixtures;
|
||||
use mysql_testing::fixtures::users::{create_test_user, TestUser};
|
||||
use mysql_testing::isolated::IsolatedTestDatabase;
|
||||
use reqwest::cookie::Jar;
|
||||
use reqwest::header::{HeaderMap, HeaderValue, SET_COOKIE};
|
||||
use reqwest::{Client, Response, StatusCode, Url};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::http_server::endpoints::users::session_info_handler::session_info_handler;
|
||||
use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker;
|
||||
use super::{handlers, security};
|
||||
|
||||
const BROWSER_ORIGIN: &str = "https://getartcraft.com";
|
||||
const TEST_SIGNING_KEY: &str = "local_endpoint_integration_test_key";
|
||||
|
||||
struct EndpointHarness {
|
||||
db: IsolatedTestDatabase,
|
||||
user: TestUser,
|
||||
signer: HttpUserSessionManager,
|
||||
server: ServerHandle,
|
||||
base_url: Url,
|
||||
desktop: Client,
|
||||
browser: Client,
|
||||
desktop_ip: String,
|
||||
browser_ip: String,
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn approved_passwordless_login_redeems_once_and_authenticates_real_session_endpoint() {
|
||||
let h = EndpointHarness::start().await;
|
||||
fixtures::assert_challenge_status_is_explicit(&h.db.pool).await;
|
||||
assert_anonymous(h.session(&h.desktop).await);
|
||||
let browser_session = h.session(&h.browser).await;
|
||||
assert_eq!(browser_session["user"]["user_token"], h.user.user_token.as_str());
|
||||
|
||||
let created = h.create().await;
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
let initial = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(initial.status, UserLoginChallengeStatus::Pending);
|
||||
assert_eq!(initial.lifetime_seconds, 1200);
|
||||
assert_eq!(initial.creation_ip, h.desktop_ip);
|
||||
assert!(initial.deciding_user.is_none());
|
||||
assert!(initial.decided_at.is_none() && initial.redeemed_at.is_none() && initial.failed_at.is_none());
|
||||
assert!(initial.session_id.is_none());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
|
||||
let pending = h.poll(&created).await;
|
||||
assert_private_response(&pending);
|
||||
assert_no_session_cookie(&pending);
|
||||
let pending: LoginChallengeResponse = read_ok(pending).await;
|
||||
assert_eq!(pending.status, LoginChallengeState::Pending);
|
||||
assert!(pending.maybe_signed_session.is_none());
|
||||
assert_eq!(h.review(&h.desktop, &created).await.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let review: ReviewLoginChallengeResponse = read_ok(h.review(&h.browser, &created).await).await;
|
||||
assert_eq!(review.status, LoginChallengeState::Pending);
|
||||
assert_eq!(review.requesting_ip, h.desktop_ip);
|
||||
assert_eq!(review.confirmation_code, created.confirmation_code);
|
||||
assert_eq!(review.username, h.user.username);
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
|
||||
let approved = h.decide(&h.browser, &created, true).await;
|
||||
assert_private_response(&approved);
|
||||
assert_no_session_cookie(&approved);
|
||||
let approved: LoginChallengeResponse = read_ok(approved).await;
|
||||
assert_eq!(approved.status, LoginChallengeState::Approved);
|
||||
assert!(approved.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0, "approval must not mint a session");
|
||||
assert_anonymous(h.session(&h.desktop).await);
|
||||
let approved_audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(approved_audit.deciding_user.as_deref(), Some(h.user.user_token.as_str()));
|
||||
assert_eq!(approved_audit.decision_ip.as_deref(), Some(h.browser_ip.as_str()));
|
||||
assert!(approved_audit.decided_at.is_some());
|
||||
assert!(approved_audit.redeemed_at.is_none() && approved_audit.session_id.is_none());
|
||||
|
||||
// Overlapping real HTTP requests serialize through the production row lock.
|
||||
let (first, retry) = tokio::join!(h.poll(&created), h.poll(&created));
|
||||
assert_private_response(&first);
|
||||
let cookie = session_cookie(&first).expect("redemption Set-Cookie");
|
||||
assert_eq!(cookie.path(), Some("/"));
|
||||
let first: LoginChallengeResponse = read_ok(first).await;
|
||||
let retry: LoginChallengeResponse = read_ok(retry).await;
|
||||
assert_eq!(first.status, LoginChallengeState::Redeemed);
|
||||
assert_eq!(first.maybe_signed_session, retry.maybe_signed_session);
|
||||
assert_eq!(first.maybe_signed_session.as_deref(), Some(cookie.value()));
|
||||
assert_ne!(cookie.value(), h.signer.create_cookie(&h.user.session_token, &h.user.user_token).unwrap().value());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 1);
|
||||
|
||||
// No injected cookie or canned response: reqwest sends the Set-Cookie it received.
|
||||
let session = h.session(&h.desktop).await;
|
||||
assert_eq!(session["success"], true);
|
||||
assert_eq!(session["logged_in"], true);
|
||||
assert_eq!(session["user"]["user_token"], h.user.user_token.as_str());
|
||||
assert_eq!(session["user"]["core_info"]["user_token"], h.user.user_token.as_str());
|
||||
assert_eq!(session["user"]["username"], h.user.username);
|
||||
assert_eq!(session["user"]["onboarding"]["password_not_set"], true);
|
||||
assert_eq!(session["user"]["onboarding"]["email_not_confirmed"], false);
|
||||
assert_eq!(session["user"]["maybe_feature_flags"], json!(["studio", "use_qt"]));
|
||||
assert_eq!(session["user"]["can_access_studio"], true);
|
||||
assert_eq!(session["user"]["can_ban_users"], false);
|
||||
assert!(session.get("signed_session").is_none());
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(audit.status, UserLoginChallengeStatus::Redeemed);
|
||||
assert_eq!(audit.redemption_ip.as_deref(), Some(h.desktop_ip.as_str()));
|
||||
assert!(audit.session_id.is_some());
|
||||
assert!(audit.redeemed_at.unwrap() >= audit.decided_at.unwrap());
|
||||
assert!(audit.failed_at.is_none());
|
||||
|
||||
// Losing the first response is recoverable from a fresh client with no cookies.
|
||||
let fresh = h.client("192.0.2.250", None);
|
||||
let replay: LoginChallengeResponse = read_ok(h.post(&fresh, "poll", json!({"device_token":created.device_token})).await).await;
|
||||
assert_eq!(replay.maybe_signed_session, first.maybe_signed_session);
|
||||
assert_eq!(h.session(&fresh).await["user"]["user_token"], h.user.user_token.as_str());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 1);
|
||||
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, -1).await;
|
||||
let expired = h.poll(&created).await;
|
||||
assert_no_session_cookie(&expired);
|
||||
let expired: LoginChallengeResponse = read_ok(expired).await;
|
||||
assert_eq!(expired.maybe_failure_type, Some(LoginChallengeFailure::Expired));
|
||||
assert!(expired.maybe_signed_session.is_none());
|
||||
assert_eq!(h.session(&h.desktop).await["logged_in"], true, "session outlives the handoff");
|
||||
h.finish().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn rejected_and_expired_requests_never_authenticate_the_desktop() {
|
||||
let h = EndpointHarness::start().await;
|
||||
for mode in ["declined", "expired_pending", "expired_approved"] {
|
||||
let created = h.create().await;
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
let review: ReviewLoginChallengeResponse = read_ok(h.review(&h.browser, &created).await).await;
|
||||
assert_eq!(review.status, LoginChallengeState::Pending);
|
||||
if mode == "declined" {
|
||||
let declined = h.decide(&h.browser, &created, false).await;
|
||||
assert_no_session_cookie(&declined);
|
||||
let declined: LoginChallengeResponse = read_ok(declined).await;
|
||||
assert_eq!(declined.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
|
||||
let replay: LoginChallengeResponse = read_ok(h.decide(&h.browser, &created, true).await).await;
|
||||
assert_eq!(replay.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
|
||||
} else {
|
||||
if mode == "expired_approved" {
|
||||
let approved: LoginChallengeResponse = read_ok(h.decide(&h.browser, &created, true).await).await;
|
||||
assert_eq!(approved.status, LoginChallengeState::Approved);
|
||||
}
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, -1).await;
|
||||
}
|
||||
let response = h.poll(&created).await;
|
||||
assert_private_response(&response);
|
||||
assert_no_session_cookie(&response);
|
||||
let result: LoginChallengeResponse = read_ok(response).await;
|
||||
assert_eq!(result.status, LoginChallengeState::Failed);
|
||||
assert_eq!(result.maybe_failure_type, Some(if mode == "declined" { LoginChallengeFailure::UserDeclined } else { LoginChallengeFailure::Expired }));
|
||||
assert!(result.maybe_signed_session.is_none());
|
||||
assert_anonymous(h.session(&h.desktop).await);
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert!(audit.session_id.is_none() && audit.redeemed_at.is_none());
|
||||
assert!(audit.failed_at.is_some());
|
||||
assert_eq!(audit.creation_ip, h.desktop_ip);
|
||||
assert_eq!(audit.failure_ip.as_deref(), if mode == "declined" { Some(h.browser_ip.as_str()) } else { None });
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
}
|
||||
assert_eq!(h.session(&h.browser).await["logged_in"], true);
|
||||
h.finish().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn revoked_expired_and_banned_sessions_fail_real_downstream_requests_and_cannot_be_reminted() {
|
||||
let h = EndpointHarness::start().await;
|
||||
for (index, mode) in ["revoked", "expired", "banned"].iter().copied().enumerate() {
|
||||
let created = h.create().await;
|
||||
let _: LoginChallengeResponse = read_ok(h.decide(&h.browser, &created, true).await).await;
|
||||
let result: LoginChallengeResponse = read_ok(h.poll(&created).await).await;
|
||||
assert_eq!(h.session(&h.desktop).await["logged_in"], true);
|
||||
let signed = result.maybe_signed_session.unwrap();
|
||||
let request = actix_web::test::TestRequest::default().insert_header(("session", signed)).to_http_request();
|
||||
let token = h.signer.decode_session_payload_from_request(&request).unwrap().unwrap().session_token;
|
||||
match mode {
|
||||
"revoked" => fixtures::revoke_session(&h.db.pool, &token).await,
|
||||
"expired" => fixtures::expire_session(&h.db.pool, &token).await,
|
||||
_ => fixtures::ban_user(&h.db.pool, &h.user.user_token).await,
|
||||
}
|
||||
assert_anonymous(h.session(&h.desktop).await);
|
||||
let retry = h.poll(&created).await;
|
||||
assert_eq!(retry.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_no_session_cookie(&retry);
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, (index + 1) as i64);
|
||||
}
|
||||
h.finish().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn a_second_account_cannot_replace_the_user_who_approved() {
|
||||
let h = EndpointHarness::start().await;
|
||||
let other = create_test_user(&h.db.pool).await.unwrap();
|
||||
let other_browser = h.client("198.51.100.250", Some(&other));
|
||||
let created = h.create().await;
|
||||
let _: LoginChallengeResponse = read_ok(h.decide(&h.browser, &created, true).await).await;
|
||||
for approve in [true, false] {
|
||||
let replay: LoginChallengeResponse = read_ok(h.decide(&other_browser, &created, approve).await).await;
|
||||
assert_eq!(replay.status, LoginChallengeState::Approved);
|
||||
assert!(replay.maybe_signed_session.is_none());
|
||||
}
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
let _: LoginChallengeResponse = read_ok(h.poll(&created).await).await;
|
||||
assert_eq!(h.session(&h.desktop).await["user"]["user_token"], h.user.user_token.as_str());
|
||||
assert_eq!(h.session(&other_browser).await["user"]["user_token"], other.user_token.as_str());
|
||||
let audit = fixtures::audit(&h.db.pool, &security::secret_hash(&created.device_token).unwrap()).await;
|
||||
assert_eq!(audit.deciding_user.as_deref(), Some(h.user.user_token.as_str()));
|
||||
assert_eq!(audit.decision_ip.as_deref(), Some(h.browser_ip.as_str()));
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 1);
|
||||
h.finish().await;
|
||||
}
|
||||
|
||||
impl EndpointHarness {
|
||||
async fn start() -> Self {
|
||||
static NEXT_IP: AtomicU16 = AtomicU16::new(128);
|
||||
let suffix = NEXT_IP.fetch_add(1, Ordering::Relaxed);
|
||||
let desktop_ip = format!("192.0.2.{suffix}");
|
||||
let browser_ip = format!("198.51.100.{suffix}");
|
||||
let db = IsolatedTestDatabase::create().await;
|
||||
let user = fixtures::create_passwordless_user(&db.pool, &[UserFeatureFlag::Studio, UserFeatureFlag::CanUseQuicktime]).await;
|
||||
let signer = HttpUserSessionManager::new("localhost", TEST_SIGNING_KEY).unwrap();
|
||||
let checker = SessionChecker::new(&signer);
|
||||
let avt = AvtCookieManager::new("localhost", TEST_SIGNING_KEY).unwrap();
|
||||
let pool = db.pool.clone();
|
||||
let app_signer = signer.clone();
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
assert!(address.ip().is_loopback());
|
||||
let base_url = Url::parse(&format!("http://{address}")).unwrap();
|
||||
let server = HttpServer::new(move || {
|
||||
App::new()
|
||||
.app_data(web::Data::new(pool.clone()))
|
||||
.app_data(web::Data::new(app_signer.clone()))
|
||||
.app_data(web::Data::new(checker.clone()))
|
||||
.app_data(web::Data::new(avt.clone()))
|
||||
.configure(handlers::configure)
|
||||
.route("/v1/session", web::get().to(session_info_handler))
|
||||
}).workers(1).disable_signals().listen(listener).unwrap().run();
|
||||
let handle = server.handle();
|
||||
actix_web::rt::spawn(server);
|
||||
let desktop = client(&base_url, &desktop_ip, None);
|
||||
let cookie = signer.create_cookie(&user.session_token, &user.user_token).unwrap().to_string();
|
||||
let browser = client(&base_url, &browser_ip, Some(&cookie));
|
||||
Self { db, user, signer, server: handle, base_url, desktop, browser, desktop_ip, browser_ip }
|
||||
}
|
||||
|
||||
fn client(&self, ip: &str, user: Option<&TestUser>) -> Client {
|
||||
let cookie = user.map(|u| self.signer.create_cookie(&u.session_token, &u.user_token).unwrap().to_string());
|
||||
client(&self.base_url, ip, cookie.as_deref())
|
||||
}
|
||||
|
||||
async fn create(&self) -> CreateLoginChallengeResponse {
|
||||
let response = self.post(&self.desktop, "create", json!({})).await;
|
||||
assert_private_response(&response);
|
||||
assert_no_session_cookie(&response);
|
||||
let created: CreateLoginChallengeResponse = read_ok(response).await;
|
||||
assert!(created.success);
|
||||
assert!(!created.verification_url.contains(&created.device_token));
|
||||
created
|
||||
}
|
||||
|
||||
async fn review(&self, client: &Client, created: &CreateLoginChallengeResponse) -> Response {
|
||||
self.post(client, "review", json!({"approval_token": approval_token(created)})).await
|
||||
}
|
||||
|
||||
async fn decide(&self, client: &Client, created: &CreateLoginChallengeResponse, approve: bool) -> Response {
|
||||
self.post(client, "decide", json!({"approval_token": approval_token(created), "approve": approve})).await
|
||||
}
|
||||
|
||||
async fn poll(&self, created: &CreateLoginChallengeResponse) -> Response {
|
||||
self.post(&self.desktop, "poll", json!({"device_token": created.device_token})).await
|
||||
}
|
||||
|
||||
async fn post(&self, client: &Client, action: &str, body: Value) -> Response {
|
||||
client.post(self.base_url.join(&format!("/v1/login_challenges/{action}")).unwrap()).json(&body).send().await.unwrap()
|
||||
}
|
||||
|
||||
async fn session(&self, client: &Client) -> Value {
|
||||
read_ok(client.get(self.base_url.join("/v1/session").unwrap()).send().await.unwrap()).await
|
||||
}
|
||||
|
||||
async fn finish(self) {
|
||||
self.server.stop(true).await;
|
||||
self.db.destroy().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn client(base_url: &Url, ip: &str, cookie: Option<&str>) -> Client {
|
||||
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
|
||||
let jar = Arc::new(Jar::default());
|
||||
if let Some(cookie) = cookie { jar.add_cookie_str(cookie, base_url); }
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("Origin", HeaderValue::from_static(BROWSER_ORIGIN));
|
||||
// Model the production gateway forwarding different browser and desktop IPs.
|
||||
headers.insert("X-Forwarded-For", HeaderValue::from_str(ip).unwrap());
|
||||
Client::builder().no_proxy().redirect(reqwest::redirect::Policy::none())
|
||||
.timeout(Duration::from_secs(10)).default_headers(headers).cookie_provider(jar).build().unwrap()
|
||||
}
|
||||
|
||||
async fn read_ok<T: DeserializeOwned>(response: Response) -> T {
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
response.json().await.unwrap()
|
||||
}
|
||||
|
||||
fn approval_token(created: &CreateLoginChallengeResponse) -> &str {
|
||||
created.verification_url.split_once("#approval_token=").unwrap().1
|
||||
}
|
||||
|
||||
fn session_cookie(response: &Response) -> Option<actix_web::cookie::Cookie<'static>> {
|
||||
response.headers().get_all(SET_COOKIE).iter()
|
||||
.filter_map(|value| actix_web::cookie::Cookie::parse(value.to_str().unwrap()).ok())
|
||||
.find(|cookie| cookie.name() == "session").map(|cookie| cookie.into_owned())
|
||||
}
|
||||
|
||||
fn assert_no_session_cookie(response: &Response) {
|
||||
assert!(session_cookie(response).is_none());
|
||||
}
|
||||
|
||||
fn assert_private_response(response: &Response) {
|
||||
assert_eq!(response.headers()["cache-control"], "no-store");
|
||||
assert_eq!(response.headers()["referrer-policy"], "no-referrer");
|
||||
}
|
||||
|
||||
fn assert_anonymous(session: Value) {
|
||||
assert_eq!(session["success"], true);
|
||||
assert_eq!(session["logged_in"], false);
|
||||
assert!(session["user"].is_null());
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
|
||||
use actix_web::middleware::DefaultHeaders;
|
||||
use actix_web::{web, HttpRequest, HttpResponse};
|
||||
use artcraft_api_defs::users::login_challenges::*;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use mysql_queries::queries::user_login_challenges::create_challenge::{create_challenge, CreateChallengeArgs};
|
||||
use mysql_queries::queries::user_login_challenges::decide_challenge::{decide_challenge, DecideChallengeArgs};
|
||||
use mysql_queries::queries::user_login_challenges::expire_challenge::{expire_challenge, ExpireChallengeArgs};
|
||||
use mysql_queries::queries::user_login_challenges::is_challenge_expired::{is_challenge_expired, IsChallengeExpiredArgs};
|
||||
use mysql_queries::queries::user_login_challenges::lock_by_approval::{lock_by_approval, LockByApprovalArgs};
|
||||
use mysql_queries::queries::user_login_challenges::lock_by_device::{lock_by_device, LockByDeviceArgs};
|
||||
use mysql_queries::queries::user_login_challenges::login_challenge::LoginChallenge;
|
||||
use mysql_queries::queries::user_login_challenges::mark_redeemed::{mark_redeemed, MarkRedeemedArgs};
|
||||
use mysql_queries::queries::users::user_sessions::bridge_session::BridgeSession;
|
||||
use mysql_queries::queries::users::user_sessions::create_bridge_session::{create_bridge_session, CreateBridgeSessionArgs};
|
||||
use mysql_queries::queries::users::user_sessions::find_approving_session::{find_approving_session, FindApprovingSessionArgs};
|
||||
use mysql_queries::queries::users::user_sessions::find_redeemed_session::{find_redeemed_session, FindRedeemedSessionArgs};
|
||||
use sqlx::{Executor, MySql, MySqlPool, Transaction};
|
||||
use tokens::tokens::user_login_challenges::UserLoginChallengeToken;
|
||||
use tokens::tokens::user_sessions::UserSessionToken;
|
||||
use tokens::tokens::users::UserToken;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use super::security::{new_confirmation_code, new_secret, rate_limit, request_ip, require_approval_origin, secret_hash};
|
||||
|
||||
const VERIFICATION_PAGE: &str = "https://app.getartcraft.com/login/desktop";
|
||||
|
||||
|
||||
pub fn configure(cfg: &mut web::ServiceConfig) {
|
||||
cfg.service(web::scope("/v1/login_challenges").wrap(DefaultHeaders::new().add(("Cache-Control", "no-store")).add(("Referrer-Policy", "no-referrer"))).app_data(web::JsonConfig::default().limit(1024)).route("/create", web::post().to(create)).route("/review", web::post().to(review)).route("/decide", web::post().to(decide)).route("/poll", web::post().to(poll)));
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/v1/login_challenges/create", tag = "Users", request_body = CreateLoginChallengeRequest,
|
||||
responses((status = 200, body = CreateLoginChallengeResponse), (status = 429, body = CommonWebError)))]
|
||||
pub async fn create(request: HttpRequest, _body: web::Json<CreateLoginChallengeRequest>, pool: web::Data<MySqlPool>) -> Result<web::Json<CreateLoginChallengeResponse>, CommonWebError> {
|
||||
let ip = request_ip(&request);
|
||||
rate_limit(&ip, true)?;
|
||||
let approval_token = new_secret()?;
|
||||
let device_token = new_secret()?;
|
||||
let approval_hash = secret_hash(&approval_token)?;
|
||||
let device_hash = secret_hash(&device_token)?;
|
||||
let code = new_confirmation_code()?;
|
||||
let token = UserLoginChallengeToken::generate();
|
||||
let mut conn = pool.acquire().await?;
|
||||
create_challenge(CreateChallengeArgs {
|
||||
token: &token,
|
||||
approval_hash: &approval_hash,
|
||||
device_hash: &device_hash,
|
||||
confirmation_code: &code,
|
||||
status: UserLoginChallengeStatus::Pending,
|
||||
ip_address: &ip,
|
||||
mysql_executor: &mut *conn,
|
||||
}).await?;
|
||||
let challenge = lock_by_device(LockByDeviceArgs {
|
||||
device_hash: &device_hash,
|
||||
mysql_executor: &mut *conn,
|
||||
}).await?.ok_or(CommonWebError::NotFound)?;
|
||||
Ok(web::Json(CreateLoginChallengeResponse {
|
||||
success: true,
|
||||
device_token,
|
||||
// A fragment avoids sending the approval credential to the website's HTTP logs.
|
||||
verification_url: format!("{}#approval_token={approval_token}", verification_page(&request)),
|
||||
confirmation_code: code,
|
||||
expires_at: challenge.expires_at,
|
||||
poll_interval_seconds: 5,
|
||||
}))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/v1/login_challenges/review", tag = "Users", request_body = ReviewLoginChallengeRequest,
|
||||
responses((status = 200, body = ReviewLoginChallengeResponse), (status = 401, body = CommonWebError), (status = 403, body = CommonWebError)))]
|
||||
pub async fn review(request: HttpRequest, body: web::Json<ReviewLoginChallengeRequest>, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<web::Json<ReviewLoginChallengeResponse>, CommonWebError> {
|
||||
require_approval_origin(&request)?;
|
||||
rate_limit(&request_ip(&request), false)?;
|
||||
let hash = secret_hash(&body.approval_token)?;
|
||||
let mut tx = pool.begin().await?;
|
||||
let approver = approving_session(&request, &signer, &mut *tx).await?;
|
||||
let mut challenge = lock_by_approval(LockByApprovalArgs {
|
||||
approval_hash: &hash,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
let expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
|
||||
let result = outcome(&challenge, expired);
|
||||
tx.commit().await?;
|
||||
Ok(web::Json(ReviewLoginChallengeResponse { success: true, status: result.status, maybe_failure_type: result.maybe_failure_type, confirmation_code: challenge.confirmation_code, requesting_ip: challenge.ip_address_creation, expires_at: challenge.expires_at, username: approver.username }))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/v1/login_challenges/decide", tag = "Users", request_body = DecideLoginChallengeRequest,
|
||||
responses((status = 200, body = LoginChallengeResponse), (status = 401, body = CommonWebError), (status = 403, body = CommonWebError)))]
|
||||
pub async fn decide(request: HttpRequest, body: web::Json<DecideLoginChallengeRequest>, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<web::Json<LoginChallengeResponse>, CommonWebError> {
|
||||
require_approval_origin(&request)?;
|
||||
let ip = request_ip(&request);
|
||||
rate_limit(&ip, false)?;
|
||||
let hash = secret_hash(&body.approval_token)?;
|
||||
let mut tx = pool.begin().await?;
|
||||
let approver = approving_session(&request, &signer, &mut *tx).await?;
|
||||
let mut challenge = lock_by_approval(LockByApprovalArgs {
|
||||
approval_hash: &hash,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
let mut expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
|
||||
if !expired && challenge.status == UserLoginChallengeStatus::Pending {
|
||||
if decide_challenge(DecideChallengeArgs {
|
||||
challenge_id: challenge.id,
|
||||
user_token: &approver.user_token,
|
||||
ip_address: &ip,
|
||||
approve: body.approve,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await? {
|
||||
challenge.status = if body.approve { UserLoginChallengeStatus::Approved } else { UserLoginChallengeStatus::Failed };
|
||||
challenge.maybe_failure_type = if body.approve { None } else { Some(UserLoginChallengeFailureType::UserDeclined) };
|
||||
} else {
|
||||
expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
|
||||
}
|
||||
}
|
||||
// No session INSERT occurs here, including on approval. Terminal decisions are immutable.
|
||||
let result = outcome(&challenge, expired);
|
||||
tx.commit().await?;
|
||||
Ok(web::Json(result))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/v1/login_challenges/poll", tag = "Users", request_body = PollLoginChallengeRequest,
|
||||
responses((status = 200, body = LoginChallengeResponse), (status = 401, body = CommonWebError), (status = 429, body = CommonWebError)))]
|
||||
pub async fn poll(request: HttpRequest, body: web::Json<PollLoginChallengeRequest>, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<HttpResponse, CommonWebError> {
|
||||
let ip = request_ip(&request);
|
||||
rate_limit(&ip, false)?;
|
||||
let hash = secret_hash(&body.device_token)?;
|
||||
let mut tx = pool.begin().await?;
|
||||
let mut challenge = lock_by_device(LockByDeviceArgs {
|
||||
device_hash: &hash,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
let expired = expire_locked_challenge(&mut challenge, &mut tx).await?;
|
||||
let mut result = outcome(&challenge, expired);
|
||||
if expired || challenge.status == UserLoginChallengeStatus::Pending || challenge.status == UserLoginChallengeStatus::Failed {
|
||||
tx.commit().await?;
|
||||
return Ok(HttpResponse::Ok().json(result));
|
||||
}
|
||||
let user = challenge.maybe_deciding_user_token.as_deref().ok_or(CommonWebError::NotAuthorized)?;
|
||||
let session = if challenge.status == UserLoginChallengeStatus::Approved {
|
||||
let session_id = create_bridge_session(CreateBridgeSessionArgs {
|
||||
user_token: user,
|
||||
ip_address: &ip,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
let session = find_redeemed_session(FindRedeemedSessionArgs {
|
||||
session_id,
|
||||
user_token: user,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
if !mark_redeemed(MarkRedeemedArgs {
|
||||
challenge_id: challenge.id,
|
||||
session_id: session.id,
|
||||
ip_address: &ip,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await? {
|
||||
// Includes expiry during redemption. Roll back the INSERT as well.
|
||||
tx.rollback().await?;
|
||||
return Ok(HttpResponse::Ok().json(outcome(&challenge, true)));
|
||||
}
|
||||
session
|
||||
} else if challenge.status == UserLoginChallengeStatus::Redeemed {
|
||||
let id = challenge.maybe_redeemed_user_session_id.ok_or(CommonWebError::NotAuthorized)?;
|
||||
find_redeemed_session(FindRedeemedSessionArgs {
|
||||
session_id: id,
|
||||
user_token: user,
|
||||
mysql_executor: &mut *tx,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)?
|
||||
} else {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
};
|
||||
let cookie = signer.create_cookie(&UserSessionToken::new(session.token), &UserToken::new(session.user_token))?;
|
||||
result.status = LoginChallengeState::Redeemed;
|
||||
result.maybe_signed_session = Some(cookie.value().to_owned());
|
||||
// The new session, challenge transition, and signing must ALL succeed first.
|
||||
tx.commit().await?;
|
||||
Ok(HttpResponse::Ok().cookie(cookie).json(result))
|
||||
}
|
||||
|
||||
/// Both queries must run in the transaction that already holds the challenge lock.
|
||||
async fn expire_locked_challenge(challenge: &mut LoginChallenge, tx: &mut Transaction<'_, MySql>) -> Result<bool, sqlx::Error> {
|
||||
let expired = is_challenge_expired(IsChallengeExpiredArgs {
|
||||
challenge_id: challenge.id,
|
||||
mysql_executor: &mut **tx,
|
||||
}).await?;
|
||||
if expired && (challenge.status == UserLoginChallengeStatus::Pending || challenge.status == UserLoginChallengeStatus::Approved) {
|
||||
expire_challenge(ExpireChallengeArgs {
|
||||
challenge_id: challenge.id,
|
||||
mysql_executor: &mut **tx,
|
||||
}).await?;
|
||||
challenge.status = UserLoginChallengeStatus::Failed;
|
||||
challenge.maybe_failure_type = Some(UserLoginChallengeFailureType::Expired);
|
||||
}
|
||||
Ok(expired)
|
||||
}
|
||||
|
||||
async fn approving_session<'c, T>(request: &HttpRequest, signer: &HttpUserSessionManager, mysql_executor: T) -> Result<BridgeSession, CommonWebError>
|
||||
where
|
||||
T: Executor<'c, Database = MySql>,
|
||||
{
|
||||
let payload = signer.decode_session_payload_from_request(request).map_err(|_| CommonWebError::NotAuthorized)?.ok_or(CommonWebError::NotAuthorized)?;
|
||||
find_approving_session(FindApprovingSessionArgs {
|
||||
session_token: &payload.session_token,
|
||||
mysql_executor,
|
||||
}).await?.ok_or(CommonWebError::NotAuthorized)
|
||||
}
|
||||
|
||||
fn verification_page(request: &HttpRequest) -> &'static str {
|
||||
let host = request.connection_info().host().to_owned();
|
||||
if host.starts_with("localhost:") || host.starts_with("127.0.0.1:") {
|
||||
"http://localhost:4201/login/desktop"
|
||||
} else {
|
||||
VERIFICATION_PAGE
|
||||
}
|
||||
}
|
||||
|
||||
fn outcome(challenge: &LoginChallenge, expired: bool) -> LoginChallengeResponse {
|
||||
// Keep an explicit decline even when its deadline has since elapsed.
|
||||
let declined = challenge.maybe_failure_type == Some(UserLoginChallengeFailureType::UserDeclined);
|
||||
let (status, failure) = if expired && !declined {
|
||||
(LoginChallengeState::Failed, Some(LoginChallengeFailure::Expired))
|
||||
} else {
|
||||
let status = match challenge.status {
|
||||
UserLoginChallengeStatus::Pending => LoginChallengeState::Pending,
|
||||
UserLoginChallengeStatus::Approved => LoginChallengeState::Approved,
|
||||
UserLoginChallengeStatus::Redeemed => LoginChallengeState::Redeemed,
|
||||
UserLoginChallengeStatus::Failed => LoginChallengeState::Failed,
|
||||
};
|
||||
let failure = match challenge.maybe_failure_type {
|
||||
Some(UserLoginChallengeFailureType::UserDeclined) => Some(LoginChallengeFailure::UserDeclined),
|
||||
Some(UserLoginChallengeFailureType::Expired) => Some(LoginChallengeFailure::Expired),
|
||||
None => None,
|
||||
};
|
||||
(status, failure)
|
||||
};
|
||||
LoginChallengeResponse { success: true, status, maybe_failure_type: failure, maybe_signed_session: None }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod website_origin_tests {
|
||||
use super::verification_page;
|
||||
use actix_web::test::TestRequest;
|
||||
|
||||
#[test]
|
||||
fn local_challenges_point_at_the_local_approval_site() {
|
||||
let request = TestRequest::default().insert_header(("Host", "localhost:12345")).to_http_request();
|
||||
assert_eq!(verification_page(&request), "http://localhost:4201/login/desktop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_challenges_point_at_the_production_approval_site() {
|
||||
let request = TestRequest::default().insert_header(("Host", "api.storyteller.ai")).to_http_request();
|
||||
assert_eq!(verification_page(&request), "https://app.getartcraft.com/login/desktop");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
pub mod handlers;
|
||||
mod security;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod endpoint_integration_tests;
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use actix_web::HttpRequest;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use http_server_common::request::get_request_ip::get_request_ip;
|
||||
use rand::{
|
||||
rngs::{OsRng, StdRng},
|
||||
Rng, SeedableRng, TryRngCore,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
|
||||
const CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
|
||||
const APPROVAL_ORIGINS: &[&str] = &["https://app.getartcraft.com", "https://getartcraft.com", "https://www.getartcraft.com"];
|
||||
const WINDOW: Duration = Duration::from_secs(60);
|
||||
const MAX_BUCKETS: usize = 10_000;
|
||||
|
||||
/// Follow the existing gateway's forwarded-IP policy, but normalize stored IPs
|
||||
/// and use the socket address for direct IPv6 requests (the legacy helper splits
|
||||
/// on ':'). Gateway deployments must strip caller-supplied forwarding headers.
|
||||
pub fn request_ip(request: &HttpRequest) -> String {
|
||||
get_request_ip(request).parse::<IpAddr>().ok().or_else(|| request.peer_addr().map(|address| address.ip())).map(|ip| ip.to_string()).unwrap_or_else(|| "0.0.0.0".into())
|
||||
}
|
||||
|
||||
pub fn new_secret() -> Result<String, CommonWebError> {
|
||||
let mut bytes = [0u8; 32];
|
||||
OsRng.try_fill_bytes(&mut bytes).map_err(CommonWebError::from_error)?;
|
||||
Ok(URL_SAFE_NO_PAD.encode(bytes))
|
||||
}
|
||||
|
||||
pub fn secret_hash(secret: &str) -> Result<[u8; 32], CommonWebError> {
|
||||
if secret.len() != 43 {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
let bytes = URL_SAFE_NO_PAD.decode(secret).map_err(|_| CommonWebError::NotAuthorized)?;
|
||||
if bytes.len() != 32 || URL_SAFE_NO_PAD.encode(&bytes) != secret {
|
||||
return Err(CommonWebError::NotAuthorized);
|
||||
}
|
||||
Ok(Sha256::digest(secret.as_bytes()).into())
|
||||
}
|
||||
|
||||
pub fn new_confirmation_code() -> Result<String, CommonWebError> {
|
||||
let mut rng = StdRng::try_from_os_rng().map_err(CommonWebError::from_error)?;
|
||||
Ok((0..8).map(|_| CODE_ALPHABET[rng.random_range(0..CODE_ALPHABET.len())] as char).collect())
|
||||
}
|
||||
|
||||
/// Approval is browser-only and requires an exact trusted Origin. JSON POSTs
|
||||
/// plus Origin validation prevent ambient cookies from granting cross-site consent.
|
||||
pub fn require_approval_origin(request: &HttpRequest) -> Result<(), CommonWebError> {
|
||||
let origin = request.headers().get("Origin").and_then(|v| v.to_str().ok()).ok_or(CommonWebError::Forbidden)?;
|
||||
if APPROVAL_ORIGINS.contains(&origin) {
|
||||
return Ok(());
|
||||
}
|
||||
// Local web development is permitted only when the API is also local.
|
||||
let host = request.connection_info().host().to_owned();
|
||||
let local_api = host.starts_with("localhost:") || host.starts_with("127.0.0.1:");
|
||||
if local_api && ["http://localhost:4200", "http://127.0.0.1:4200", "http://localhost:4201", "http://127.0.0.1:4201"].contains(&origin) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(CommonWebError::Forbidden)
|
||||
}
|
||||
|
||||
/// Per-process abuse cap, shared by all Actix workers. Entries expire and memory
|
||||
/// is bounded. Deployments can impose stricter aggregate limits at their gateway.
|
||||
pub fn rate_limit(ip: &str, create: bool) -> Result<(), CommonWebError> {
|
||||
static BUCKETS: OnceLock<Mutex<HashMap<(String, bool), (Instant, u32)>>> = OnceLock::new();
|
||||
let mut buckets = BUCKETS.get_or_init(|| Mutex::new(HashMap::new())).lock().map_err(|_| CommonWebError::TooManyRequests)?;
|
||||
let now = Instant::now();
|
||||
buckets.retain(|_, (started, _)| now.duration_since(*started) < WINDOW);
|
||||
let key = (ip.to_owned(), create);
|
||||
if buckets.len() >= MAX_BUCKETS && !buckets.contains_key(&key) {
|
||||
return Err(CommonWebError::TooManyRequests);
|
||||
}
|
||||
let (_, count) = buckets.entry(key).or_insert((now, 0));
|
||||
let limit = if create { 10 } else { 120 };
|
||||
if *count >= limit {
|
||||
return Err(CommonWebError::TooManyRequests);
|
||||
}
|
||||
*count += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use actix_web::test::TestRequest;
|
||||
use super::require_approval_origin;
|
||||
|
||||
#[test]
|
||||
fn both_local_sites_require_a_local_api() {
|
||||
for origin in ["http://localhost:4200", "http://127.0.0.1:4200", "http://localhost:4201", "http://127.0.0.1:4201"] {
|
||||
for host in ["localhost:12345", "127.0.0.1:12345", "api.storyteller.ai"] {
|
||||
let request = TestRequest::post().insert_header(("Host", host)).insert_header(("Origin", origin)).to_http_request();
|
||||
assert_eq!(require_approval_origin(&request).is_ok(), host != "api.storyteller.ai");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_origins_require_exact_matches() {
|
||||
for origin in ["https://getartcraft.com.evil.example", "https://app.getartcraft.com.evil.example", "http://getartcraft.com", "http://localhost:4202", "null"] {
|
||||
let request = TestRequest::post().insert_header(("Host", "localhost:12345")).insert_header(("Origin", origin)).to_http_request();
|
||||
assert!(require_approval_origin(&request).is_err());
|
||||
}
|
||||
assert!(require_approval_origin(&TestRequest::post().to_http_request()).is_err());
|
||||
}
|
||||
}
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
//! Real HTTP handlers + signed cookies + disposable local MySQL databases.
|
||||
//! No application URLs, production DB configuration, Redis, or external providers.
|
||||
use std::sync::atomic::{AtomicU16, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use actix_artcraft::sessions::user_sessions::http_user_session_manager::HttpUserSessionManager;
|
||||
use actix_http::Request;
|
||||
use actix_web::body::MessageBody;
|
||||
use actix_web::dev::{Service, ServiceResponse};
|
||||
use actix_web::http::StatusCode;
|
||||
use actix_web::{test, web, App, HttpRequest};
|
||||
use artcraft_api_defs::users::login_challenges::*;
|
||||
use mysql_queries::queries::user_login_challenges::expire_abandoned_challenges::{expire_abandoned_challenges, ExpireAbandonedChallengesArgs};
|
||||
use mysql_queries::queries::user_login_challenges::lock_by_device::{lock_by_device, LockByDeviceArgs};
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_failure_type::UserLoginChallengeFailureType;
|
||||
use enums::by_table::user_login_challenges::user_login_challenge_status::UserLoginChallengeStatus;
|
||||
use mysql_testing::fixtures::login_challenges as fixtures;
|
||||
use mysql_testing::fixtures::mcp_sessions::create_test_mcp_session;
|
||||
use mysql_testing::fixtures::users::{create_test_user, TestUser};
|
||||
use mysql_testing::isolated::IsolatedTestDatabase;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
use crate::http_server::common_responses::common_web_error::CommonWebError;
|
||||
use crate::http_server::user_lookup::user_session::require_user_session::require_user_session;
|
||||
use crate::http_server::user_lookup::user_session::session_utils::session_checker::SessionChecker;
|
||||
use super::{handlers, security};
|
||||
|
||||
const ORIGIN: &str = "https://app.getartcraft.com";
|
||||
const BROWSER_IP: &str = "198.51.100.20";
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn both_browser_sites_can_review_approve_and_decline() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure).route("/protected", web::get().to(protected))).await;
|
||||
let mut minted = 0;
|
||||
for origin in ["https://getartcraft.com", "https://www.getartcraft.com", "https://app.getartcraft.com"] {
|
||||
for approve in [false, true] {
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
let approval = approval_token(&created);
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/review").insert_header(("Origin", origin)).insert_header(("session", h.cookie.as_str())).set_json(json!({"approval_token": approval})).to_request();
|
||||
let review: ReviewLoginChallengeResponse = test::call_and_read_body_json(&app, request).await;
|
||||
assert_eq!(review.status, LoginChallengeState::Pending);
|
||||
assert_eq!(review.username, h.user.username);
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("Origin", origin)).insert_header(("session", h.cookie.as_str())).set_json(json!({"approval_token": approval, "approve": approve})).to_request();
|
||||
let decision: LoginChallengeResponse = test::call_and_read_body_json(&app, request).await;
|
||||
assert!(decision.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, minted);
|
||||
let polled = post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await;
|
||||
if approve {
|
||||
let cookie = polled.response().cookies().find(|c| c.name() == "session").unwrap().into_owned();
|
||||
let downstream = test::call_service(&app, test::TestRequest::get().uri("/protected").cookie(cookie).to_request()).await;
|
||||
assert_eq!(downstream.status(), StatusCode::OK);
|
||||
let body: Value = test::read_body_json(downstream).await;
|
||||
assert_eq!(body["user_token"], h.user.user_token.as_str());
|
||||
minted += 1;
|
||||
} else {
|
||||
assert!(!polled.headers().contains_key("set-cookie"));
|
||||
let result: LoginChallengeResponse = test::read_body_json(polled).await;
|
||||
assert_eq!(result.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
|
||||
assert!(result.maybe_signed_session.is_none());
|
||||
}
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, minted);
|
||||
}
|
||||
}
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn approval_mints_once_and_cookie_authenticates_downstream() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure).route("/protected", web::get().to(protected))).await;
|
||||
fixtures::set_passwordless(&h.db.pool, &h.user.user_token).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
let approval = approval_token(&created);
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
assert!(!created.verification_url.contains(&created.device_token));
|
||||
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.lifetime_seconds, 1200);
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
|
||||
let pending = post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await;
|
||||
assert!(!pending.headers().contains_key("set-cookie"));
|
||||
assert_eq!(pending.headers().get("cache-control").unwrap(), "no-store");
|
||||
let pending: LoginChallengeResponse = test::read_body_json(pending).await;
|
||||
assert_eq!(pending.status, LoginChallengeState::Pending);
|
||||
assert!(pending.maybe_signed_session.is_none());
|
||||
assert_eq!(post(&app, &h, "review", json!({"approval_token": approval}), false).await.status(), StatusCode::UNAUTHORIZED);
|
||||
// Swapping approval and device credentials cannot expose or redeem a session.
|
||||
assert_eq!(post(&app, &h, "poll", json!({"device_token": approval}), false).await.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(post(&app, &h, "review", json!({"approval_token": created.device_token}), true).await.status(), StatusCode::UNAUTHORIZED);
|
||||
let reviewed: ReviewLoginChallengeResponse = test::read_body_json(post(&app, &h, "review", json!({"approval_token": approval}), true).await).await;
|
||||
assert_eq!(reviewed.requesting_ip, h.ip);
|
||||
assert_eq!(reviewed.confirmation_code, created.confirmation_code);
|
||||
assert_eq!(reviewed.username, h.user.username);
|
||||
|
||||
let approved = post(&app, &h, "decide", json!({"approval_token": approval, "approve": true}), true).await;
|
||||
assert!(!approved.headers().contains_key("set-cookie"));
|
||||
let approved: LoginChallengeResponse = test::read_body_json(approved).await;
|
||||
assert_eq!(approved.status, LoginChallengeState::Approved);
|
||||
assert!(approved.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0, "Yes records consent only");
|
||||
|
||||
// Two polls overlap. Both must observe the single committed session.
|
||||
let (first, retry) = tokio::join!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false), post(&app, &h, "poll", json!({"device_token": created.device_token}), false));
|
||||
let cookie = first.response().cookies().find(|c| c.name() == "session").unwrap().into_owned();
|
||||
let first: LoginChallengeResponse = test::read_body_json(first).await;
|
||||
let retry: LoginChallengeResponse = test::read_body_json(retry).await;
|
||||
assert_eq!(first.status, LoginChallengeState::Redeemed);
|
||||
assert_eq!(first.maybe_signed_session, retry.maybe_signed_session, "response loss retries reuse the same session");
|
||||
assert_eq!(first.maybe_signed_session.as_deref(), Some(cookie.value()));
|
||||
assert_ne!(cookie.value(), h.cookie, "never copy the website's existing session");
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 1);
|
||||
assert!(fixtures::audit(&h.db.pool, &hash).await.session_id.is_some());
|
||||
let response = test::call_service(&app, test::TestRequest::get().uri("/protected").cookie(cookie.clone()).to_request()).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let downstream: Value = test::read_body_json(response).await;
|
||||
assert_eq!(downstream["user_token"], h.user.user_token.as_str());
|
||||
|
||||
// The handoff expires in 20 minutes; a successfully issued session keeps its own lifetime.
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, -1).await;
|
||||
let expired: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
|
||||
assert_eq!(expired.maybe_failure_type, Some(LoginChallengeFailure::Expired));
|
||||
assert!(expired.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.status, UserLoginChallengeStatus::Redeemed);
|
||||
assert_eq!(test::call_service(&app, test::TestRequest::get().uri("/protected").cookie(cookie).to_request()).await.status(), StatusCode::OK);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn decline_expiry_and_invalid_consent_never_mint_sessions() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
let approval = approval_token(&created);
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
for origin in [None, Some("https://evil.example"), Some("null")] {
|
||||
let mut request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("session", h.cookie.as_str())).set_json(json!({"approval_token": approval, "approve": true}));
|
||||
if let Some(origin) = origin {
|
||||
request = request.insert_header(("Origin", origin));
|
||||
}
|
||||
assert_eq!(test::call_service(&app, request.to_request()).await.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
let mcp = create_test_mcp_session(&h.db.pool, &h.user.user_token).await.unwrap();
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("Origin", ORIGIN)).insert_header(("Authorization", format!("Bearer {}", mcp.private_session_token.as_str()))).set_json(json!({"approval_token": approval, "approve": true})).to_request();
|
||||
assert_eq!(test::call_service(&app, request).await.status(), StatusCode::UNAUTHORIZED);
|
||||
let declined: LoginChallengeResponse = test::read_body_json(post(&app, &h, "decide", json!({"approval_token": approval, "approve": false}), true).await).await;
|
||||
assert_eq!(declined.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
|
||||
// A later Yes cannot overwrite No.
|
||||
let replay: LoginChallengeResponse = test::read_body_json(post(&app, &h, "decide", json!({"approval_token": approval, "approve": true}), true).await).await;
|
||||
assert_eq!(replay.maybe_failure_type, Some(LoginChallengeFailure::UserDeclined));
|
||||
let rejected: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
|
||||
assert_eq!(rejected.status, LoginChallengeState::Failed);
|
||||
assert!(rejected.maybe_signed_session.is_none());
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(audit.creation_ip, h.ip);
|
||||
assert_eq!(audit.failure_ip.as_deref(), Some(BROWSER_IP));
|
||||
assert_eq!(audit.failure, Some(UserLoginChallengeFailureType::UserDeclined));
|
||||
assert!(audit.session_id.is_none());
|
||||
|
||||
for approve_first in [false, true] {
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
if approve_first {
|
||||
assert_eq!(post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await.status(), StatusCode::OK);
|
||||
}
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, 0).await;
|
||||
let result: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
|
||||
assert_eq!(result.maybe_failure_type, Some(LoginChallengeFailure::Expired));
|
||||
assert!(result.maybe_signed_session.is_none());
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(audit.status, UserLoginChallengeStatus::Failed);
|
||||
assert!(audit.failure_ip.is_none());
|
||||
}
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn expired_revoked_and_banned_sessions_fail_closed() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure).route("/protected", web::get().to(protected))).await;
|
||||
for revoke in [false, true] {
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
|
||||
let redeemed: LoginChallengeResponse = test::read_body_json(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await).await;
|
||||
let signed = redeemed.maybe_signed_session.unwrap();
|
||||
let request = test::TestRequest::default().insert_header(("session", signed.as_str())).to_http_request();
|
||||
let token = h.signer.decode_session_payload_from_request(&request).unwrap().unwrap().session_token;
|
||||
if revoke {
|
||||
fixtures::revoke_session(&h.db.pool, &token).await;
|
||||
} else {
|
||||
fixtures::expire_session(&h.db.pool, &token).await;
|
||||
}
|
||||
assert_eq!(test::call_service(&app, test::TestRequest::get().uri("/protected").insert_header(("session", signed)).to_request()).await.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
let count = fixtures::bridge_session_count(&h.db.pool).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
|
||||
fixtures::ban_user(&h.db.pool, &h.user.user_token).await;
|
||||
assert_eq!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false).await.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, count);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn expiry_is_checked_after_waiting_for_the_redemption_lock() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, 1).await;
|
||||
let mut lock = h.db.pool.begin().await.unwrap();
|
||||
lock_by_device(LockByDeviceArgs {
|
||||
device_hash: &hash,
|
||||
mysql_executor: &mut *lock,
|
||||
}).await.unwrap().unwrap();
|
||||
let (response, _) = tokio::join!(post(&app, &h, "poll", json!({"device_token": created.device_token}), false), async {
|
||||
tokio::time::sleep(Duration::from_millis(1500)).await;
|
||||
lock.commit().await.unwrap();
|
||||
});
|
||||
let result: LoginChallengeResponse = test::read_body_json(response).await;
|
||||
assert_eq!(result.maybe_failure_type, Some(LoginChallengeFailure::Expired));
|
||||
assert!(result.maybe_signed_session.is_none());
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn competing_decisions_are_immutable_and_abandoned_requests_expire() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/create").peer_addr("[2001:db8::42]:1234".parse().unwrap()).set_json(json!({})).to_request();
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(test::call_service(&app, request).await).await;
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.creation_ip, "2001:db8::42");
|
||||
let approval = approval_token(&created);
|
||||
let (yes, no) = tokio::join!(post(&app, &h, "decide", json!({"approval_token": approval, "approve": true}), true), post(&app, &h, "decide", json!({"approval_token": approval, "approve": false}), true));
|
||||
let yes: LoginChallengeResponse = test::read_body_json(yes).await;
|
||||
let no: LoginChallengeResponse = test::read_body_json(no).await;
|
||||
assert_eq!(yes.status, no.status);
|
||||
assert_eq!(yes.maybe_failure_type, no.maybe_failure_type);
|
||||
assert!(matches!(yes.status, LoginChallengeState::Approved | LoginChallengeState::Failed));
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
// Sweep both pending and approved challenges even if the desktop never polls.
|
||||
for approve_first in [false, true] {
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
if approve_first {
|
||||
post(&app, &h, "decide", json!({"approval_token": approval_token(&created), "approve": true}), true).await;
|
||||
}
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
fixtures::set_challenge_deadline(&h.db.pool, &hash, -1).await;
|
||||
assert_eq!(expire_abandoned_challenges(ExpireAbandonedChallengesArgs {
|
||||
mysql_executor: &h.db.pool,
|
||||
}).await.unwrap(), 1);
|
||||
let audit = fixtures::audit(&h.db.pool, &hash).await;
|
||||
assert_eq!(audit.failure, Some(UserLoginChallengeFailureType::Expired));
|
||||
assert!(audit.failure_ip.is_none());
|
||||
assert!(audit.session_id.is_none());
|
||||
}
|
||||
assert_eq!(expire_abandoned_challenges(ExpireAbandonedChallengesArgs {
|
||||
mysql_executor: &h.db.pool,
|
||||
}).await.unwrap(), 0);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
#[cfg_attr(feature = "skip_database_tests", ignore)]
|
||||
async fn impersonated_expired_and_revoked_browser_sessions_cannot_approve() {
|
||||
let h = Harness::create().await;
|
||||
let app = test::init_service(App::new().app_data(web::Data::new(h.db.pool.clone())).app_data(web::Data::new(h.signer.clone())).configure(handlers::configure)).await;
|
||||
let created: CreateLoginChallengeResponse = test::read_body_json(post(&app, &h, "create", json!({}), false).await).await;
|
||||
let approval = approval_token(&created);
|
||||
for mode in ["impersonated", "expired", "revoked"] {
|
||||
let user = create_test_user(&h.db.pool).await.unwrap();
|
||||
match mode {
|
||||
"impersonated" => fixtures::impersonate_session(&h.db.pool, user.session_token.as_str(), &h.user.user_token).await,
|
||||
"expired" => fixtures::expire_session(&h.db.pool, user.session_token.as_str()).await,
|
||||
_ => fixtures::revoke_session(&h.db.pool, user.session_token.as_str()).await,
|
||||
}
|
||||
let cookie = h.signer.create_cookie(&user.session_token, &user.user_token).unwrap();
|
||||
let request = test::TestRequest::post().uri("/v1/login_challenges/decide").insert_header(("Origin", ORIGIN)).cookie(cookie).set_json(json!({"approval_token": approval, "approve": true})).to_request();
|
||||
assert_eq!(test::call_service(&app, request).await.status(), StatusCode::UNAUTHORIZED, "{mode}");
|
||||
}
|
||||
let hash = security::secret_hash(&created.device_token).unwrap();
|
||||
assert_eq!(fixtures::audit(&h.db.pool, &hash).await.status, UserLoginChallengeStatus::Pending);
|
||||
assert_eq!(fixtures::bridge_session_count(&h.db.pool).await, 0);
|
||||
h.db.destroy().await;
|
||||
}
|
||||
|
||||
struct Harness {
|
||||
db: IsolatedTestDatabase,
|
||||
user: TestUser,
|
||||
signer: HttpUserSessionManager,
|
||||
cookie: String,
|
||||
ip: String,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
async fn create() -> Self {
|
||||
static NEXT_IP: AtomicU16 = AtomicU16::new(1);
|
||||
let db = IsolatedTestDatabase::create().await;
|
||||
let user = create_test_user(&db.pool).await.unwrap();
|
||||
let signer = HttpUserSessionManager::new("localhost", "bridge_integration_test_secret").unwrap();
|
||||
let cookie = signer.create_cookie(&user.session_token, &user.user_token).unwrap().value().to_owned();
|
||||
let ip = format!("192.0.2.{}", NEXT_IP.fetch_add(1, Ordering::Relaxed));
|
||||
Self { db, user, signer, cookie, ip }
|
||||
}
|
||||
}
|
||||
|
||||
async fn post<S, B>(app: &S, h: &Harness, action: &str, body: impl Serialize, authenticated: bool) -> ServiceResponse<B>
|
||||
where
|
||||
S: Service<Request, Response = ServiceResponse<B>, Error = actix_web::Error>,
|
||||
B: MessageBody,
|
||||
{
|
||||
let ip = if action == "review" || action == "decide" { BROWSER_IP } else { &h.ip };
|
||||
let mut request = test::TestRequest::post().uri(&format!("/v1/login_challenges/{action}")).insert_header(("Origin", ORIGIN)).peer_addr(format!("{ip}:1234").parse().unwrap()).set_json(body);
|
||||
if authenticated {
|
||||
request = request.insert_header(("session", h.cookie.as_str()));
|
||||
}
|
||||
test::call_service(app, request.to_request()).await
|
||||
}
|
||||
|
||||
fn approval_token(created: &CreateLoginChallengeResponse) -> String {
|
||||
created.verification_url.split_once("#approval_token=").unwrap().1.to_owned()
|
||||
}
|
||||
|
||||
/// Probe the SAME production authentication helper used by protected endpoints,
|
||||
/// with the cookie signer and SQL lookups, rather than merely decoding a JWT.
|
||||
async fn protected(request: HttpRequest, pool: web::Data<MySqlPool>, signer: web::Data<HttpUserSessionManager>) -> Result<web::Json<Value>, CommonWebError> {
|
||||
let checker = SessionChecker::new(&signer);
|
||||
let user = require_user_session(&request, &checker, &**pool).await?;
|
||||
Ok(web::Json(json!({"user_token": user.user_token, "username": user.username})))
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod image_studio;
|
||||
pub mod inference_job;
|
||||
pub mod internal;
|
||||
pub mod mcp_sessions;
|
||||
pub mod login_challenges;
|
||||
pub mod media_files;
|
||||
pub mod misc;
|
||||
pub mod model_download;
|
||||
|
||||
+1
@@ -32,6 +32,7 @@ pub fn add_user_routes<T, B> (app: App<T>) -> App<T>
|
||||
>,
|
||||
{
|
||||
app
|
||||
.configure(crate::http_server::endpoints::login_challenges::handlers::configure)
|
||||
.service(
|
||||
// TODO(bt,2022-11-16): non-/v1/ endpoints are deprecated and subject for future removal
|
||||
web::resource("/create_account")
|
||||
|
||||
@@ -55,6 +55,7 @@ use crate::startup::setup_disabled_endpoints::read_disabled_endpoints;
|
||||
use crate::startup::setup_metrics::build_metrics;
|
||||
use crate::state::server_state::ServerState;
|
||||
use crate::threads::db_health_checker_thread::db_health_checker_thread::db_health_checker_thread;
|
||||
use crate::threads::expire_login_challenges::expire_login_challenges;
|
||||
use crate::threads::poll_ip_banlist_thread::poll_ip_bans;
|
||||
use crate::threads::poll_model_token_info_thread::poll_model_token_info_thread;
|
||||
|
||||
@@ -108,6 +109,7 @@ async fn main() -> AnyhowResult<()> {
|
||||
// ==================== Background threads ==================== //
|
||||
|
||||
let tokio_runtime = Runtime::new()?;
|
||||
tokio_runtime.spawn(expire_login_challenges(server_state.mysql_pool.clone()));
|
||||
|
||||
info!("Spawning pager worker thread.");
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use log::warn;
|
||||
use mysql_queries::queries::user_login_challenges::expire_abandoned_challenges::{expire_abandoned_challenges, ExpireAbandonedChallengesArgs};
|
||||
use sqlx::MySqlPool;
|
||||
|
||||
pub async fn expire_login_challenges(pool: MySqlPool) {
|
||||
loop {
|
||||
if let Err(error) = expire_abandoned_challenges(ExpireAbandonedChallengesArgs {
|
||||
mysql_executor: &pool,
|
||||
}).await {
|
||||
warn!("Login challenge expiration sweep failed: {}", error);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod db_health_checker_thread;
|
||||
pub mod expire_login_challenges;
|
||||
pub mod poll_ip_banlist_thread;
|
||||
pub mod poll_model_token_info_thread;
|
||||
pub mod poll_model_token_info_thread;
|
||||
@@ -2,17 +2,24 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0"
|
||||
></script>
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag("js", new Date());
|
||||
gtag("config", "G-VKW89949V0");
|
||||
if (window.location.pathname === "/login/desktop") {
|
||||
const policy = document.createElement("meta");
|
||||
policy.name = "referrer";
|
||||
policy.content = "no-referrer";
|
||||
document.head.appendChild(policy);
|
||||
} else {
|
||||
const analytics = document.createElement("script");
|
||||
analytics.async = true;
|
||||
analytics.src = "https://www.googletagmanager.com/gtag/js?id=G-VKW89949V0";
|
||||
document.head.appendChild(analytics);
|
||||
gtag("js", new Date());
|
||||
gtag("config", "G-VKW89949V0");
|
||||
}
|
||||
</script>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
|
||||
@@ -48,6 +48,7 @@ import { SignupCtaModal } from "../components/signup-cta-modal";
|
||||
import { InsufficientCreditsModal } from "../components/insufficient-credits-modal";
|
||||
import { GlobalActionReminder } from "../components/global-action-reminder";
|
||||
import { useSession } from "../lib/session";
|
||||
import { DesktopLogin } from "../pages/login/desktop-login";
|
||||
|
||||
function ScrollToTop() {
|
||||
const { pathname } = useLocation();
|
||||
@@ -144,6 +145,7 @@ export function App() {
|
||||
<>
|
||||
<ScrollToTop />
|
||||
<Routes>
|
||||
<Route path="/login/desktop" element={<DesktopLogin />} />
|
||||
{/* Public — no chrome, no auth gate. login/signup share a layout route
|
||||
so the showcase pane (and its video) stays mounted when toggling
|
||||
between them. */}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { AUTH_FORM_PADDING } from "./auth-form-styles";
|
||||
import { ReactNode } from "react";
|
||||
import { Outlet, Navigate, Link } from "react-router-dom";
|
||||
import { Outlet, Navigate, Link, useSearchParams } from "react-router-dom";
|
||||
import { LoaderCircleIcon } from "lucide-react";
|
||||
import { AuthShowcase } from "./auth-showcase";
|
||||
import { AuthPageFrame } from "./auth-page-frame";
|
||||
import { useMediaQuery } from "../ui/use-media-query";
|
||||
import { useSession } from "../../lib/session";
|
||||
import { safeAuthReturnPath } from "../../lib/login-bridge-context";
|
||||
|
||||
/**
|
||||
* Persistent shell for the auth pages. Rendered as a layout route so the
|
||||
@@ -15,14 +16,15 @@ import { useSession } from "../../lib/session";
|
||||
* (AuthHeader) and footer (AuthFooter) into the outlet.
|
||||
*/
|
||||
export const AuthLayout = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
// Only mount the showcase on wide screens (matches the `lg` breakpoint) so
|
||||
// mobile never downloads the demo videos.
|
||||
const showShowcase = useMediaQuery("(min-width: 1024px)");
|
||||
const { loggedIn, authChecked } = useSession();
|
||||
|
||||
// Already signed in? Never show the auth form — go straight home.
|
||||
// Preserve a safe return path when session refresh completes during login.
|
||||
if (loggedIn) {
|
||||
return <Navigate to="/" replace />;
|
||||
return <Navigate to={safeAuthReturnPath(searchParams.get("from"))} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -21,6 +21,8 @@ interface SignupFormProps {
|
||||
signupSource: string;
|
||||
className?: string;
|
||||
autoFocus?: boolean;
|
||||
/** Let the enclosing flow resume after Google, without a billing redirect. */
|
||||
continueAfterGoogleLogin?: boolean;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
@@ -28,6 +30,7 @@ export const SignupForm = ({
|
||||
signupSource,
|
||||
className = "",
|
||||
autoFocus = false,
|
||||
continueAfterGoogleLogin = false,
|
||||
}: SignupFormProps) => {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState("");
|
||||
@@ -84,6 +87,10 @@ export const SignupForm = ({
|
||||
};
|
||||
|
||||
const handleGoogleSuccess = async () => {
|
||||
if (continueAfterGoogleLogin) {
|
||||
onSuccess();
|
||||
return;
|
||||
}
|
||||
// Refresh the session and check the subscription in parallel; users without
|
||||
// an active subscription are pushed to pricing, subscribers go home.
|
||||
const [, subscribed] = await Promise.all([
|
||||
|
||||
@@ -2,13 +2,15 @@
|
||||
//const WINDOWS_VERSION = "0.11.0"; // 2026-03-02 Credits accounting
|
||||
//const WINDOWS_VERSION = "0.14.0"; // 2026-03-10 WorldLabs native, Angles
|
||||
//const WINDOWS_VERSION = "0.20.0"; // 2026-03-24 Reprompt, performance
|
||||
const WINDOWS_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
|
||||
//const WINDOWS_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
|
||||
const WINDOWS_VERSION = "0.41.0";
|
||||
|
||||
//const MAC_VERSION = "0.7.0"; // 2026-02-23 Seedance
|
||||
//const MAC_VERSION = "0.11.0"; // 2026-03-02 Credits accounting
|
||||
//const MAC_VERSION = "0.14.0"; // 2026-03-10 WorldLabs native, Angles
|
||||
//const MAC_VERSION = "0.20.0"; // 2026-03-24 Reprompt, performance
|
||||
const MAC_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
|
||||
//const MAC_VERSION = "0.28.0"; // 2026-04-03 characters + prompt box
|
||||
const MAC_VERSION = "0.41.0";
|
||||
|
||||
export const DOWNLOAD_LINKS = {
|
||||
WINDOWS: `https://github.com/storytold/artcraft/releases/download/artcraft-v${WINDOWS_VERSION}/ArtCraft_${WINDOWS_VERSION}_x64-setup.exe`,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { captureLoginBridgeContext, clearLoginBridgeContext, LOGIN_BRIDGE_PATH, safeAuthReturnPath, authContinuationUrl } from "@frontend/login";
|
||||
@@ -8,6 +8,10 @@ import { captureLandingContext, getReferrer } from "@storyteller/common";
|
||||
import { setOmniGenErrorNotifier } from "@storyteller/omni-gen";
|
||||
import { setToastDelegate } from "@storyteller/ui-toaster";
|
||||
import { toast } from "./components/toast/toast";
|
||||
import { captureLoginBridgeContext, LOGIN_BRIDGE_PATH } from "./lib/login-bridge-context";
|
||||
|
||||
captureLoginBridgeContext();
|
||||
const isLoginBridge = window.location.pathname === LOGIN_BRIDGE_PATH;
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
||||
|
||||
@@ -50,10 +54,10 @@ setToastDelegate({
|
||||
// Persist landing context (referral username, landing URL, referrer) to apex-
|
||||
// domain cookies so attribution survives the getartcraft.com →
|
||||
// app.getartcraft.com hop. First visit wins.
|
||||
captureLandingContext();
|
||||
if (!isLoginBridge) captureLandingContext();
|
||||
|
||||
// Fire-and-forget: log the referral once per browser session
|
||||
if (!sessionStorage.getItem("referral_logged")) {
|
||||
if (!isLoginBridge && !sessionStorage.getItem("referral_logged")) {
|
||||
sessionStorage.setItem("referral_logged", "1");
|
||||
const referrer = getReferrer();
|
||||
new UsersApi()
|
||||
|
||||
@@ -3,14 +3,17 @@ import { ArrowLeftIcon, LoaderCircleIcon, MailIcon } from "lucide-react";
|
||||
import { Button } from "@storyteller/ui-button";
|
||||
import { Input } from "@storyteller/ui-input";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { PasswordResetApi } from "@storyteller/api";
|
||||
|
||||
import { AuthHeader } from "../../components/auth/auth-layout";
|
||||
import { AuthPageFrame } from "../../components/auth/auth-page-frame";
|
||||
import Seo from "../../components/seo";
|
||||
import { authContinuationUrl } from "../../lib/login-bridge-context";
|
||||
|
||||
const ForgotPassword = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const from = searchParams.get("from");
|
||||
const [email, setEmail] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -109,7 +112,7 @@ const ForgotPassword = () => {
|
||||
We've sent a password reset code to <br />
|
||||
<span className="text-white font-medium">{email}</span>
|
||||
</p>
|
||||
<Link to="/forgot-password/verify">
|
||||
<Link to={authContinuationUrl("/forgot-password/verify", from)}>
|
||||
<Button className="w-full justify-center h-10 mb-3">
|
||||
Enter Verification Code
|
||||
</Button>
|
||||
@@ -125,7 +128,7 @@ const ForgotPassword = () => {
|
||||
|
||||
<div className="mt-8 text-center text-sm">
|
||||
<Link
|
||||
to="/login"
|
||||
to={authContinuationUrl("/login", from)}
|
||||
className="text-white/60 hover:text-white transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ArrowLeftIcon /> Back to Log in
|
||||
|
||||
@@ -16,6 +16,8 @@ import { PasswordResetApi, BillingApi } from "@storyteller/api";
|
||||
import { AuthHeader } from "../../components/auth/auth-layout";
|
||||
import { AuthPageFrame } from "../../components/auth/auth-page-frame";
|
||||
import Seo from "../../components/seo";
|
||||
import { authContinuationUrl, LOGIN_BRIDGE_PATH, safeAuthReturnPath } from "../../lib/login-bridge-context";
|
||||
import { refreshSession } from "../../lib/session";
|
||||
|
||||
const VerifyReset = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -76,6 +78,13 @@ const VerifyReset = () => {
|
||||
setSuccess(true);
|
||||
window.dispatchEvent(new Event("auth-change"));
|
||||
|
||||
if (safeAuthReturnPath(searchParams.get("from")) === LOGIN_BRIDGE_PATH) {
|
||||
await refreshSession(true);
|
||||
setRedirectTo(LOGIN_BRIDGE_PATH);
|
||||
setRedirectLabel("Review desktop login");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has an active subscription to decide redirect
|
||||
try {
|
||||
const billingApi = new BillingApi();
|
||||
@@ -235,7 +244,7 @@ const VerifyReset = () => {
|
||||
|
||||
<div className="mt-8 text-center text-sm">
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
to={authContinuationUrl("/forgot-password", searchParams.get("from"))}
|
||||
className="text-white/60 hover:text-white transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<ArrowLeftIcon /> Request a new code
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { DesktopLogin } from "./desktop-login";
|
||||
import { safeAuthReturnPath } from "../../lib/login-bridge-context";
|
||||
|
||||
const { transport, auth } = vi.hoisted(() => ({ transport: vi.fn(), auth: { loggedIn: true } }));
|
||||
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: transport }));
|
||||
vi.mock("../../lib/session", () => ({ useSession: () => ({ loggedIn: auth.loggedIn, authChecked: true }) }));
|
||||
vi.mock("@storyteller/api", async () => ({
|
||||
...(await import("../../../../../libs/api/src/lib/LoginChallengesApi")),
|
||||
HttpApiError: (await import("../../../../../libs/api/src/lib/ApiManager")).HttpApiError,
|
||||
}));
|
||||
|
||||
const APPROVAL_TOKEN = "A".repeat(43);
|
||||
const REVIEW = {
|
||||
success: true, status: "pending", maybe_failure_type: null,
|
||||
confirmation_code: "WDJBMJHT", requesting_ip: "2001:db8::42",
|
||||
username: "google_user", expires_at: new Date(Date.now() + 1_200_000).toISOString(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
auth.loggedIn = true;
|
||||
transport.mockReset();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
window.history.replaceState(null, "", `/login/desktop#approval_token=${APPROVAL_TOKEN}`);
|
||||
transport.mockImplementation(async (url: string, init: RequestInit) => {
|
||||
const body = JSON.parse(String(init.body));
|
||||
if (url.endsWith("/review")) return response(REVIEW);
|
||||
return response({ success: true, status: body.approve ? "approved" : "failed", maybe_failure_type: body.approve ? null : "user_declined" });
|
||||
});
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("webapp login approval integration", () => {
|
||||
it("shows the code and IP, then approves with one explicit button click", async () => {
|
||||
renderPage();
|
||||
expect(await screen.findByText("google_user")).toBeTruthy();
|
||||
expect(screen.getByText("2001:db8::42")).toBeTruthy();
|
||||
expect(screen.getByText("WDJB-MJHT")).toBeTruthy();
|
||||
const approve = screen.getByRole("button", { name: "Approve desktop login" }) as HTMLButtonElement;
|
||||
expect(approve.disabled).toBe(false);
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
expect(window.location.hash).toBe("");
|
||||
fireEvent.click(approve);
|
||||
await screen.findByText("Desktop login approved. Return to ArtCraft to finish signing in.");
|
||||
expect(JSON.parse(transport.mock.calls[1][1].body)).toEqual({ approval_token: APPROVAL_TOKEN, approve: true });
|
||||
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
|
||||
expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBeNull();
|
||||
});
|
||||
|
||||
it("records decline without issuing a session", async () => {
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Decline" }));
|
||||
await screen.findByText("Desktop login declined. No session was created.");
|
||||
expect(JSON.parse(transport.mock.calls[1][1].body).approve).toBe(false);
|
||||
expect(localStorage.getItem("artcraft_signed_session")).toBeNull();
|
||||
});
|
||||
|
||||
it("preserves approval through login without putting a credential in the login URL", async () => {
|
||||
auth.loggedIn = false;
|
||||
const first = renderPage();
|
||||
await screen.findByText("/login?from=%2Flogin%2Fdesktop");
|
||||
expect(transport).not.toHaveBeenCalled();
|
||||
expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBe(APPROVAL_TOKEN);
|
||||
first.unmount();
|
||||
auth.loggedIn = true;
|
||||
renderPage();
|
||||
await screen.findByText("google_user");
|
||||
expect(JSON.parse(transport.mock.calls[0][1].body)).toEqual({ approval_token: APPROVAL_TOKEN });
|
||||
expect(screen.queryByText("Desktop login approved.")).toBeNull();
|
||||
});
|
||||
|
||||
it("prevents approving an expired request", async () => {
|
||||
transport.mockResolvedValue(response({ ...REVIEW, expires_at: new Date(Date.now() - 1000).toISOString() }));
|
||||
renderPage();
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("button", { name: "Approve desktop login" })).toBeNull();
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("shows an invalid challenge without redirecting forever", async () => {
|
||||
transport.mockResolvedValue(new Response("{}", { status: 401 }));
|
||||
renderPage();
|
||||
await screen.findByText(/request or account session is no longer valid/);
|
||||
await waitFor(() => expect(transport).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...REVIEW, success: false },
|
||||
{ ...REVIEW, status: "unknown_future_state" },
|
||||
{ ...REVIEW, expires_at: "invalid-date" },
|
||||
])("does not offer approval for an invalid review: %j", async (review) => {
|
||||
transport.mockImplementation(async () => response(review));
|
||||
renderPage();
|
||||
await screen.findByText(/Unable to load this login request/);
|
||||
expect(screen.queryByRole("button", { name: "Approve desktop login" })).toBeNull();
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retains consent after a lost decision response and allows an idempotent retry", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("google_user");
|
||||
transport.mockRejectedValueOnce(new Error("Network unavailable"));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Approve desktop login" }));
|
||||
await screen.findByText(/Unable to confirm your choice/);
|
||||
expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBe(APPROVAL_TOKEN);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Approve desktop login" }));
|
||||
await screen.findByText(/Desktop login approved/);
|
||||
expect(transport.mock.calls[1][1].body).toBe(transport.mock.calls[2][1].body);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["approved", null, "Desktop login approved"],
|
||||
["redeemed", null, "Desktop login approved"],
|
||||
["failed", "user_declined", "Desktop login declined"],
|
||||
["failed", "expired", "This login request expired"],
|
||||
])("shows terminal %s/%s without offering another decision", async (status, failure, message) => {
|
||||
transport.mockImplementation(async () => response({ ...REVIEW, status, maybe_failure_type: failure }));
|
||||
renderPage();
|
||||
await screen.findByText(new RegExp(message!));
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Approve desktop login" })).toBeNull();
|
||||
expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBeNull();
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects external or protocol-relative authentication return URLs", () => {
|
||||
for (const path of ["https://evil.example", "//evil.example", "/\\evil.example", "/\nevil.example"]) {
|
||||
expect(safeAuthReturnPath(path)).toBe("/");
|
||||
}
|
||||
expect(safeAuthReturnPath("/login/desktop")).toBe("/login/desktop");
|
||||
});
|
||||
});
|
||||
|
||||
function renderPage() {
|
||||
return render(<MemoryRouter initialEntries={["/login/desktop"]}><Routes>
|
||||
<Route path="/login/desktop" element={<DesktopLogin />} />
|
||||
<Route path="/login" element={<LoginLocation />} />
|
||||
</Routes></MemoryRouter>);
|
||||
}
|
||||
|
||||
function LoginLocation() {
|
||||
const location = useLocation();
|
||||
return <p>{location.pathname}{location.search}</p>;
|
||||
}
|
||||
|
||||
function response(body: unknown) {
|
||||
return new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { DesktopLoginApproval, LOGIN_BRIDGE_PATH } from "@frontend/login";
|
||||
import { useSession } from "../../lib/session";
|
||||
|
||||
export function DesktopLogin() {
|
||||
const { loggedIn, authChecked, user } = useSession();
|
||||
return <DesktopLoginApproval
|
||||
key={user?.user_token ?? "signed-out"}
|
||||
loggedIn={loggedIn}
|
||||
authChecked={authChecked}
|
||||
signIn={<Navigate to={`/login?from=${encodeURIComponent(LOGIN_BRIDGE_PATH)}`} replace />}
|
||||
/>;
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
|
||||
import Login from "./login";
|
||||
import { AuthLayout } from "../../components/auth/auth-layout";
|
||||
import Signup from "../signup/signup";
|
||||
import ForgotPassword from "../forgot-password/forgot-password";
|
||||
import VerifyReset from "../forgot-password/verify-reset";
|
||||
|
||||
const { transport, refresh, billing, auth } = vi.hoisted(() => ({
|
||||
transport: vi.fn(), refresh: vi.fn(), billing: vi.fn(), auth: { loggedIn: false },
|
||||
}));
|
||||
vi.mock("@storyteller/tauri-utils", () => ({ FetchProxy: transport }));
|
||||
vi.mock("@storyteller/api", async () => ({
|
||||
...(await import("../../../../../libs/api/src/lib/UsersApi")),
|
||||
...(await import("../../../../../libs/api/src/lib/PasswordResetApi")),
|
||||
}));
|
||||
vi.mock("../../lib/session", () => ({
|
||||
refreshSession: refresh,
|
||||
useSession: () => ({ loggedIn: auth.loggedIn, authChecked: true }),
|
||||
}));
|
||||
vi.mock("../../lib/billing", () => ({ hasActiveSubscription: billing }));
|
||||
vi.mock("../../components/auth", async () => ({
|
||||
...(await import("../../components/auth/GoogleLoginButton")),
|
||||
...(await import("../../components/auth/signup-form")),
|
||||
AuthHeader: () => null,
|
||||
AuthFooter: ({ children }: any) => <div>{children}</div>,
|
||||
}));
|
||||
vi.mock("@react-oauth/google", () => ({ GoogleLogin: ({ onSuccess }: any) => <button onClick={() => onSuccess({ credential: "google_test_credential" })}>Google sign in</button> }));
|
||||
vi.mock("@storyteller/common", () => ({ getLandingUrl: () => undefined, getReferralCode: () => undefined, getReferralUsername: () => undefined, getReferrer: () => undefined }));
|
||||
vi.mock("@storyteller/icons", () => ({ DynamicIcon: () => null }));
|
||||
vi.mock("@storyteller/ui-button", () => ({ Button: ({ children, ...props }: any) => <button {...props}>{children}</button> }));
|
||||
vi.mock("@storyteller/ui-input", () => ({ Input: ({ inputClassName, isError, ...props }: any) => <input {...props} /> }));
|
||||
vi.mock("../../components/seo", () => ({ default: () => null }));
|
||||
vi.mock("../../components/auth/auth-showcase", () => ({ AuthShowcase: () => null }));
|
||||
vi.mock("../../components/auth/auth-page-frame", () => ({ AuthPageFrame: ({ children }: any) => <div>{children}</div> }));
|
||||
vi.mock("../../components/ui/use-media-query", () => ({ useMediaQuery: () => false }));
|
||||
|
||||
beforeEach(() => {
|
||||
auth.loggedIn = false;
|
||||
transport.mockReset();
|
||||
refresh.mockReset().mockResolvedValue(undefined);
|
||||
billing.mockReset().mockResolvedValue(false);
|
||||
transport.mockImplementation(async () => new Response(JSON.stringify({ success: true, signed_session: "signed_web_session" }), { status: 200 }));
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
sessionStorage.setItem("artcraft_pending_desktop_approval", "A".repeat(43));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("webapp authentication bridge continuation", () => {
|
||||
it("returns an unsubscribed Google user to explicit desktop consent without passing the bridge token to SSO", async () => {
|
||||
renderRoutes(<Login />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Google sign in" }));
|
||||
await screen.findByText("/login/desktop");
|
||||
expect(refresh).toHaveBeenCalledWith(true);
|
||||
expect(billing).not.toHaveBeenCalled();
|
||||
expect(JSON.parse(transport.mock.calls[0][1].body)).toEqual({ google_credential: "google_test_credential" });
|
||||
expect(sessionStorage.getItem("artcraft_pending_desktop_approval")).toBe("A".repeat(43));
|
||||
});
|
||||
|
||||
it("preserves the consent destination when the auth layout sees the refreshed session first", async () => {
|
||||
auth.loggedIn = true;
|
||||
renderRoutes(<AuthLayout />);
|
||||
await screen.findByText("/login/desktop");
|
||||
expect(transport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves the destination through signup and Google signup without checking billing", async () => {
|
||||
renderRoutes(<Login />);
|
||||
fireEvent.click(screen.getByRole("link", { name: "Sign up" }));
|
||||
expect(screen.getByRole("link", { name: "Log in" }).getAttribute("href")).toBe("/login?from=%2Flogin%2Fdesktop");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Google sign in" }));
|
||||
await screen.findByText("/login/desktop");
|
||||
expect(refresh).toHaveBeenCalledWith(true);
|
||||
expect(billing).not.toHaveBeenCalled();
|
||||
expect(transport).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns password login to consent", async () => {
|
||||
renderRoutes(<Login />);
|
||||
fireEvent.change(screen.getByPlaceholderText("you@example.com or username"), { target: { value: "user@example.com" } });
|
||||
fireEvent.change(screen.getByPlaceholderText("Min. 8 characters"), { target: { value: "password123" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Log in" }));
|
||||
await screen.findByText("/login/desktop");
|
||||
expect(refresh).toHaveBeenCalledWith(true);
|
||||
expect(billing).not.toHaveBeenCalled();
|
||||
expect(transport.mock.calls[0][1].body).not.toContain("A".repeat(43));
|
||||
});
|
||||
|
||||
it("preserves the destination through the password reset form and redemption", async () => {
|
||||
renderRoutes(<Login />);
|
||||
fireEvent.click(screen.getByRole("link", { name: "Forgot password?" }));
|
||||
expect(screen.getByRole("link", { name: /Back to Log in/ }).getAttribute("href")).toBe("/login?from=%2Flogin%2Fdesktop");
|
||||
fireEvent.change(screen.getByPlaceholderText("you@example.com"), { target: { value: "user@example.com" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send Reset Code" }));
|
||||
fireEvent.click(await screen.findByRole("link", { name: "Enter Verification Code" }));
|
||||
fireEvent.change(screen.getByPlaceholderText("Enter verification code"), { target: { value: "test_reset_code" } });
|
||||
const passwords = screen.getAllByPlaceholderText(/password|characters/i);
|
||||
passwords.forEach((input) => fireEvent.change(input, { target: { value: "password123" } }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Change Password" }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Review desktop login" }));
|
||||
await screen.findByText("/login/desktop");
|
||||
expect(refresh).toHaveBeenCalledWith(true);
|
||||
expect(transport).toHaveBeenCalledTimes(2);
|
||||
expect(billing).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
function renderRoutes(login: React.ReactNode) {
|
||||
return render(<MemoryRouter initialEntries={["/login?from=%2Flogin%2Fdesktop"]}><Routes>
|
||||
<Route path="/login" element={login} />
|
||||
<Route path="/signup" element={<Signup />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/forgot-password/verify" element={<VerifyReset />} />
|
||||
<Route path="*" element={<Location />} />
|
||||
</Routes></MemoryRouter>);
|
||||
}
|
||||
function Location() {
|
||||
return <p>{useLocation().pathname}</p>;
|
||||
}
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
import Seo from "../../components/seo";
|
||||
import { refreshSession } from "../../lib/session";
|
||||
import { hasActiveSubscription } from "../../lib/billing";
|
||||
import { authContinuationUrl, LOGIN_BRIDGE_PATH, safeAuthReturnPath } from "../../lib/login-bridge-context";
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const fromParam = searchParams.get("from");
|
||||
const redirectTo = fromParam && fromParam.startsWith("/") ? fromParam : "/";
|
||||
const redirectTo = safeAuthReturnPath(fromParam);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
@@ -51,6 +52,11 @@ const Login = () => {
|
||||
};
|
||||
|
||||
const handleGoogleSuccess = async () => {
|
||||
if (redirectTo === LOGIN_BRIDGE_PATH) {
|
||||
await refreshSession(true);
|
||||
navigate(redirectTo);
|
||||
return;
|
||||
}
|
||||
// Refresh the session (so the app sees the new cookie) and check the
|
||||
// subscription in parallel; users without one are pushed to pricing.
|
||||
const [, subscribed] = await Promise.all([
|
||||
@@ -105,7 +111,7 @@ const Login = () => {
|
||||
Password
|
||||
</label>
|
||||
<Link
|
||||
to="/forgot-password"
|
||||
to={authContinuationUrl("/forgot-password", fromParam)}
|
||||
className="text-xs text-primary hover:text-primary-400 transition-colors"
|
||||
>
|
||||
Forgot password?
|
||||
@@ -164,7 +170,7 @@ const Login = () => {
|
||||
<AuthFooter>
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
to="/signup"
|
||||
to={authContinuationUrl("/signup", fromParam)}
|
||||
className="font-semibold text-primary transition-colors hover:text-primary-400"
|
||||
>
|
||||
Sign up
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { AuthHeader, AuthFooter, SignupForm } from "../../components/auth";
|
||||
import Seo from "../../components/seo";
|
||||
import { authContinuationUrl, LOGIN_BRIDGE_PATH, safeAuthReturnPath } from "../../lib/login-bridge-context";
|
||||
import { refreshSession } from "../../lib/session";
|
||||
|
||||
const Signup = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const from = searchParams.get("from");
|
||||
const isDesktopLogin = safeAuthReturnPath(from) === LOGIN_BRIDGE_PATH;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -13,7 +18,11 @@ const Signup = () => {
|
||||
/>
|
||||
<AuthHeader title={<>Make it <span className="font-serif-italic">yours.</span></>} subtitle="Create your account and start crafting." />
|
||||
<SignupForm
|
||||
onSuccess={() => navigate("/welcome")}
|
||||
onSuccess={async () => {
|
||||
if (isDesktopLogin) await refreshSession(true);
|
||||
navigate(isDesktopLogin ? LOGIN_BRIDGE_PATH : "/welcome");
|
||||
}}
|
||||
continueAfterGoogleLogin={isDesktopLogin}
|
||||
signupSource="artcraft"
|
||||
/>
|
||||
|
||||
@@ -21,7 +30,7 @@ const Signup = () => {
|
||||
<AuthFooter>
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to="/login"
|
||||
to={authContinuationUrl("/login", from)}
|
||||
className="font-semibold text-primary transition-colors hover:text-primary-400"
|
||||
>
|
||||
Log in
|
||||
|
||||
@@ -10,6 +10,7 @@ module.exports = {
|
||||
"{src,pages,components,app}/**/*!(*.stories|*.spec).{ts,tsx,html}",
|
||||
),
|
||||
...createGlobPatternsForDependencies(__dirname),
|
||||
join(__dirname, "../../libs/login/src/**/*.{ts,tsx}"),
|
||||
"./app/index.html",
|
||||
"./app/src/**/*.{js,jsx,ts,tsx}",
|
||||
"./app/src/*.{js,jsx,ts,tsx}",
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/tsconfig.app.tsbuildinfo",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["dom"],
|
||||
"lib": [
|
||||
"dom"
|
||||
],
|
||||
"types": [
|
||||
"node",
|
||||
"@nx/react/typings/cssmodule.d.ts",
|
||||
@@ -141,6 +143,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/keybinds/tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/login/tsconfig.lib.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -17,12 +17,18 @@
|
||||
{
|
||||
"path": "../../libs/components/moodboard"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/vfx"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/slider-v2"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/toaster"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/login"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/omni-gen"
|
||||
},
|
||||
@@ -95,9 +101,6 @@
|
||||
{
|
||||
"path": "../../libs/icons"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/components/vfx"
|
||||
},
|
||||
{
|
||||
"path": "../../libs/common"
|
||||
},
|
||||
|
||||
@@ -2,3 +2,5 @@ node_modules/
|
||||
.next/
|
||||
next-env.d.ts
|
||||
*.tsbuildinfo
|
||||
playwright-report/
|
||||
test-results/
|
||||
@@ -11,3 +11,13 @@ publish = ".next"
|
||||
|
||||
[[plugins]]
|
||||
package = "@netlify/plugin-nextjs"
|
||||
|
||||
# Share links. The Next runtime serves /media/:token itself (src/app/media);
|
||||
# this only maps the legacy query form the Vite site accepted onto the
|
||||
# canonical route at the edge. Also declared in next.config.ts for local dev.
|
||||
[[redirects]]
|
||||
from = "/media"
|
||||
query = { media = ":token" }
|
||||
to = "/media/:token"
|
||||
status = 301
|
||||
force = true
|
||||
@@ -2,23 +2,39 @@ import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
// Share previews must work from the raw <head>, even for an unknown crawler
|
||||
// or curl. Wait for generateMetadata instead of streaming it into the body.
|
||||
// Media lookups have a five-second timeout; static marketing pages do not
|
||||
// perform that lookup. https://nextjs.org/docs/app/api-reference/config/next-config-js/htmlLimitedBots
|
||||
htmlLimitedBots: /.*/,
|
||||
// Pin the project root: the repo has lockfiles both here and in the Nx
|
||||
// workspace above, and letting Next guess picks the wrong one (breaks
|
||||
// output file tracing on serverless deploys).
|
||||
turbopack: { root: __dirname },
|
||||
outputFileTracingRoot: __dirname,
|
||||
async redirects() {
|
||||
// Legacy Stripe return paths from the Vite site; checkout now lives in
|
||||
// the webapp, which serves the canonical slash forms.
|
||||
return ["checkout_success", "checkout_cancel", "portal_closed"].map(
|
||||
(route) => ({
|
||||
source: `/${route}`,
|
||||
destination: `https://app.getartcraft.com/checkout/${
|
||||
route === "checkout_success" ? "success" : "cancel"
|
||||
}`,
|
||||
permanent: false,
|
||||
}),
|
||||
);
|
||||
return [
|
||||
// Legacy Stripe return paths from the Vite site; checkout now lives in
|
||||
// the webapp, which serves the canonical slash forms.
|
||||
...["checkout_success", "checkout_cancel", "portal_closed"].map(
|
||||
(route) => ({
|
||||
source: `/${route}`,
|
||||
destination: `https://app.getartcraft.com/checkout/${
|
||||
route === "checkout_success" ? "success" : "cancel"
|
||||
}`,
|
||||
permanent: false,
|
||||
}),
|
||||
),
|
||||
// Legacy share-link form (`/media?media=<token>`) from the Vite site;
|
||||
// the canonical route is /media/<token>. Mirrored in netlify.toml so
|
||||
// the edge answers it before the Next runtime.
|
||||
{
|
||||
source: "/media",
|
||||
has: [{ type: "query", key: "media", value: "(?<token>.+)" }],
|
||||
destination: "/media/:token",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
// The hero wall uses WebGL video textures, which need CORS-clean sources.
|
||||
// The showcase CDN sends no Access-Control-Allow-Origin header, so these
|
||||
|
||||
+84
-13
@@ -12,6 +12,7 @@
|
||||
"@react-three/drei": "10.7.7",
|
||||
"@react-three/fiber": "9.6.1",
|
||||
"@react-three/postprocessing": "3.0.4",
|
||||
"@sparkjsdev/spark": "0.1.10",
|
||||
"gsap": "3.15.0",
|
||||
"lenis": "1.3.23",
|
||||
"lucide-react": "^1.31.0",
|
||||
@@ -23,6 +24,7 @@
|
||||
"zustand": "5.0.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.58.2",
|
||||
"@tailwindcss/postcss": "4.3.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
@@ -652,9 +654,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -671,9 +670,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -690,9 +686,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -709,9 +702,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -753,6 +743,23 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.7.tgz",
|
||||
@@ -1126,6 +1133,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
|
||||
"integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.17.8",
|
||||
"@types/webxr": "*",
|
||||
@@ -1195,6 +1203,15 @@
|
||||
"three": ">=0.144.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sparkjsdev/spark": {
|
||||
"version": "0.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@sparkjsdev/spark/-/spark-0.1.10.tgz",
|
||||
"integrity": "sha512-CiijdZQuj7KPDUqIZPiEqyUkJCYo1JqR05vq/V+ElxMwqR7L70ZuZDyIKcasjZHSiPB8pGRMH8HZGqUKO9aRPQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fflate": "^0.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@@ -1508,6 +1525,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
|
||||
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -1518,6 +1536,7 @@
|
||||
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -1542,6 +1561,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz",
|
||||
"integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@dimforge/rapier3d-compat": "~0.12.0",
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
@@ -1762,6 +1782,21 @@
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/glsl-noise": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz",
|
||||
@@ -2316,6 +2351,38 @@
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
@@ -2350,6 +2417,7 @@
|
||||
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.4.tgz",
|
||||
"integrity": "sha512-oAS/PjAbc/xT3OzjUrGsorJ4J064XwhVD2t0OwKLP/E8QwDMUJ0oOv6ZI1SYMtLchv4g0ySEx+JcLy92+48vlA==",
|
||||
"license": "Zlib",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"three": ">= 0.168.0 < 0.186.0"
|
||||
}
|
||||
@@ -2375,6 +2443,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -2384,6 +2453,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -2602,7 +2672,8 @@
|
||||
"version": "0.184.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
|
||||
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/three-mesh-bvh": {
|
||||
"version": "0.8.3",
|
||||
|
||||
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user