From 387cc4472edf6b527dac0ee19ecfa47232321ec7 Mon Sep 17 00:00:00 2001 From: lewis Date: Sun, 4 Jan 2026 14:58:45 +0200 Subject: [PATCH] Functional typesafe backend --- ...6a54823770aee565f4cf5da2fc1f9b89b6bbb.json | 29 + ...7cdf535cd70e09fde0a8a28249df0070eb2fc.json | 22 - ...e2e5feaa9c59c38ec9175568abdacda167107.json | 15 - ...445b65c8cc8c723baca221d85f5e4f2478b99.json | 22 - ...fce2209635090252ac3692823450431d03dc6.json | 22 - ...4abcf8bc8268a348d3be0da9840d1708d20b5.json | 14 - ...79a3c411917144a011f50849b737130b24dbe.json | 54 - ...70b3360a3ac71e649b293efb88d92c3254068.json | 22 - ...813f13f5892f653128469be727b686e6a0f0a.json | 28 - ...294cd58f64b4fb431b54b5deda13d64525e88.json | 28 - ...9580ff03b717586c4ca2d5343709e2dac86b6.json | 22 - ...61ac07a02ffa9a926f94508d7873c4ca07e65.json | 23 - ...92881ba343c73a9a6e513e205c801c5943ec0.json | 28 - ...b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json | 22 - ...d3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json | 22 - ...6f6fba9fe338e4a6a10636b5389d426df1631.json | 22 - ...413743ba168d1e0d8b85566711e54d4048f81.json | 28 - ...94246ead214ca794760d3fe77bb5cf4f27be5.json | 22 - ...2ac0f84c73de8c244ba4560f004ee3f4b7002.json | 28 - ...eadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json | 108 - ...d5171e70fa25e3b8393e142cebcbff752f0f5.json | 34 - ...7b633cec7287c1cc177f0e1d47ec6571564d5.json | 22 - ...cae4825dec587b1387f0fee401458aea2a2e5.json | 60 - ...b29f7abfe18ee6f559bd94ab16274b1cfdfee.json | 22 - ...ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json | 22 - ...a3b40041b043bae57e95002d4bf5df46a4ab4.json | 14 - ...80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json | 22 - ...5a1a627809f444b0faff7e611d985f31b90e9.json | 22 - ...bf8b34ca6a2246c20fc96f76f0e95530762a7.json | 22 - ...fac26435b0feec65cf1c56f851d1c4d6b1814.json | 14 - ...03699bb3a90f39bc9c4c0f738a37827e8f382.json | 28 - src/api/actor/preferences.rs | 122 +- src/api/admin/account/delete.rs | 92 +- src/api/admin/account/email.rs | 45 +- src/api/admin/account/info.rs | 69 +- src/api/admin/account/search.rs | 19 +- src/api/admin/account/update.rs | 111 +- src/api/admin/invite.rs | 51 +- src/api/admin/status.rs | 110 +- src/api/age_assurance.rs | 2 +- src/api/backup.rs | 206 +- src/api/delegation.rs | 389 +--- src/api/error.rs | 572 +++++- src/api/identity/account.rs | 461 +---- src/api/identity/did.rs | 231 +-- src/api/identity/plc/request.rs | 18 +- src/api/identity/plc/sign.rs | 132 +- src/api/identity/plc/submit.rs | 116 +- src/api/mod.rs | 5 + src/api/moderation/mod.rs | 38 +- src/api/notification_prefs.rs | 159 +- src/api/proxy.rs | 56 +- src/api/repo/blob.rs | 130 +- src/api/repo/import.rs | 352 +--- src/api/repo/meta.rs | 28 +- src/api/repo/record/batch.rs | 263 +-- src/api/repo/record/delete.rs | 86 +- src/api/repo/record/read.rs | 125 +- src/api/repo/record/validation.rs | 87 +- src/api/repo/record/write.rs | 311 +-- src/api/responses.rs | 114 ++ src/api/server/account_status.rs | 252 +-- src/api/server/app_password.rs | 36 +- src/api/server/email.rs | 243 +-- src/api/server/invite.rs | 18 +- src/api/server/migration.rs | 32 +- src/api/server/passkey_account.rs | 517 ++--- src/api/server/passkeys.rs | 129 +- src/api/server/password.rs | 269 +-- src/api/server/reauth.rs | 197 +- src/api/server/service_auth.rs | 98 +- src/api/server/session.rs | 437 ++--- src/api/server/signing_key.rs | 8 +- src/api/server/totp.rs | 374 +--- src/api/server/trusted_devices.rs | 128 +- src/api/server/verify_email.rs | 18 +- src/api/server/verify_token.rs | 147 +- src/api/temp.rs | 32 +- src/api/validation.rs | 195 ++ src/api/verification.rs | 6 +- src/appview/mod.rs | 21 +- src/auth/extractor.rs | 51 +- src/auth/mod.rs | 64 +- src/auth/scope_check.rs | 68 +- src/comms/service.rs | 20 +- src/delegation/db.rs | 5 +- src/lib.rs | 8 +- src/main.rs | 10 +- src/oauth/db/device.rs | 5 +- src/oauth/db/mod.rs | 13 +- src/oauth/db/request.rs | 15 +- src/oauth/db/token.rs | 48 +- src/oauth/dpop.rs | 9 +- src/oauth/endpoints/authorize.rs | 180 +- src/oauth/endpoints/delegation.rs | 3 +- src/oauth/endpoints/token/grants.rs | 126 +- src/oauth/endpoints/token/mod.rs | 17 +- src/oauth/endpoints/token/types.rs | 115 +- src/oauth/scopes/parser.rs | 17 +- src/oauth/types.rs | 279 +++ src/oauth/verify.rs | 4 +- src/scheduled.rs | 6 +- src/sync/blob.rs | 44 +- src/sync/commit.rs | 61 +- src/sync/crawl.rs | 7 +- src/sync/deprecated.rs | 56 +- src/sync/frame.rs | 110 +- src/sync/repo.rs | 196 +- src/sync/util.rs | 115 +- src/types.rs | 1738 +++++++++++++++++ src/util.rs | 6 +- src/validation/mod.rs | 54 +- tests/admin_email.rs | 2 - tests/delete_account.rs | 2 +- tests/import_verification.rs | 4 +- tests/lifecycle_record.rs | 2 +- 116 files changed, 5339 insertions(+), 6641 deletions(-) create mode 100644 .sqlx/query-032ac69a52c0baa269988f662516a54823770aee565f4cf5da2fc1f9b89b6bbb.json delete mode 100644 .sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json delete mode 100644 .sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json delete mode 100644 .sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json delete mode 100644 .sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json delete mode 100644 .sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json delete mode 100644 .sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json delete mode 100644 .sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json delete mode 100644 .sqlx/query-4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a.json delete mode 100644 .sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json delete mode 100644 .sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json delete mode 100644 .sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json delete mode 100644 .sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json delete mode 100644 .sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json delete mode 100644 .sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json delete mode 100644 .sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json delete mode 100644 .sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json delete mode 100644 .sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json delete mode 100644 .sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json delete mode 100644 .sqlx/query-9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json delete mode 100644 .sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json delete mode 100644 .sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json delete mode 100644 .sqlx/query-b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5.json delete mode 100644 .sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json delete mode 100644 .sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json delete mode 100644 .sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json delete mode 100644 .sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json delete mode 100644 .sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json delete mode 100644 .sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json delete mode 100644 .sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json delete mode 100644 .sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json create mode 100644 src/api/responses.rs create mode 100644 src/types.rs diff --git a/.sqlx/query-032ac69a52c0baa269988f662516a54823770aee565f4cf5da2fc1f9b89b6bbb.json b/.sqlx/query-032ac69a52c0baa269988f662516a54823770aee565f4cf5da2fc1f9b89b6bbb.json new file mode 100644 index 0000000..f6fc1b7 --- /dev/null +++ b/.sqlx/query-032ac69a52c0baa269988f662516a54823770aee565f4cf5da2fc1f9b89b6bbb.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT trusted_at, trusted_until FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE od.id = $1 AND oad.did = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "trusted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "trusted_until", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "032ac69a52c0baa269988f662516a54823770aee565f4cf5da2fc1f9b89b6bbb" +} diff --git a/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json b/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json deleted file mode 100644 index 15ba9ce..0000000 --- a/.sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT t.token FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc" -} diff --git a/.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json b/.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json deleted file mode 100644 index 734848a..0000000 --- a/.sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE users SET deactivated_at = $1 WHERE did = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Timestamptz", - "Text" - ] - }, - "nullable": [] - }, - "hash": "0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107" -} diff --git a/.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json b/.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json deleted file mode 100644 index 9e5e897..0000000 --- a/.sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "body", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99" -} diff --git a/.sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json b/.sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json deleted file mode 100644 index cf39684..0000000 --- a/.sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM comms_queue WHERE status = 'pending' AND user_id = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6" -} diff --git a/.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json b/.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json deleted file mode 100644 index 8d0d055..0000000 --- a/.sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE users SET password_reset_code_expires_at = NOW() - INTERVAL '1 hour' WHERE email = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5" -} diff --git a/.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json b/.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json deleted file mode 100644 index 7004d69..0000000 --- a/.sqlx/query-4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe.json +++ /dev/null @@ -1,54 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT subject, body, comms_type as \"comms_type: String\" FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "subject", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "body", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "comms_type: String", - "type_info": { - "Custom": { - "name": "comms_type", - "kind": { - "Enum": [ - "welcome", - "email_verification", - "password_reset", - "email_update", - "account_deletion", - "admin_email", - "plc_operation", - "two_factor_code", - "channel_verification", - "passkey_recovery", - "legacy_login_alert", - "migration_verification" - ] - } - } - } - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - true, - false, - false - ] - }, - "hash": "4445cc86cdf04894b340e67661b79a3c411917144a011f50849b737130b24dbe" -} diff --git a/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json b/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json deleted file mode 100644 index b81fee7..0000000 --- a/.sqlx/query-4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT id FROM users WHERE email = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "4560c237741ce9d4166aecd669770b3360a3ac71e649b293efb88d92c3254068" -} diff --git a/.sqlx/query-4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a.json b/.sqlx/query-4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a.json deleted file mode 100644 index 5c9872f..0000000 --- a/.sqlx/query-4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT body, metadata FROM comms_queue WHERE user_id = $1 AND comms_type = 'channel_verification' ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "body", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "metadata", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - true - ] - }, - "hash": "4649e8daefaf4cfefc5cb2de8b3813f13f5892f653128469be727b686e6a0f0a" -} diff --git a/.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json b/.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json deleted file mode 100644 index e232907..0000000 --- a/.sqlx/query-47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT token, expires_at FROM account_deletion_requests WHERE did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "expires_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "47fe4a54857344d8f789f37092a294cd58f64b4fb431b54b5deda13d64525e88" -} diff --git a/.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json b/.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json deleted file mode 100644 index 873f0f7..0000000 --- a/.sqlx/query-49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email_verified FROM users WHERE did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email_verified", - "type_info": "Bool" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "49cbc923cc4a0dcf7dea4ead5ab9580ff03b717586c4ca2d5343709e2dac86b6" -} diff --git a/.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json b/.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json deleted file mode 100644 index 6754294..0000000 --- a/.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT trusted_until FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE od.id = $1 AND oad.did = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "trusted_until", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65" -} diff --git a/.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json b/.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json deleted file mode 100644 index 32bd1aa..0000000 --- a/.sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT k.key_bytes, k.encryption_version\n FROM user_keys k\n JOIN users u ON k.user_id = u.id\n WHERE u.did = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "key_bytes", - "type_info": "Bytea" - }, - { - "ordinal": 1, - "name": "encryption_version", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - true - ] - }, - "hash": "5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0" -} diff --git a/.sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json b/.sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json deleted file mode 100644 index 296bb04..0000000 --- a/.sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT body FROM comms_queue WHERE user_id = $1 AND comms_type = 'email_update' ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "body", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false - ] - }, - "hash": "5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45" -} diff --git a/.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json b/.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json deleted file mode 100644 index af80ee3..0000000 --- a/.sqlx/query-785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT subject FROM comms_queue WHERE user_id = $1 AND comms_type = 'admin_email' AND body = 'Email without subject' LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "subject", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - true - ] - }, - "hash": "785a864944c5939331704c71b0cd3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f" -} diff --git a/.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json b/.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json deleted file mode 100644 index 416f681..0000000 --- a/.sqlx/query-7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT t.token\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "7caa8f9083b15ec1209dda35c4c6f6fba9fe338e4a6a10636b5389d426df1631" -} diff --git a/.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json b/.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json deleted file mode 100644 index 8d5a9b7..0000000 --- a/.sqlx/query-82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT t.token, t.expires_at FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "expires_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "82717b6f61cd79347e1ca7e92c4413743ba168d1e0d8b85566711e54d4048f81" -} diff --git a/.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json b/.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json deleted file mode 100644 index ef52899..0000000 --- a/.sqlx/query-9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT body FROM comms_queue WHERE user_id = (SELECT id FROM users WHERE did = $1) AND comms_type = 'email_verification' ORDER BY created_at DESC LIMIT 1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "body", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "9ad422bf3c43e3cfd86fc88c73594246ead214ca794760d3fe77bb5cf4f27be5" -} diff --git a/.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json b/.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json deleted file mode 100644 index 178bc9e..0000000 --- a/.sqlx/query-9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT did, public_key_did_key FROM reserved_signing_keys WHERE public_key_did_key = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "public_key_did_key", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - true, - false - ] - }, - "hash": "9b035b051769e6b9d45910a8bb42ac0f84c73de8c244ba4560f004ee3f4b7002" -} diff --git a/.sqlx/query-9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json b/.sqlx/query-9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json deleted file mode 100644 index 8bcc4f5..0000000 --- a/.sqlx/query-9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json +++ /dev/null @@ -1,108 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, recipient, subject, body,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\"\n FROM comms_queue\n WHERE id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "recipient", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "subject", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "body", - "type_info": "Text" - }, - { - "ordinal": 5, - "name": "channel: CommsChannel", - "type_info": { - "Custom": { - "name": "comms_channel", - "kind": { - "Enum": [ - "email", - "discord", - "telegram", - "signal" - ] - } - } - } - }, - { - "ordinal": 6, - "name": "comms_type: CommsType", - "type_info": { - "Custom": { - "name": "comms_type", - "kind": { - "Enum": [ - "welcome", - "email_verification", - "password_reset", - "email_update", - "account_deletion", - "admin_email", - "plc_operation", - "two_factor_code", - "channel_verification", - "passkey_recovery", - "legacy_login_alert", - "migration_verification" - ] - } - } - } - }, - { - "ordinal": 7, - "name": "status: CommsStatus", - "type_info": { - "Custom": { - "name": "comms_status", - "kind": { - "Enum": [ - "pending", - "processing", - "sent", - "failed" - ] - } - } - } - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - false, - false, - true, - false, - false, - false, - false - ] - }, - "hash": "9e772a967607553a0ab800970eaeadcaab7e06bdb79e0c89eb919b1bc1d6fabe" -} diff --git a/.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json b/.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json deleted file mode 100644 index 2640f70..0000000 --- a/.sqlx/query-a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT private_key_bytes, expires_at, used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "private_key_bytes", - "type_info": "Bytea" - }, - { - "ordinal": 1, - "name": "expires_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "used_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "a23a390659616779d7dbceaa3b5d5171e70fa25e3b8393e142cebcbff752f0f5" -} diff --git a/.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json b/.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json deleted file mode 100644 index f10b2bc..0000000 --- a/.sqlx/query-a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT token FROM account_deletion_requests WHERE did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "token", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "a802d7d860f263eace39ce82bb27b633cec7287c1cc177f0e1d47ec6571564d5" -} diff --git a/.sqlx/query-b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5.json b/.sqlx/query-b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5.json deleted file mode 100644 index 0297c9a..0000000 --- a/.sqlx/query-b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n recipient, subject, body,\n comms_type as \"comms_type: CommsType\"\n FROM comms_queue\n WHERE id = $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "recipient", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "subject", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "body", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "comms_type: CommsType", - "type_info": { - "Custom": { - "name": "comms_type", - "kind": { - "Enum": [ - "welcome", - "email_verification", - "password_reset", - "email_update", - "account_deletion", - "admin_email", - "plc_operation", - "two_factor_code", - "channel_verification", - "passkey_recovery", - "legacy_login_alert", - "migration_verification" - ] - } - } - } - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - false, - true, - false, - false - ] - }, - "hash": "b0fca342e85dea89a06b4fee144cae4825dec587b1387f0fee401458aea2a2e5" -} diff --git a/.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json b/.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json deleted file mode 100644 index 6b53208..0000000 --- a/.sqlx/query-cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT password_reset_code FROM users WHERE email = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "password_reset_code", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "cd3b8098ad4c1056c1d23acd8a6b29f7abfe18ee6f559bd94ab16274b1cfdfee" -} diff --git a/.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json b/.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json deleted file mode 100644 index 08e8e23..0000000 --- a/.sqlx/query-cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) as \"count!\" FROM plc_operation_tokens t JOIN users u ON t.user_id = u.id WHERE u.did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count!", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - null - ] - }, - "hash": "cda68f9b6c60295a196fc853b70ec5fd51a8ffaa2bac5942c115c99d1cbcafa3" -} diff --git a/.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json b/.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json deleted file mode 100644 index be751e8..0000000 --- a/.sqlx/query-d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE account_deletion_requests SET expires_at = NOW() - INTERVAL '1 hour' WHERE token = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "d529d6dc9858c1da360f0417e94a3b40041b043bae57e95002d4bf5df46a4ab4" -} diff --git a/.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json b/.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json deleted file mode 100644 index 0576d91..0000000 --- a/.sqlx/query-e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM comms_queue WHERE user_id = $1 AND comms_type = 'password_reset'", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Uuid" - ] - }, - "nullable": [ - null - ] - }, - "hash": "e20cbe2a939d790aaea718b084a80d8ede655ba1cc0fd4346d7e91d6de7d6cf3" -} diff --git a/.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json b/.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json deleted file mode 100644 index edb2f4b..0000000 --- a/.sqlx/query-e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT used_at FROM reserved_signing_keys WHERE public_key_did_key = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "used_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "e64cd36284d10ab7f3d9f6959975a1a627809f444b0faff7e611d985f31b90e9" -} diff --git a/.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json b/.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json deleted file mode 100644 index e8c8434..0000000 --- a/.sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT email FROM users WHERE did = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "email", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - true - ] - }, - "hash": "f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7" -} diff --git a/.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json b/.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json deleted file mode 100644 index db8bde6..0000000 --- a/.sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE users SET is_admin = TRUE WHERE did = $1", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [] - }, - "hash": "f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814" -} diff --git a/.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json b/.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json deleted file mode 100644 index 32320a7..0000000 --- a/.sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT password_reset_code, password_reset_code_expires_at FROM users WHERE email = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "password_reset_code", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "password_reset_code_expires_at", - "type_info": "Timestamptz" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - true, - true - ] - }, - "hash": "f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382" -} diff --git a/src/api/actor/preferences.rs b/src/api/actor/preferences.rs index c443693..c72d335 100644 --- a/src/api/actor/preferences.rs +++ b/src/api/actor/preferences.rs @@ -1,3 +1,4 @@ +use crate::api::error::ApiError; use crate::state::AppState; use axum::{ Json, @@ -7,7 +8,7 @@ use axum::{ }; use chrono::{Datelike, NaiveDate, Utc}; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::Value; const APP_BSKY_NAMESPACE: &str = "app.bsky"; const MAX_PREFERENCES_COUNT: usize = 100; @@ -39,37 +40,25 @@ pub async fn get_preferences( ) { Some(t) => t, None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); + return ApiError::AuthenticationRequired.into_response(); } }; let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { Ok(user) => user, Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); + return ApiError::AuthenticationFailed(None).into_response(); } }; let has_full_access = auth_user.permissions().has_full_access(); let user_id: uuid::Uuid = - match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did) + match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", &*auth_user.did) .fetch_optional(&state.db) .await { Ok(Some(id)) => id, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "User not found"})), - ) - .into_response(); + return ApiError::InternalError(Some("User not found".into())).into_response(); } }; let prefs_result = sqlx::query!( @@ -81,11 +70,7 @@ pub async fn get_preferences( let prefs = match prefs_result { Ok(rows) => rows, Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to fetch preferences"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to fetch preferences".into())).into_response(); } }; let mut personal_details_pref: Option = None; @@ -114,7 +99,7 @@ pub async fn get_preferences( .and_then(|v| v.as_str()) .and_then(get_age_from_datestring) { - let declared_age_pref = json!({ + let declared_age_pref = serde_json::json!({ "$type": DECLARED_AGE_PREF, "isOverAge13": age >= 13, "isOverAge16": age >= 16, @@ -139,92 +124,75 @@ pub async fn put_preferences( ) { Some(t) => t, None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); + return ApiError::AuthenticationRequired.into_response(); } }; let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { Ok(user) => user, Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); + return ApiError::AuthenticationFailed(None).into_response(); } }; let has_full_access = auth_user.permissions().has_full_access(); let user_id: uuid::Uuid = - match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did) + match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", &*auth_user.did) .fetch_optional(&state.db) .await { Ok(Some(id)) => id, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "User not found"})), - ) - .into_response(); + return ApiError::InternalError(Some("User not found".into())).into_response(); } }; if input.preferences.len() > MAX_PREFERENCES_COUNT { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": format!("Too many preferences: {} exceeds limit of {}", input.preferences.len(), MAX_PREFERENCES_COUNT)})), - ) - .into_response(); + return ApiError::InvalidRequest(format!( + "Too many preferences: {} exceeds limit of {}", + input.preferences.len(), + MAX_PREFERENCES_COUNT + )) + .into_response(); } let mut forbidden_prefs: Vec = Vec::new(); for pref in &input.preferences { let pref_str = serde_json::to_string(pref).unwrap_or_default(); if pref_str.len() > MAX_PREFERENCE_SIZE { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": format!("Preference too large: {} bytes exceeds limit of {}", pref_str.len(), MAX_PREFERENCE_SIZE)})), - ) - .into_response(); + return ApiError::InvalidRequest(format!( + "Preference too large: {} bytes exceeds limit of {}", + pref_str.len(), + MAX_PREFERENCE_SIZE + )) + .into_response(); } let pref_type = match pref.get("$type").and_then(|t| t.as_str()) { Some(t) => t, None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Preference is missing a $type"})), - ) + return ApiError::InvalidRequest("Preference is missing a $type".into()) .into_response(); } }; if !pref_type.starts_with(APP_BSKY_NAMESPACE) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": format!("Some preferences are not in the {} namespace", APP_BSKY_NAMESPACE)})), - ) - .into_response(); + return ApiError::InvalidRequest(format!( + "Some preferences are not in the {} namespace", + APP_BSKY_NAMESPACE + )) + .into_response(); } if pref_type == PERSONAL_DETAILS_PREF && !has_full_access { forbidden_prefs.push(pref_type.to_string()); } } if !forbidden_prefs.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": format!("Do not have authorization to set preferences: {}", forbidden_prefs.join(", "))})), - ) - .into_response(); + return ApiError::InvalidRequest(format!( + "Do not have authorization to set preferences: {}", + forbidden_prefs.join(", ") + )) + .into_response(); } let mut tx = match state.db.begin().await { Ok(tx) => tx, Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to start transaction"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to start transaction".into())).into_response(); } }; let delete_result = sqlx::query!( @@ -237,11 +205,7 @@ pub async fn put_preferences( .await; if delete_result.is_err() { let _ = tx.rollback().await; - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to clear preferences"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to clear preferences".into())).into_response(); } for pref in input.preferences { let pref_type = match pref.get("$type").and_then(|t| t.as_str()) { @@ -261,19 +225,11 @@ pub async fn put_preferences( .await; if insert_result.is_err() { let _ = tx.rollback().await; - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to save preference"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to save preference".into())).into_response(); } } if tx.commit().await.is_err() { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to commit transaction"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to commit transaction".into())).into_response(); } StatusCode::OK.into_response() } diff --git a/src/api/admin/account/delete.rs b/src/api/admin/account/delete.rs index 43904d5..352c128 100644 --- a/src/api/admin/account/delete.rs +++ b/src/api/admin/account/delete.rs @@ -1,18 +1,19 @@ +use crate::api::error::ApiError; +use crate::api::EmptyResponse; use crate::auth::BearerAuthAdmin; use crate::state::AppState; +use crate::types::Did; use axum::{ Json, extract::State, - http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; -use serde_json::json; use tracing::{error, warn}; #[derive(Deserialize)] pub struct DeleteAccountInput { - pub did: String, + pub did: Did, } pub async fn delete_account( @@ -20,58 +21,35 @@ pub async fn delete_account( _auth: BearerAuthAdmin, Json(input): Json, ) -> Response { - let did = input.did.trim(); - if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); - } - let user = sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) + let did = &input.did; + let user = sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did.as_str()) .fetch_optional(&state.db) .await; let (user_id, handle) = match user { Ok(Some(row)) => (row.id, row.handle), Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error in delete_account: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let mut tx = match state.db.begin().await { Ok(tx) => tx, Err(e) => { error!("Failed to begin transaction for account deletion: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; - if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did) + if let Err(e) = sqlx::query!("DELETE FROM session_tokens WHERE did = $1", did.as_str()) .execute(&mut *tx) .await { error!("Failed to delete session tokens for {}: {:?}", did, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to delete session tokens"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to delete session tokens".into())).into_response(); } - if let Err(e) = sqlx::query!("DELETE FROM used_refresh_tokens WHERE session_id IN (SELECT id FROM session_tokens WHERE did = $1)", did) + if let Err(e) = sqlx::query!("DELETE FROM used_refresh_tokens WHERE session_id IN (SELECT id FROM session_tokens WHERE did = $1)", did.as_str()) .execute(&mut *tx) .await { @@ -82,33 +60,21 @@ pub async fn delete_account( .await { error!("Failed to delete records for user {}: {:?}", user_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to delete records"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to delete records".into())).into_response(); } if let Err(e) = sqlx::query!("DELETE FROM repos WHERE user_id = $1", user_id) .execute(&mut *tx) .await { error!("Failed to delete repos for user {}: {:?}", user_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to delete repos"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to delete repos".into())).into_response(); } if let Err(e) = sqlx::query!("DELETE FROM blobs WHERE created_by_user = $1", user_id) .execute(&mut *tx) .await { error!("Failed to delete blobs for user {}: {:?}", user_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to delete blobs"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to delete blobs".into())).into_response(); } if let Err(e) = sqlx::query!("DELETE FROM app_passwords WHERE user_id = $1", user_id) .execute(&mut *tx) @@ -118,11 +84,7 @@ pub async fn delete_account( "Failed to delete app passwords for user {}: {:?}", user_id, e ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to delete app passwords"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to delete app passwords".into())).into_response(); } if let Err(e) = sqlx::query!( "DELETE FROM invite_code_uses WHERE used_by_user = $1", @@ -153,33 +115,21 @@ pub async fn delete_account( .await { error!("Failed to delete user keys for user {}: {:?}", user_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to delete user keys"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to delete user keys".into())).into_response(); } if let Err(e) = sqlx::query!("DELETE FROM users WHERE id = $1", user_id) .execute(&mut *tx) .await { error!("Failed to delete user {}: {:?}", user_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to delete user"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to delete user".into())).into_response(); } if let Err(e) = tx.commit().await { error!("Failed to commit account deletion transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to commit deletion"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to commit deletion".into())).into_response(); } if let Err(e) = - crate::api::repo::record::sequence_account_event(&state, did, false, Some("deleted")).await + crate::api::repo::record::sequence_account_event(&state, did.as_str(), false, Some("deleted")).await { warn!( "Failed to sequence account deletion event for {}: {}", @@ -187,5 +137,5 @@ pub async fn delete_account( ); } let _ = state.cache.delete(&format!("handle:{}", handle)).await; - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } diff --git a/src/api/admin/account/email.rs b/src/api/admin/account/email.rs index 756d73a..46af1b3 100644 --- a/src/api/admin/account/email.rs +++ b/src/api/admin/account/email.rs @@ -1,5 +1,7 @@ +use crate::api::error::{ApiError, AtpJson}; use crate::auth::BearerAuthAdmin; use crate::state::AppState; +use crate::types::Did; use axum::{ Json, extract::State, @@ -7,14 +9,13 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; -use serde_json::json; use tracing::{error, warn}; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct SendEmailInput { - pub recipient_did: String, - pub sender_did: String, + pub recipient_did: Did, + pub sender_did: Did, pub content: String, pub subject: Option, pub comment: Option, @@ -28,27 +29,15 @@ pub struct SendEmailOutput { pub async fn send_email( State(state): State, _auth: BearerAuthAdmin, - Json(input): Json, + AtpJson(input): AtpJson, ) -> Response { - let recipient_did = input.recipient_did.trim(); let content = input.content.trim(); - if recipient_did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "recipientDid is required"})), - ) - .into_response(); - } if content.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "content is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("content is required".into()).into_response(); } let user = sqlx::query!( "SELECT id, email, handle FROM users WHERE did = $1", - recipient_did + input.recipient_did.as_str() ) .fetch_optional(&state.db) .await; @@ -57,29 +46,17 @@ pub async fn send_email( let email = match row.email { Some(e) => e, None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "NoEmail", "message": "Recipient has no email address"})), - ) - .into_response(); + return ApiError::NoEmail.into_response(); } }; (row.id, email, row.handle) } Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Recipient account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error in send_email: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); @@ -97,7 +74,7 @@ pub async fn send_email( let result = crate::comms::enqueue_comms(&state.db, item).await; match result { Ok(_) => { - tracing::info!("Admin email queued for {} ({})", handle, recipient_did); + tracing::info!("Admin email queued for {} ({})", handle, input.recipient_did); (StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response() } Err(e) => { diff --git a/src/api/admin/account/info.rs b/src/api/admin/account/info.rs index 55fd6e8..c255bf2 100644 --- a/src/api/admin/account/info.rs +++ b/src/api/admin/account/info.rs @@ -1,5 +1,7 @@ +use crate::api::error::ApiError; use crate::auth::BearerAuthAdmin; use crate::state::AppState; +use crate::types::{Did, Handle}; use axum::{ Json, extract::{Query, RawQuery, State}, @@ -7,19 +9,18 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; -use serde_json::json; use tracing::error; #[derive(Deserialize)] pub struct GetAccountInfoParams { - pub did: String, + pub did: Did, } #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct AccountInfo { - pub did: String, - pub handle: String, + pub did: Did, + pub handle: Handle, #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, pub indexed_at: String, @@ -42,8 +43,8 @@ pub struct InviteCodeInfo { pub code: String, pub available: i32, pub disabled: bool, - pub for_account: String, - pub created_by: String, + pub for_account: Did, + pub created_by: Did, pub created_at: String, pub uses: Vec, } @@ -51,7 +52,7 @@ pub struct InviteCodeInfo { #[derive(Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct InviteCodeUseInfo { - pub used_by: String, + pub used_by: Did, pub used_at: String, } @@ -66,21 +67,13 @@ pub async fn get_account_info( _auth: BearerAuthAdmin, Query(params): Query, ) -> Response { - let did = params.did.trim(); - if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); - } let result = sqlx::query!( r#" SELECT id, did, handle, email, created_at, invites_disabled, email_verified, deactivated_at FROM users WHERE did = $1 "#, - did + params.did.as_str() ) .fetch_optional(&state.db) .await; @@ -91,8 +84,8 @@ pub async fn get_account_info( ( StatusCode::OK, Json(AccountInfo { - did: row.did, - handle: row.handle, + did: row.did.into(), + handle: row.handle.into(), email: row.email, indexed_at: row.created_at.to_rfc3339(), invite_note: None, @@ -109,18 +102,10 @@ pub async fn get_account_info( ) .into_response() } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(), + Ok(None) => ApiError::AccountNotFound.into_response(), Err(e) => { error!("DB error in get_account_info: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -199,13 +184,13 @@ async fn get_invite_code_info(db: &sqlx::PgPool, code: &str) -> Option rows, Err(e) => { error!("Failed to fetch account infos: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -306,7 +283,7 @@ pub async fn get_account_infos( .entry(u.code.clone()) .or_default() .push(InviteCodeUseInfo { - used_by: u.used_by, + used_by: u.used_by.into(), used_at: u.used_at.to_rfc3339(), }); } @@ -320,8 +297,8 @@ pub async fn get_account_infos( code: ic.code.clone(), available: ic.available_uses, disabled: ic.disabled.unwrap_or(false), - for_account: ic.for_account, - created_by: ic.created_by, + for_account: ic.for_account.into(), + created_by: ic.created_by.into(), created_at: ic.created_at.to_rfc3339(), uses: uses_by_code.get(&ic.code).cloned().unwrap_or_default(), }; @@ -339,8 +316,8 @@ pub async fn get_account_infos( .and_then(|code| code_info_map.get(code).cloned()); let invites = codes_by_user.get(&row.id).cloned(); infos.push(AccountInfo { - did: row.did, - handle: row.handle, + did: row.did.into(), + handle: row.handle.into(), email: row.email, indexed_at: row.created_at.to_rfc3339(), invite_note: None, diff --git a/src/api/admin/account/search.rs b/src/api/admin/account/search.rs index e5d8d02..27b6e2b 100644 --- a/src/api/admin/account/search.rs +++ b/src/api/admin/account/search.rs @@ -1,5 +1,7 @@ +use crate::api::error::ApiError; use crate::auth::BearerAuthAdmin; use crate::state::AppState; +use crate::types::{Did, Handle}; use axum::{ Json, extract::{Query, State}, @@ -7,7 +9,6 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; -use serde_json::json; use tracing::error; #[derive(Deserialize)] @@ -26,8 +27,8 @@ fn default_limit() -> i64 { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct AccountView { - pub did: String, - pub handle: String, + pub did: Did, + pub handle: Handle, #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, pub indexed_at: String, @@ -101,8 +102,8 @@ pub async fn search_accounts( invites_disabled, )| { AccountView { - did: did.clone(), - handle, + did: did.clone().into(), + handle: handle.into(), email, indexed_at: created_at.to_rfc3339(), email_confirmed_at: if email_verified { @@ -117,7 +118,7 @@ pub async fn search_accounts( ) .collect(); let next_cursor = if has_more { - accounts.last().map(|a| a.did.clone()) + accounts.last().map(|a| a.did.to_string()) } else { None }; @@ -132,11 +133,7 @@ pub async fn search_accounts( } Err(e) => { error!("DB error in search_accounts: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } diff --git a/src/api/admin/account/update.rs b/src/api/admin/account/update.rs index 314f57d..dc126ad 100644 --- a/src/api/admin/account/update.rs +++ b/src/api/admin/account/update.rs @@ -1,13 +1,14 @@ +use crate::api::error::ApiError; +use crate::api::EmptyResponse; use crate::auth::BearerAuthAdmin; use crate::state::AppState; +use crate::types::{Did, PlainPassword}; use axum::{ Json, extract::State, - http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; -use serde_json::json; use tracing::{error, warn}; #[derive(Deserialize)] @@ -24,11 +25,7 @@ pub async fn update_account_email( let account = input.account.trim(); let email = input.email.trim(); if account.is_empty() || email.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "account and email are required"})), - ) - .into_response(); + return ApiError::InvalidRequest("account and email are required".into()).into_response(); } let result = sqlx::query!("UPDATE users SET email = $1 WHERE did = $2", email, account) .execute(&state.db) @@ -36,28 +33,20 @@ pub async fn update_account_email( match result { Ok(r) => { if r.rows_affected() == 0 { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } Err(e) => { error!("DB error updating email: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } #[derive(Deserialize)] pub struct UpdateAccountHandleInput { - pub did: String, + pub did: Did, pub handle: String, } @@ -66,26 +55,16 @@ pub async fn update_account_handle( _auth: BearerAuthAdmin, Json(input): Json, ) -> Response { - let did = input.did.trim(); + let did = &input.did; let input_handle = input.handle.trim(); - if did.is_empty() || input_handle.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did and handle are required"})), - ) - .into_response(); + if input_handle.is_empty() { + return ApiError::InvalidRequest("handle is required".into()).into_response(); } if !input_handle .chars() .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_') { - return ( - StatusCode::BAD_REQUEST, - Json( - json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}), - ), - ) - .into_response(); + return ApiError::InvalidHandle(None).into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let handle = if !input_handle.contains('.') { @@ -93,7 +72,7 @@ pub async fn update_account_handle( } else { input_handle.to_string() }; - let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) + let old_handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did.as_str()) .fetch_optional(&state.db) .await .ok() @@ -101,62 +80,50 @@ pub async fn update_account_handle( let existing = sqlx::query!( "SELECT id FROM users WHERE handle = $1 AND did != $2", handle, - did + did.as_str() ) .fetch_optional(&state.db) .await; if let Ok(Some(_)) = existing { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "HandleTaken", "message": "Handle is already in use"})), - ) - .into_response(); + return ApiError::HandleTaken.into_response(); } - let result = sqlx::query!("UPDATE users SET handle = $1 WHERE did = $2", handle, did) + let result = sqlx::query!("UPDATE users SET handle = $1 WHERE did = $2", handle, did.as_str()) .execute(&state.db) .await; match result { Ok(r) => { if r.rows_affected() == 0 { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } if let Some(old) = old_handle { let _ = state.cache.delete(&format!("handle:{}", old)).await; } let _ = state.cache.delete(&format!("handle:{}", handle)).await; if let Err(e) = - crate::api::repo::record::sequence_identity_event(&state, did, Some(&handle)).await + crate::api::repo::record::sequence_identity_event(&state, did.as_str(), Some(&handle)).await { warn!( "Failed to sequence identity event for admin handle update: {}", e ); } - if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did, &handle).await + if let Err(e) = crate::api::identity::did::update_plc_handle(&state, did.as_str(), &handle).await { warn!("Failed to update PLC handle for admin handle update: {}", e); } - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } Err(e) => { error!("DB error updating handle: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } #[derive(Deserialize)] pub struct UpdateAccountPasswordInput { - pub did: String, - pub password: String, + pub did: Did, + pub password: PlainPassword, } pub async fn update_account_password( @@ -164,51 +131,35 @@ pub async fn update_account_password( _auth: BearerAuthAdmin, Json(input): Json, ) -> Response { - let did = input.did.trim(); + let did = &input.did; let password = input.password.trim(); - if did.is_empty() || password.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did and password are required"})), - ) - .into_response(); + if password.is_empty() { + return ApiError::InvalidRequest("password is required".into()).into_response(); } let password_hash = match bcrypt::hash(password, bcrypt::DEFAULT_COST) { Ok(h) => h, Err(e) => { error!("Failed to hash password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let result = sqlx::query!( "UPDATE users SET password_hash = $1 WHERE did = $2", password_hash, - did + did.as_str() ) .execute(&state.db) .await; match result { Ok(r) => { if r.rows_affected() == 0 { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } Err(e) => { error!("DB error updating password: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } diff --git a/src/api/admin/invite.rs b/src/api/admin/invite.rs index 351ca4d..7c5c710 100644 --- a/src/api/admin/invite.rs +++ b/src/api/admin/invite.rs @@ -1,3 +1,5 @@ +use crate::api::EmptyResponse; +use crate::api::error::ApiError; use crate::auth::BearerAuthAdmin; use crate::state::AppState; use axum::{ @@ -7,7 +9,6 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; -use serde_json::json; use tracing::error; #[derive(Deserialize)] @@ -47,7 +48,7 @@ pub async fn disable_invite_codes( } } } - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } #[derive(Deserialize)] @@ -145,11 +146,7 @@ pub async fn get_invite_codes( Ok(rows) => rows, Err(e) => { error!("DB error fetching invite codes: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let mut codes = Vec::new(); @@ -220,11 +217,7 @@ pub async fn disable_account_invites( ) -> Response { let account = input.account.trim(); if account.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "account is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("account is required".into()).into_response(); } let result = sqlx::query!( "UPDATE users SET invites_disabled = TRUE WHERE did = $1", @@ -235,21 +228,13 @@ pub async fn disable_account_invites( match result { Ok(r) => { if r.rows_affected() == 0 { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } Err(e) => { error!("DB error disabling account invites: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -266,11 +251,7 @@ pub async fn enable_account_invites( ) -> Response { let account = input.account.trim(); if account.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "account is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("account is required".into()).into_response(); } let result = sqlx::query!( "UPDATE users SET invites_disabled = FALSE WHERE did = $1", @@ -281,21 +262,13 @@ pub async fn enable_account_invites( match result { Ok(r) => { if r.rows_affected() == 0 { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } Err(e) => { error!("DB error enabling account invites: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } diff --git a/src/api/admin/status.rs b/src/api/admin/status.rs index b414cc7..876f55e 100644 --- a/src/api/admin/status.rs +++ b/src/api/admin/status.rs @@ -1,3 +1,4 @@ +use crate::api::error::ApiError; use crate::auth::BearerAuthAdmin; use crate::state::AppState; use axum::{ @@ -37,11 +38,7 @@ pub async fn get_subject_status( Query(params): Query, ) -> Response { if params.did.is_none() && params.uri.is_none() && params.blob.is_none() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Must provide did, uri, or blob"})), - ) - .into_response(); + return ApiError::InvalidRequest("Must provide did, uri, or blob".into()).into_response(); } if let Some(did) = ¶ms.did { let user = sqlx::query!( @@ -74,19 +71,11 @@ pub async fn get_subject_status( .into_response(); } Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})), - ) - .into_response(); + return ApiError::SubjectNotFound.into_response(); } Err(e) => { error!("DB error in get_subject_status: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } } @@ -118,19 +107,11 @@ pub async fn get_subject_status( .into_response(); } Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})), - ) - .into_response(); + return ApiError::RecordNotFound.into_response(); } Err(e) => { error!("DB error in get_subject_status: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } } @@ -138,11 +119,10 @@ pub async fn get_subject_status( let did = match ¶ms.did { Some(d) => d, None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Must provide a did to request blob state"})), + return ApiError::InvalidRequest( + "Must provide a did to request blob state".into(), ) - .into_response(); + .into_response(); } }; let blob = sqlx::query!( @@ -172,27 +152,15 @@ pub async fn get_subject_status( .into_response(); } Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "SubjectNotFound", "message": "Subject not found"})), - ) - .into_response(); + return ApiError::BlobNotFound(None).into_response(); } Err(e) => { error!("DB error in get_subject_status: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } } - ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Invalid subject type"})), - ) - .into_response() + ApiError::InvalidRequest("Invalid subject type".into()).into_response() } #[derive(Deserialize)] @@ -223,11 +191,7 @@ pub async fn update_subject_status( Ok(tx) => tx, Err(e) => { error!("Failed to begin transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if let Some(takedown) = &input.takedown { @@ -245,11 +209,10 @@ pub async fn update_subject_status( .await { error!("Failed to update user takedown status for {}: {:?}", did, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to update takedown status"})), - ) - .into_response(); + return ApiError::InternalError(Some( + "Failed to update takedown status".into(), + )) + .into_response(); } } if let Some(deactivated) = &input.deactivated { @@ -270,20 +233,15 @@ pub async fn update_subject_status( "Failed to update user deactivation status for {}: {:?}", did, e ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to update deactivation status"})), - ) - .into_response(); + return ApiError::InternalError(Some( + "Failed to update deactivation status".into(), + )) + .into_response(); } } if let Err(e) = tx.commit().await { error!("Failed to commit transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Some(takedown) = &input.takedown { let status = if takedown.applied { @@ -363,11 +321,10 @@ pub async fn update_subject_status( "Failed to update record takedown status for {}: {:?}", uri, e ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to update takedown status"})), - ) - .into_response(); + return ApiError::InternalError(Some( + "Failed to update takedown status".into(), + )) + .into_response(); } } return ( @@ -401,11 +358,10 @@ pub async fn update_subject_status( .await { error!("Failed to update blob takedown status for {}: {:?}", cid, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to update takedown status"})), - ) - .into_response(); + return ApiError::InternalError(Some( + "Failed to update takedown status".into(), + )) + .into_response(); } } return ( @@ -423,9 +379,5 @@ pub async fn update_subject_status( } _ => {} } - ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Invalid subject type"})), - ) - .into_response() + ApiError::InvalidRequest("Invalid subject type".into()).into_response() } diff --git a/src/api/age_assurance.rs b/src/api/age_assurance.rs index ffa3c1f..52e76bb 100644 --- a/src/api/age_assurance.rs +++ b/src/api/age_assurance.rs @@ -50,7 +50,7 @@ async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option } }; - let row = match sqlx::query!("SELECT created_at FROM users WHERE did = $1", auth_user.did) + let row = match sqlx::query!("SELECT created_at FROM users WHERE did = $1", &auth_user.did) .fetch_optional(&state.db) .await { diff --git a/src/api/backup.rs b/src/api/backup.rs index 6543cb4..1f13882 100644 --- a/src/api/backup.rs +++ b/src/api/backup.rs @@ -1,3 +1,5 @@ +use crate::api::error::ApiError; +use crate::api::{EmptyResponse, EnabledResponse}; use crate::auth::BearerAuth; use crate::scheduled::generate_full_backup; use crate::state::AppState; @@ -35,26 +37,18 @@ pub struct ListBackupsOutput { pub async fn list_backups(State(state): State, auth: BearerAuth) -> Response { let user = match sqlx::query!( "SELECT id, backup_enabled FROM users WHERE did = $1", - auth.0.did + auth.0.did.as_str() ) .fetch_optional(&state.db) .await { Ok(Some(u)) => u, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error fetching user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Database error"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -73,11 +67,7 @@ pub async fn list_backups(State(state): State, auth: BearerAuth) -> Re Ok(rows) => rows, Err(e) => { error!("DB error fetching backups: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Database error"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -116,11 +106,7 @@ pub async fn get_backup( let backup_id = match uuid::Uuid::parse_str(&query.id) { Ok(id) => id, Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})), - ) - .into_response(); + return ApiError::InvalidRequest("Invalid backup ID".into()).into_response(); } }; @@ -132,39 +118,25 @@ pub async fn get_backup( WHERE ab.id = $1 AND u.did = $2 "#, backup_id, - auth.0.did + auth.0.did.as_str() ) .fetch_optional(&state.db) .await { Ok(Some(b)) => b, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "BackupNotFound", "message": "Backup not found"})), - ) - .into_response(); + return ApiError::BackupNotFound.into_response(); } Err(e) => { error!("DB error fetching backup: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Database error"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let backup_storage = match state.backup_storage.as_ref() { Some(storage) => storage, None => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json( - json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}), - ), - ) - .into_response(); + return ApiError::BackupsDisabled.into_response(); } }; @@ -172,11 +144,7 @@ pub async fn get_backup( Ok(bytes) => bytes, Err(e) => { error!("Failed to fetch backup from storage: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to retrieve backup"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to retrieve backup".into())).into_response(); } }; @@ -207,13 +175,7 @@ pub async fn create_backup(State(state): State, auth: BearerAuth) -> R let backup_storage = match state.backup_storage.as_ref() { Some(storage) => storage, None => { - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json( - json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}), - ), - ) - .into_response(); + return ApiError::BackupsDisabled.into_response(); } }; @@ -224,58 +186,36 @@ pub async fn create_backup(State(state): State, auth: BearerAuth) -> R JOIN repos r ON r.user_id = u.id WHERE u.did = $1 "#, - auth.0.did + auth.0.did.as_str() ) .fetch_optional(&state.db) .await { Ok(Some(u)) => u, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error fetching user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Database error"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if user.deactivated_at.is_some() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), - ) - .into_response(); + return ApiError::AccountDeactivated.into_response(); } let repo_rev = match &user.repo_rev { Some(rev) => rev.clone(), None => { - return ( - StatusCode::BAD_REQUEST, - Json( - json!({"error": "RepoNotReady", "message": "Repository not ready for backup"}), - ), - ) - .into_response(); + return ApiError::RepoNotReady.into_response(); } }; let head_cid = match Cid::from_str(&user.repo_root_cid) { Ok(c) => c, Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})), - ) - .into_response(); + return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response(); } }; @@ -283,11 +223,7 @@ pub async fn create_backup(State(state): State, auth: BearerAuth) -> R Ok(bytes) => bytes, Err(e) => { error!("Failed to generate CAR: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to generate backup"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to generate backup".into())).into_response(); } }; @@ -301,11 +237,7 @@ pub async fn create_backup(State(state): State, auth: BearerAuth) -> R Ok(key) => key, Err(e) => { error!("Failed to upload backup: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to store backup"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to store backup".into())).into_response(); } }; @@ -335,11 +267,7 @@ pub async fn create_backup(State(state): State, auth: BearerAuth) -> R "Failed to rollback orphaned backup from S3" ); } - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to record backup"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to record backup".into())).into_response(); } }; @@ -420,11 +348,7 @@ pub async fn delete_backup( let backup_id = match uuid::Uuid::parse_str(&query.id) { Ok(id) => id, Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})), - ) - .into_response(); + return ApiError::InvalidRequest("Invalid backup ID".into()).into_response(); } }; @@ -436,35 +360,23 @@ pub async fn delete_backup( WHERE ab.id = $1 AND u.did = $2 "#, backup_id, - auth.0.did + auth.0.did.as_str() ) .fetch_optional(&state.db) .await { Ok(Some(b)) => b, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "BackupNotFound", "message": "Backup not found"})), - ) - .into_response(); + return ApiError::BackupNotFound.into_response(); } Err(e) => { error!("DB error fetching backup: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Database error"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if backup.deactivated_at.is_some() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), - ) - .into_response(); + return ApiError::AccountDeactivated.into_response(); } if let Some(backup_storage) = state.backup_storage.as_ref() @@ -482,16 +394,12 @@ pub async fn delete_backup( .await { error!("DB error deleting backup: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to delete backup"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to delete backup".into())).into_response(); } info!(did = %auth.0.did, backup_id = %backup_id, "Deleted backup"); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } #[derive(Deserialize)] @@ -507,78 +415,54 @@ pub async fn set_backup_enabled( ) -> Response { let user = match sqlx::query!( "SELECT deactivated_at FROM users WHERE did = $1", - auth.0.did + auth.0.did.as_str() ) .fetch_optional(&state.db) .await { Ok(Some(u)) => u, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error fetching user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Database error"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if user.deactivated_at.is_some() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), - ) - .into_response(); + return ApiError::AccountDeactivated.into_response(); } if let Err(e) = sqlx::query!( "UPDATE users SET backup_enabled = $1 WHERE did = $2", input.enabled, - auth.0.did + auth.0.did.as_str() ) .execute(&state.db) .await { error!("DB error updating backup_enabled: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to update setting"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to update setting".into())).into_response(); } info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting"); - (StatusCode::OK, Json(json!({"enabled": input.enabled}))).into_response() + EnabledResponse::new(input.enabled).into_response() } pub async fn export_blobs(State(state): State, auth: BearerAuth) -> Response { - let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did) + let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did.as_str()) .fetch_optional(&state.db) .await { Ok(Some(u)) => u, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error fetching user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Database error"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -597,11 +481,7 @@ pub async fn export_blobs(State(state): State, auth: BearerAuth) -> Re Ok(rows) => rows, Err(e) => { error!("DB error fetching blobs: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Database error"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -695,11 +575,7 @@ pub async fn export_blobs(State(state): State, auth: BearerAuth) -> Re if let Err(e) = zip.finish() { error!("Failed to finish zip: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to create zip file"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to create zip file".into())).into_response(); } } diff --git a/src/api/delegation.rs b/src/api/delegation.rs index 7d795f8..8ea6d3c 100644 --- a/src/api/delegation.rs +++ b/src/api/delegation.rs @@ -1,10 +1,11 @@ +use crate::api::error::ApiError; use crate::api::repo::record::utils::create_signed_commit; use crate::auth::BearerAuth; use crate::delegation::{self, DelegationActionType}; use crate::oauth::db as oauth_db; use crate::state::{AppState, RateLimitKind}; +use crate::types::{Did, Handle}; use crate::util::extract_client_ip; -use crate::validation::is_valid_did; use axum::{ Json, extract::{Query, State}, @@ -21,8 +22,8 @@ use tracing::{error, info, warn}; #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ControllerInfo { - pub did: String, - pub handle: String, + pub did: Did, + pub handle: Handle, pub granted_scopes: String, pub granted_at: chrono::DateTime, pub is_active: bool, @@ -38,14 +39,7 @@ pub async fn list_controllers(State(state): State, auth: BearerAuth) - Ok(c) => c, Err(e) => { tracing::error!("Failed to list controllers: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "ServerError", - "message": "Failed to list controllers" - })), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to list controllers".into())).into_response(); } }; @@ -53,7 +47,7 @@ pub async fn list_controllers(State(state): State, auth: BearerAuth) - controllers: controllers .into_iter() .map(|c| ControllerInfo { - did: c.did, + did: c.did.into(), handle: c.handle, granted_scopes: c.granted_scopes, granted_at: c.granted_at, @@ -66,7 +60,7 @@ pub async fn list_controllers(State(state): State, auth: BearerAuth) - #[derive(Debug, Deserialize)] pub struct AddControllerInput { - pub controller_did: String, + pub controller_did: Did, pub granted_scopes: String, } @@ -75,67 +69,32 @@ pub async fn add_controller( auth: BearerAuth, Json(input): Json, ) -> Response { - if !is_valid_did(&input.controller_did) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "InvalidRequest", - "message": "Invalid DID format" - })), - ) - .into_response(); - } - if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.granted_scopes) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "InvalidScopes", - "message": e - })), - ) - .into_response(); + return ApiError::InvalidScopes(e).into_response(); } let controller_exists: bool = sqlx::query_scalar!( r#"SELECT EXISTS(SELECT 1 FROM users WHERE did = $1) as "exists!""#, - input.controller_did + input.controller_did.as_str() ) .fetch_one(&state.db) .await .unwrap_or(false); if !controller_exists { - return ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "ControllerNotFound", - "message": "Controller account not found" - })), - ) - .into_response(); + return ApiError::ControllerNotFound.into_response(); } match delegation::controls_any_accounts(&state.db, &auth.0.did).await { Ok(true) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "InvalidDelegation", - "message": "Cannot add controllers to an account that controls other accounts" - })), + return ApiError::InvalidDelegation( + "Cannot add controllers to an account that controls other accounts".into(), ) - .into_response(); + .into_response(); } Err(e) => { tracing::error!("Failed to check delegation status: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "ServerError", - "message": "Failed to verify delegation status" - })), - ) + return ApiError::InternalError(Some("Failed to verify delegation status".into())) .into_response(); } Ok(false) => {} @@ -143,24 +102,14 @@ pub async fn add_controller( match delegation::has_any_controllers(&state.db, &input.controller_did).await { Ok(true) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "InvalidDelegation", - "message": "Cannot add a controlled account as a controller" - })), + return ApiError::InvalidDelegation( + "Cannot add a controlled account as a controller".into(), ) - .into_response(); + .into_response(); } Err(e) => { tracing::error!("Failed to check controller status: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "ServerError", - "message": "Failed to verify controller status" - })), - ) + return ApiError::InternalError(Some("Failed to verify controller status".into())) .into_response(); } Ok(false) => {} @@ -200,21 +149,14 @@ pub async fn add_controller( } Err(e) => { tracing::error!("Failed to add controller: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "ServerError", - "message": "Failed to add controller" - })), - ) - .into_response() + ApiError::InternalError(Some("Failed to add controller".into())).into_response() } } } #[derive(Debug, Deserialize)] pub struct RemoveControllerInput { - pub controller_did: String, + pub controller_did: Did, } pub async fn remove_controller( @@ -222,17 +164,6 @@ pub async fn remove_controller( auth: BearerAuth, Json(input): Json, ) -> Response { - if !is_valid_did(&input.controller_did) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "InvalidRequest", - "message": "Invalid DID format" - })), - ) - .into_response(); - } - match delegation::revoke_delegation(&state.db, &auth.0.did, &input.controller_did, &auth.0.did) .await { @@ -242,8 +173,8 @@ pub async fn remove_controller( WHERE user_id = (SELECT id FROM users WHERE did = $1) AND created_by_controller_did = $2 RETURNING id"#, - auth.0.did, - input.controller_did + &auth.0.did, + input.controller_did.as_str() ) .fetch_all(&state.db) .await @@ -281,31 +212,17 @@ pub async fn remove_controller( ) .into_response() } - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "DelegationNotFound", - "message": "No active delegation found for this controller" - })), - ) - .into_response(), + Ok(false) => ApiError::DelegationNotFound.into_response(), Err(e) => { tracing::error!("Failed to remove controller: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "ServerError", - "message": "Failed to remove controller" - })), - ) - .into_response() + ApiError::InternalError(Some("Failed to remove controller".into())).into_response() } } } #[derive(Debug, Deserialize)] pub struct UpdateControllerScopesInput { - pub controller_did: String, + pub controller_did: Did, pub granted_scopes: String, } @@ -314,26 +231,8 @@ pub async fn update_controller_scopes( auth: BearerAuth, Json(input): Json, ) -> Response { - if !is_valid_did(&input.controller_did) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "InvalidRequest", - "message": "Invalid DID format" - })), - ) - .into_response(); - } - if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.granted_scopes) { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "InvalidScopes", - "message": e - })), - ) - .into_response(); + return ApiError::InvalidScopes(e).into_response(); } match delegation::update_delegation_scopes( @@ -367,24 +266,10 @@ pub async fn update_controller_scopes( ) .into_response() } - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "DelegationNotFound", - "message": "No active delegation found for this controller" - })), - ) - .into_response(), + Ok(false) => ApiError::DelegationNotFound.into_response(), Err(e) => { tracing::error!("Failed to update controller scopes: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "ServerError", - "message": "Failed to update controller scopes" - })), - ) - .into_response() + ApiError::InternalError(Some("Failed to update controller scopes".into())).into_response() } } } @@ -392,8 +277,8 @@ pub async fn update_controller_scopes( #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct DelegatedAccountInfo { - pub did: String, - pub handle: String, + pub did: Did, + pub handle: Handle, pub granted_scopes: String, pub granted_at: chrono::DateTime, } @@ -408,13 +293,7 @@ pub async fn list_controlled_accounts(State(state): State, auth: Beare Ok(a) => a, Err(e) => { tracing::error!("Failed to list controlled accounts: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "ServerError", - "message": "Failed to list controlled accounts" - })), - ) + return ApiError::InternalError(Some("Failed to list controlled accounts".into())) .into_response(); } }; @@ -423,7 +302,7 @@ pub async fn list_controlled_accounts(State(state): State, auth: Beare accounts: accounts .into_iter() .map(|a| DelegatedAccountInfo { - did: a.did, + did: a.did.into(), handle: a.handle, granted_scopes: a.granted_scopes, granted_at: a.granted_at, @@ -449,9 +328,9 @@ fn default_limit() -> i64 { #[serde(rename_all = "camelCase")] pub struct AuditLogEntry { pub id: String, - pub delegated_did: String, - pub actor_did: String, - pub controller_did: Option, + pub delegated_did: Did, + pub actor_did: Did, + pub controller_did: Option, pub action_type: String, pub action_details: Option, pub created_at: chrono::DateTime, @@ -478,14 +357,7 @@ pub async fn get_audit_log( Ok(e) => e, Err(e) => { tracing::error!("Failed to get audit log: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "ServerError", - "message": "Failed to get audit log" - })), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to get audit log".into())).into_response(); } }; @@ -498,9 +370,9 @@ pub async fn get_audit_log( .into_iter() .map(|e| AuditLogEntry { id: e.id.to_string(), - delegated_did: e.delegated_did, - actor_did: e.actor_did, - controller_did: e.controller_did, + delegated_did: e.delegated_did.into(), + actor_did: e.actor_did.into(), + controller_did: e.controller_did.map(Into::into), action_type: format!("{:?}", e.action_type), action_details: e.action_details, created_at: e.created_at, @@ -551,8 +423,8 @@ pub struct CreateDelegatedAccountInput { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateDelegatedAccountResponse { - pub did: String, - pub handle: String, + pub did: Did, + pub handle: Handle, } pub async fn create_delegated_account( @@ -567,47 +439,26 @@ pub async fn create_delegated_account( .await { warn!(ip = %client_ip, "Delegated account creation rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many account creation attempts. Please try again later." - })), - ) - .into_response(); + return ApiError::RateLimitExceeded(Some( + "Too many account creation attempts. Please try again later.".into(), + )) + .into_response(); } if let Err(e) = delegation::scopes::validate_delegation_scopes(&input.controller_scopes) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidScopes", - "message": e - })), - ) - .into_response(); + return ApiError::InvalidScopes(e).into_response(); } match delegation::has_any_controllers(&state.db, &auth.0.did).await { Ok(true) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidDelegation", - "message": "Cannot create delegated accounts from a controlled account" - })), + return ApiError::InvalidDelegation( + "Cannot create delegated accounts from a controlled account".into(), ) - .into_response(); + .into_response(); } Err(e) => { tracing::error!("Failed to check controller status: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ - "error": "ServerError", - "message": "Failed to verify controller status" - })), - ) + return ApiError::InternalError(Some("Failed to verify controller status".into())) .into_response(); } Ok(false) => {} @@ -628,11 +479,7 @@ pub async fn create_delegated_account( match crate::api::validation::validate_short_handle(handle_to_validate) { Ok(h) => format!("{}.{}", h, hostname), Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": e.to_string()})), - ) - .into_response(); + return ApiError::InvalidRequest(e.to_string()).into_response(); } } } else { @@ -647,11 +494,7 @@ pub async fn create_delegated_account( if let Some(ref email) = email && !crate::api::validation::is_valid_email(email) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), - ) - .into_response(); + return ApiError::InvalidEmail.into_response(); } if let Some(ref code) = input.invite_code { @@ -666,22 +509,14 @@ pub async fn create_delegated_account( .unwrap_or(Some(false)); if valid != Some(true) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidInviteCode", "message": "Invalid or expired invite code"})), - ) - .into_response(); + return ApiError::InvalidInviteCode.into_response(); } } else { let invite_required = std::env::var("INVITE_CODE_REQUIRED") .map(|v| v == "true" || v == "1") .unwrap_or(false); if invite_required { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InviteCodeRequired", "message": "An invite code is required to create an account"})), - ) - .into_response(); + return ApiError::InviteCodeRequired.into_response(); } } @@ -696,11 +531,7 @@ pub async fn create_delegated_account( Ok(k) => k, Err(e) => { error!("Error creating signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -716,12 +547,7 @@ pub async fn create_delegated_account( Ok(r) => r, Err(e) => { error!("Error creating PLC genesis operation: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json( - json!({"error": "InternalError", "message": "Failed to create PLC operation"}), - ), - ) + return ApiError::InternalError(Some("Failed to create PLC operation".into())) .into_response(); } }; @@ -732,28 +558,21 @@ pub async fn create_delegated_account( .await { error!("Failed to submit PLC genesis operation: {:?}", e); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({ - "error": "UpstreamError", - "message": format!("Failed to register DID with PLC directory: {}", e) - })), - ) - .into_response(); + return ApiError::UpstreamErrorMsg(format!( + "Failed to register DID with PLC directory: {}", + e + )) + .into_response(); } let did = genesis_result.did; - info!(did = %did, handle = %handle, controller = %auth.0.did, "Created DID for delegated account"); + info!(did = %did, handle = %handle, controller = %&auth.0.did, "Created DID for delegated account"); let mut tx = match state.db.begin().await { Ok(tx) => tx, Err(e) => { error!("Error starting transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -777,27 +596,13 @@ pub async fn create_delegated_account( { let constraint = db_err.constraint().unwrap_or(""); if constraint.contains("handle") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "HandleNotAvailable", "message": "Handle already taken"})), - ) - .into_response(); + return ApiError::HandleNotAvailable(None).into_response(); } else if constraint.contains("email") { - return ( - StatusCode::BAD_REQUEST, - Json( - json!({"error": "InvalidEmail", "message": "Email already registered"}), - ), - ) - .into_response(); + return ApiError::EmailTaken.into_response(); } } error!("Error inserting user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -805,11 +610,7 @@ pub async fn create_delegated_account( Ok(bytes) => bytes, Err(e) => { error!("Error encrypting signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -823,30 +624,22 @@ pub async fn create_delegated_account( .await { error!("Error inserting user key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Err(e) = sqlx::query!( r#"INSERT INTO account_delegations (delegated_did, controller_did, granted_scopes, granted_by) VALUES ($1, $2, $3, $4)"#, did, - auth.0.did, + &auth.0.did, input.controller_scopes, - auth.0.did + &auth.0.did ) .execute(&mut *tx) .await { error!("Error creating initial delegation: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let mst = Mst::new(Arc::new(state.block_store.clone())); @@ -854,11 +647,7 @@ pub async fn create_delegated_account( Ok(c) => c, Err(e) => { error!("Error persisting MST: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let rev = Tid::now(LimitedU32::MIN); @@ -867,22 +656,14 @@ pub async fn create_delegated_account( Ok(result) => result, Err(e) => { error!("Error creating genesis commit: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { Ok(c) => c, Err(e) => { error!("Error saving genesis commit: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let commit_cid_str = commit_cid.to_string(); @@ -897,11 +678,7 @@ pub async fn create_delegated_account( .await { error!("Error inserting repo: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; if let Err(e) = sqlx::query!( @@ -917,11 +694,7 @@ pub async fn create_delegated_account( .await { error!("Error inserting user_blocks: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Some(ref code) = input.invite_code { @@ -943,11 +716,7 @@ pub async fn create_delegated_account( if let Err(e) = tx.commit().await { error!("Error committing transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Err(e) = @@ -991,7 +760,7 @@ pub async fn create_delegated_account( ) .await; - info!(did = %did, handle = %handle, controller = %auth.0.did, "Delegated account created"); + info!(did = %did, handle = %handle, controller = %&auth.0.did, "Delegated account created"); - Json(CreateDelegatedAccountResponse { did, handle }).into_response() + Json(CreateDelegatedAccountResponse { did: did.into(), handle: handle.into() }).into_response() } diff --git a/src/api/error.rs b/src/api/error.rs index 16e57b9..49c7ca9 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -1,9 +1,10 @@ use axum::{ Json, + extract::{FromRequest, Request, rejection::JsonRejection}, http::StatusCode, response::{IntoResponse, Response}, }; -use serde::Serialize; +use serde::{Serialize, de::DeserializeOwned}; use std::borrow::Cow; #[derive(Debug, Serialize)] @@ -15,24 +16,23 @@ struct ErrorBody<'a> { #[derive(Debug)] pub enum ApiError { - InternalError, + InternalError(Option), AuthenticationRequired, - AuthenticationFailed, - AuthenticationFailedMsg(String), + AuthenticationFailed(Option), InvalidRequest(String), - InvalidToken, - ExpiredToken, - ExpiredTokenMsg(String), + InvalidToken(Option), + ExpiredToken(Option), TokenRequired, AccountDeactivated, AccountTakedown, AccountNotFound, - RepoNotFound, - RepoNotFoundMsg(String), + RepoNotFound(Option), + RepoTakendown, + RepoDeactivated, RecordNotFound, - BlobNotFound, - InvalidHandle, - HandleNotAvailable, + BlobNotFound(Option), + InvalidHandle(Option), + HandleNotAvailable(Option), HandleTaken, InvalidEmail, EmailTaken, @@ -40,10 +40,63 @@ pub enum ApiError { DuplicateCreate, DuplicateAppPassword, AppPasswordNotFound, - InvalidSwap, + SessionNotFound, + InvalidSwap(Option), + InvalidPassword(String), + InvalidRepo(String), + AccountMigrated, + AccountNotVerified, + InvalidCollection, + InvalidRecord(String), Forbidden, - InsufficientScope, + AdminRequired, + InsufficientScope(Option), InvitesDisabled, + RateLimitExceeded(Option), + PayloadTooLarge(String), + TotpAlreadyEnabled, + TotpNotEnabled, + InvalidCode(Option), + InvalidChannel, + IdentifierMismatch, + NoPasskeys, + NoChallengeInProgress, + InvalidCredential, + PasskeyCounterAnomaly, + NoRegistrationInProgress, + RegistrationFailed, + PasskeyNotFound, + InvalidId, + InvalidScopes(String), + ControllerNotFound, + InvalidDelegation(String), + DelegationNotFound, + InviteCodeRequired, + BackupNotFound, + BackupsDisabled, + RepoNotReady, + DeviceNotFound, + NoEmail, + MfaVerificationRequired, + AuthorizationError(String), + InvalidDid(String), + InvalidSigningKey, + SetupExpired, + InvalidAccount, + InvalidRecoveryLink, + RecoveryLinkExpired, + MissingEmail, + MissingDiscordId, + MissingTelegramUsername, + MissingSignalNumber, + InvalidVerificationChannel, + SelfHostedDidWebDisabled, + AccountAlreadyExists, + HandleNotFound, + SubjectNotFound, + NotFoundMsg(String), + ServiceUnavailable(Option), + UpstreamErrorMsg(String), DatabaseError, UpstreamFailure, UpstreamTimeout, @@ -58,48 +111,105 @@ pub enum ApiError { impl ApiError { fn status_code(&self) -> StatusCode { match self { - Self::InternalError | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, - Self::UpstreamFailure | Self::UpstreamUnavailable(_) => StatusCode::BAD_GATEWAY, + Self::InternalError(_) | Self::DatabaseError => StatusCode::INTERNAL_SERVER_ERROR, + Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => { + StatusCode::BAD_GATEWAY + } + Self::ServiceUnavailable(_) | Self::BackupsDisabled => { + StatusCode::SERVICE_UNAVAILABLE + } Self::UpstreamTimeout => StatusCode::GATEWAY_TIMEOUT, Self::UpstreamError { status, .. } => { StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY) } Self::AuthenticationRequired - | Self::AuthenticationFailed - | Self::AuthenticationFailedMsg(_) - | Self::InvalidToken - | Self::ExpiredToken - | Self::ExpiredTokenMsg(_) - | Self::TokenRequired + | Self::AuthenticationFailed(_) | Self::AccountDeactivated - | Self::AccountTakedown => StatusCode::UNAUTHORIZED, - Self::Forbidden | Self::InsufficientScope | Self::InvitesDisabled => { - StatusCode::FORBIDDEN - } + | Self::AccountTakedown + | Self::InvalidCode(_) + | Self::InvalidPassword(_) + | Self::PasskeyCounterAnomaly => StatusCode::UNAUTHORIZED, + Self::Forbidden + | Self::AdminRequired + | Self::InsufficientScope(_) + | Self::InvitesDisabled + | Self::InvalidRepo(_) + | Self::AccountMigrated + | Self::AccountNotVerified + | Self::MfaVerificationRequired + | Self::AuthorizationError(_) => StatusCode::FORBIDDEN, + Self::RateLimitExceeded(_) => StatusCode::TOO_MANY_REQUESTS, + Self::PayloadTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE, Self::AccountNotFound - | Self::RepoNotFound - | Self::RepoNotFoundMsg(_) | Self::RecordNotFound - | Self::BlobNotFound - | Self::AppPasswordNotFound => StatusCode::NOT_FOUND, + | Self::AppPasswordNotFound + | Self::SessionNotFound + | Self::DeviceNotFound + | Self::ControllerNotFound + | Self::DelegationNotFound + | Self::BackupNotFound + | Self::InvalidRecoveryLink + | Self::HandleNotFound + | Self::SubjectNotFound + | Self::BlobNotFound(_) + | Self::NotFoundMsg(_) => StatusCode::NOT_FOUND, + Self::RepoTakendown + | Self::RepoDeactivated + | Self::RepoNotFound(_) => StatusCode::BAD_REQUEST, + Self::InvalidSwap(_) | Self::TotpAlreadyEnabled => { + StatusCode::CONFLICT + } Self::InvalidRequest(_) - | Self::InvalidHandle - | Self::HandleNotAvailable + | Self::InvalidHandle(_) + | Self::HandleNotAvailable(_) | Self::HandleTaken | Self::InvalidEmail | Self::EmailTaken | Self::InvalidInviteCode | Self::DuplicateCreate | Self::DuplicateAppPassword - | Self::InvalidSwap => StatusCode::BAD_REQUEST, + | Self::InvalidCollection + | Self::InvalidRecord(_) + | Self::TotpNotEnabled + | Self::InvalidChannel + | Self::IdentifierMismatch + | Self::NoPasskeys + | Self::NoChallengeInProgress + | Self::InvalidCredential + | Self::NoEmail + | Self::NoRegistrationInProgress + | Self::RegistrationFailed + | Self::InvalidId + | Self::InvalidScopes(_) + | Self::InvalidDelegation(_) + | Self::InviteCodeRequired + | Self::RepoNotReady + | Self::InvalidDid(_) + | Self::InvalidSigningKey + | Self::SetupExpired + | Self::InvalidAccount + | Self::RecoveryLinkExpired + | Self::MissingEmail + | Self::MissingDiscordId + | Self::MissingTelegramUsername + | Self::MissingSignalNumber + | Self::InvalidVerificationChannel + | Self::SelfHostedDidWebDisabled + | Self::AccountAlreadyExists + | Self::InvalidToken(_) + | Self::ExpiredToken(_) + | Self::TokenRequired => StatusCode::BAD_REQUEST, + Self::PasskeyNotFound => StatusCode::NOT_FOUND, } } fn error_name(&self) -> Cow<'static, str> { match self { - Self::InternalError | Self::DatabaseError => Cow::Borrowed("InternalError"), - Self::UpstreamFailure | Self::UpstreamUnavailable(_) => { - Cow::Borrowed("UpstreamFailure") + Self::InternalError(_) | Self::DatabaseError => Cow::Borrowed("InternalError"), + Self::UpstreamFailure | Self::UpstreamUnavailable(_) | Self::UpstreamErrorMsg(_) => { + Cow::Borrowed("UpstreamError") } + Self::ServiceUnavailable(_) => Cow::Borrowed("ServiceUnavailable"), + Self::NotFoundMsg(_) => Cow::Borrowed("NotFound"), Self::UpstreamTimeout => Cow::Borrowed("UpstreamTimeout"), Self::UpstreamError { error, .. } => { if let Some(e) = error { @@ -108,43 +218,186 @@ impl ApiError { Cow::Borrowed("UpstreamError") } Self::AuthenticationRequired => Cow::Borrowed("AuthenticationRequired"), - Self::AuthenticationFailed | Self::AuthenticationFailedMsg(_) => { - Cow::Borrowed("AuthenticationFailed") - } - Self::InvalidToken => Cow::Borrowed("InvalidToken"), - Self::ExpiredToken | Self::ExpiredTokenMsg(_) => Cow::Borrowed("ExpiredToken"), + Self::AuthenticationFailed(_) => Cow::Borrowed("AuthenticationFailed"), + Self::InvalidToken(_) => Cow::Borrowed("InvalidToken"), + Self::ExpiredToken(_) => Cow::Borrowed("ExpiredToken"), Self::TokenRequired => Cow::Borrowed("TokenRequired"), Self::AccountDeactivated => Cow::Borrowed("AccountDeactivated"), Self::AccountTakedown => Cow::Borrowed("AccountTakedown"), Self::Forbidden => Cow::Borrowed("Forbidden"), - Self::InsufficientScope => Cow::Borrowed("InsufficientScope"), + Self::AdminRequired => Cow::Borrowed("AdminRequired"), + Self::InsufficientScope(_) => Cow::Borrowed("InsufficientScope"), Self::InvitesDisabled => Cow::Borrowed("InvitesDisabled"), Self::AccountNotFound => Cow::Borrowed("AccountNotFound"), - Self::RepoNotFound | Self::RepoNotFoundMsg(_) => Cow::Borrowed("RepoNotFound"), + Self::RepoNotFound(_) => Cow::Borrowed("RepoNotFound"), + Self::RepoTakendown => Cow::Borrowed("RepoTakendown"), + Self::RepoDeactivated => Cow::Borrowed("RepoDeactivated"), Self::RecordNotFound => Cow::Borrowed("RecordNotFound"), - Self::BlobNotFound => Cow::Borrowed("BlobNotFound"), + Self::BlobNotFound(_) => Cow::Borrowed("BlobNotFound"), Self::AppPasswordNotFound => Cow::Borrowed("AppPasswordNotFound"), + Self::SessionNotFound => Cow::Borrowed("SessionNotFound"), Self::InvalidRequest(_) => Cow::Borrowed("InvalidRequest"), - Self::InvalidHandle => Cow::Borrowed("InvalidHandle"), - Self::HandleNotAvailable => Cow::Borrowed("HandleNotAvailable"), + Self::InvalidHandle(_) => Cow::Borrowed("InvalidHandle"), + Self::HandleNotAvailable(_) => Cow::Borrowed("HandleNotAvailable"), Self::HandleTaken => Cow::Borrowed("HandleTaken"), Self::InvalidEmail => Cow::Borrowed("InvalidEmail"), Self::EmailTaken => Cow::Borrowed("EmailTaken"), Self::InvalidInviteCode => Cow::Borrowed("InvalidInviteCode"), Self::DuplicateCreate => Cow::Borrowed("DuplicateCreate"), Self::DuplicateAppPassword => Cow::Borrowed("DuplicateAppPassword"), - Self::InvalidSwap => Cow::Borrowed("InvalidSwap"), + Self::InvalidSwap(_) => Cow::Borrowed("InvalidSwap"), + Self::InvalidPassword(_) => Cow::Borrowed("InvalidPassword"), + Self::InvalidRepo(_) => Cow::Borrowed("InvalidRepo"), + Self::AccountMigrated => Cow::Borrowed("AccountMigrated"), + Self::AccountNotVerified => Cow::Borrowed("AccountNotVerified"), + Self::InvalidCollection => Cow::Borrowed("InvalidCollection"), + Self::InvalidRecord(_) => Cow::Borrowed("InvalidRecord"), + Self::TotpAlreadyEnabled => Cow::Borrowed("TotpAlreadyEnabled"), + Self::TotpNotEnabled => Cow::Borrowed("TotpNotEnabled"), + Self::InvalidCode(_) => Cow::Borrowed("InvalidCode"), + Self::InvalidChannel => Cow::Borrowed("InvalidChannel"), + Self::IdentifierMismatch => Cow::Borrowed("IdentifierMismatch"), + Self::NoPasskeys => Cow::Borrowed("NoPasskeys"), + Self::NoChallengeInProgress => Cow::Borrowed("NoChallengeInProgress"), + Self::InvalidCredential => Cow::Borrowed("InvalidCredential"), + Self::PasskeyCounterAnomaly => Cow::Borrowed("PasskeyCounterAnomaly"), + Self::NoRegistrationInProgress => Cow::Borrowed("NoRegistrationInProgress"), + Self::RegistrationFailed => Cow::Borrowed("RegistrationFailed"), + Self::PasskeyNotFound => Cow::Borrowed("PasskeyNotFound"), + Self::InvalidId => Cow::Borrowed("InvalidId"), + Self::InvalidScopes(_) => Cow::Borrowed("InvalidScopes"), + Self::ControllerNotFound => Cow::Borrowed("ControllerNotFound"), + Self::InvalidDelegation(_) => Cow::Borrowed("InvalidDelegation"), + Self::DelegationNotFound => Cow::Borrowed("DelegationNotFound"), + Self::InviteCodeRequired => Cow::Borrowed("InviteCodeRequired"), + Self::BackupNotFound => Cow::Borrowed("BackupNotFound"), + Self::BackupsDisabled => Cow::Borrowed("BackupsDisabled"), + Self::RepoNotReady => Cow::Borrowed("RepoNotReady"), + Self::MfaVerificationRequired => Cow::Borrowed("MfaVerificationRequired"), + Self::RateLimitExceeded(_) => Cow::Borrowed("RateLimitExceeded"), + Self::PayloadTooLarge(_) => Cow::Borrowed("PayloadTooLarge"), + Self::DeviceNotFound => Cow::Borrowed("DeviceNotFound"), + Self::NoEmail => Cow::Borrowed("NoEmail"), + Self::AuthorizationError(_) => Cow::Borrowed("AuthorizationError"), + Self::InvalidDid(_) => Cow::Borrowed("InvalidDid"), + Self::InvalidSigningKey => Cow::Borrowed("InvalidSigningKey"), + Self::SetupExpired => Cow::Borrowed("SetupExpired"), + Self::InvalidAccount => Cow::Borrowed("InvalidAccount"), + Self::InvalidRecoveryLink => Cow::Borrowed("InvalidRecoveryLink"), + Self::RecoveryLinkExpired => Cow::Borrowed("RecoveryLinkExpired"), + Self::MissingEmail => Cow::Borrowed("MissingEmail"), + Self::MissingDiscordId => Cow::Borrowed("MissingDiscordId"), + Self::MissingTelegramUsername => Cow::Borrowed("MissingTelegramUsername"), + Self::MissingSignalNumber => Cow::Borrowed("MissingSignalNumber"), + Self::InvalidVerificationChannel => Cow::Borrowed("InvalidVerificationChannel"), + Self::SelfHostedDidWebDisabled => Cow::Borrowed("SelfHostedDidWebDisabled"), + Self::AccountAlreadyExists => Cow::Borrowed("AccountAlreadyExists"), + Self::HandleNotFound => Cow::Borrowed("HandleNotFound"), + Self::SubjectNotFound => Cow::Borrowed("SubjectNotFound"), } } fn message(&self) -> Option { match self { - Self::AuthenticationFailedMsg(msg) - | Self::ExpiredTokenMsg(msg) - | Self::InvalidRequest(msg) - | Self::RepoNotFoundMsg(msg) - | Self::UpstreamUnavailable(msg) => Some(msg.clone()), + Self::InternalError(msg) + | Self::AuthenticationFailed(msg) + | Self::InvalidToken(msg) + | Self::ExpiredToken(msg) + | Self::RepoNotFound(msg) + | Self::BlobNotFound(msg) + | Self::InvalidHandle(msg) + | Self::HandleNotAvailable(msg) + | Self::InvalidSwap(msg) + | Self::InsufficientScope(msg) + | Self::InvalidCode(msg) + | Self::RateLimitExceeded(msg) + | Self::ServiceUnavailable(msg) => msg.clone(), + Self::InvalidRequest(msg) + | Self::UpstreamUnavailable(msg) + | Self::InvalidPassword(msg) + | Self::InvalidRepo(msg) + | Self::InvalidRecord(msg) + | Self::NotFoundMsg(msg) + | Self::UpstreamErrorMsg(msg) + | Self::PayloadTooLarge(msg) => Some(msg.clone()), + Self::AccountMigrated => Some( + "Account has been migrated to another PDS. Repo operations are not allowed." + .to_string(), + ), + Self::AccountNotVerified => Some( + "You must verify at least one notification channel before creating records" + .to_string(), + ), + Self::NoPasskeys => { + Some("No passkeys registered for this account".to_string()) + } + Self::NoChallengeInProgress => Some( + "No passkey authentication in progress or challenge expired".to_string(), + ), + Self::InvalidCredential => Some("Failed to parse credential response".to_string()), + Self::NoRegistrationInProgress => Some( + "No registration in progress. Call startPasskeyRegistration first.".to_string(), + ), + Self::RegistrationFailed => { + Some("Failed to verify passkey registration".to_string()) + } + Self::PasskeyNotFound => Some("Passkey not found".to_string()), + Self::InvalidId => Some("Invalid ID format".to_string()), + Self::InvalidScopes(msg) | Self::InvalidDelegation(msg) => Some(msg.clone()), + Self::ControllerNotFound => Some("Controller account not found".to_string()), + Self::DelegationNotFound => { + Some("No active delegation found for this controller".to_string()) + } + Self::InviteCodeRequired => { + Some("An invite code is required to create an account".to_string()) + } + Self::BackupNotFound => Some("Backup not found".to_string()), + Self::BackupsDisabled => Some("Backup storage not configured".to_string()), + Self::RepoNotReady => Some("Repository not ready for backup".to_string()), + Self::PasskeyCounterAnomaly => Some( + "Authentication failed: security key counter anomaly detected. This may indicate a cloned key.".to_string(), + ), + Self::MfaVerificationRequired => Some( + "This sensitive operation requires MFA verification".to_string(), + ), + Self::DeviceNotFound => Some("Device not found".to_string()), + Self::NoEmail => Some("Recipient has no email address".to_string()), + Self::AuthorizationError(msg) | Self::InvalidDid(msg) => Some(msg.clone()), + Self::InvalidSigningKey => { + Some("Signing key not found, already used, or expired".to_string()) + } + Self::SetupExpired => { + Some("Setup has already been completed or expired".to_string()) + } + Self::InvalidAccount => { + Some("This account is not a passkey-only account".to_string()) + } + Self::InvalidRecoveryLink => Some("Invalid recovery link".to_string()), + Self::RecoveryLinkExpired => Some("Recovery link has expired".to_string()), + Self::MissingEmail => { + Some("Email is required when using email verification".to_string()) + } + Self::MissingDiscordId => { + Some("Discord ID is required when using Discord verification".to_string()) + } + Self::MissingTelegramUsername => { + Some("Telegram username is required when using Telegram verification".to_string()) + } + Self::MissingSignalNumber => { + Some("Signal phone number is required when using Signal verification".to_string()) + } + Self::InvalidVerificationChannel => Some("Invalid verification channel".to_string()), + Self::SelfHostedDidWebDisabled => { + Some("Self-hosted did:web accounts are disabled on this server".to_string()) + } + Self::AccountAlreadyExists => Some("Account already exists".to_string()), + Self::HandleNotFound => Some("Unable to resolve handle".to_string()), + Self::SubjectNotFound => Some("Subject not found".to_string()), + Self::IdentifierMismatch => { + Some("The identifier does not match the verification token".to_string()) + } Self::UpstreamError { message, .. } => message.clone(), Self::UpstreamTimeout => Some("Upstream service timed out".to_string()), + Self::AdminRequired => Some("This action requires admin privileges".to_string()), _ => None, } } @@ -182,6 +435,7 @@ impl IntoResponse for ApiError { } } + impl From for ApiError { fn from(e: sqlx::Error) -> Self { tracing::error!("Database error: {:?}", e); @@ -194,9 +448,11 @@ impl From for ApiError { match e { crate::auth::TokenValidationError::AccountDeactivated => Self::AccountDeactivated, crate::auth::TokenValidationError::AccountTakedown => Self::AccountTakedown, - crate::auth::TokenValidationError::KeyDecryptionFailed => Self::InternalError, - crate::auth::TokenValidationError::AuthenticationFailed => Self::AuthenticationFailed, - crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken, + crate::auth::TokenValidationError::KeyDecryptionFailed => Self::InternalError(None), + crate::auth::TokenValidationError::AuthenticationFailed => { + Self::AuthenticationFailed(None) + } + crate::auth::TokenValidationError::TokenExpired => Self::ExpiredToken(None), } } } @@ -212,3 +468,209 @@ impl From for ApiError { } } } + +impl From for ApiError { + fn from(e: crate::auth::extractor::AuthError) -> Self { + match e { + crate::auth::extractor::AuthError::MissingToken => Self::AuthenticationRequired, + crate::auth::extractor::AuthError::InvalidFormat => { + Self::AuthenticationFailed(Some("Invalid authorization header format".to_string())) + } + crate::auth::extractor::AuthError::AuthenticationFailed => { + Self::AuthenticationFailed(None) + } + crate::auth::extractor::AuthError::TokenExpired => { + Self::AuthenticationFailed(Some("Token has expired".to_string())) + } + crate::auth::extractor::AuthError::AccountDeactivated => Self::AccountDeactivated, + crate::auth::extractor::AuthError::AccountTakedown => Self::AccountTakedown, + crate::auth::extractor::AuthError::AdminRequired => Self::AdminRequired, + } + } +} + +impl From for ApiError { + fn from(e: crate::handle::HandleResolutionError) -> Self { + match e { + crate::handle::HandleResolutionError::NotFound => Self::HandleNotFound, + crate::handle::HandleResolutionError::InvalidDid => { + Self::InvalidHandle(Some("Invalid DID format in handle record".to_string())) + } + crate::handle::HandleResolutionError::DidMismatch { expected, actual } => { + Self::InvalidHandle(Some(format!( + "Handle DID mismatch: expected {}, got {}", + expected, actual + ))) + } + crate::handle::HandleResolutionError::DnsError(msg) => { + Self::InternalError(Some(format!("DNS resolution failed: {}", msg))) + } + crate::handle::HandleResolutionError::HttpError(msg) => { + Self::InternalError(Some(format!("Handle HTTP resolution failed: {}", msg))) + } + } + } +} + +impl From for ApiError { + fn from(e: crate::auth::verification_token::VerifyError) -> Self { + use crate::auth::verification_token::VerifyError; + match e { + VerifyError::InvalidFormat => { + Self::InvalidRequest("The verification code is invalid or malformed".to_string()) + } + VerifyError::UnsupportedVersion => { + Self::InvalidRequest("This verification code version is not supported".to_string()) + } + VerifyError::Expired => { + Self::InvalidRequest("The verification code has expired. Please request a new one.".to_string()) + } + VerifyError::InvalidSignature => { + Self::InvalidRequest("The verification code is invalid".to_string()) + } + VerifyError::IdentifierMismatch => Self::IdentifierMismatch, + VerifyError::PurposeMismatch => { + Self::InvalidRequest("Verification code purpose does not match".to_string()) + } + VerifyError::ChannelMismatch => { + Self::InvalidRequest("Verification code channel does not match".to_string()) + } + } + } +} + +impl From for ApiError { + fn from(e: crate::api::validation::HandleValidationError) -> Self { + use crate::api::validation::HandleValidationError; + match e { + HandleValidationError::Reserved => Self::HandleNotAvailable(None), + HandleValidationError::BannedWord => { + Self::InvalidHandle(Some("Inappropriate language in handle".to_string())) + } + _ => Self::InvalidHandle(Some(e.to_string())), + } + } +} + +impl From for ApiError { + fn from(e: jacquard::types::string::AtStrError) -> Self { + Self::InvalidRequest(format!("Invalid {}: {}", e.spec, e.kind)) + } +} + +impl From for ApiError { + fn from(e: crate::plc::PlcError) -> Self { + use crate::plc::PlcError; + match e { + PlcError::NotFound => Self::NotFoundMsg("DID not found in PLC directory".into()), + PlcError::Tombstoned => Self::InvalidRequest("DID is tombstoned".into()), + PlcError::Timeout => Self::UpstreamTimeout, + PlcError::CircuitBreakerOpen => { + Self::ServiceUnavailable(Some("PLC directory service temporarily unavailable".into())) + } + PlcError::Http(err) => { + tracing::error!("PLC HTTP error: {:?}", err); + Self::UpstreamErrorMsg("Failed to communicate with PLC directory".into()) + } + PlcError::InvalidResponse(msg) => { + tracing::error!("PLC invalid response: {}", msg); + Self::UpstreamErrorMsg(format!("Invalid response from PLC directory: {}", msg)) + } + PlcError::Serialization(msg) => { + tracing::error!("PLC serialization error: {}", msg); + Self::InternalError(Some(format!("PLC serialization error: {}", msg))) + } + PlcError::Signing(msg) => { + tracing::error!("PLC signing error: {}", msg); + Self::InternalError(Some(format!("PLC signing error: {}", msg))) + } + } + } +} + +impl From for ApiError { + fn from(e: bcrypt::BcryptError) -> Self { + tracing::error!("Bcrypt error: {:?}", e); + Self::InternalError(None) + } +} + +impl From for ApiError { + fn from(e: cid::Error) -> Self { + Self::InvalidRequest(format!("Invalid CID: {}", e)) + } +} + +impl From> for ApiError { + fn from(e: crate::circuit_breaker::CircuitBreakerError) -> Self { + use crate::circuit_breaker::CircuitBreakerError; + match e { + CircuitBreakerError::CircuitOpen(err) => { + tracing::warn!("PLC directory circuit breaker open: {}", err); + Self::ServiceUnavailable(Some( + "PLC directory service temporarily unavailable".into(), + )) + } + CircuitBreakerError::OperationFailed(plc_err) => Self::from(plc_err), + } + } +} + +impl From for ApiError { + fn from(e: crate::storage::StorageError) -> Self { + tracing::error!("Storage error: {:?}", e); + Self::InternalError(Some("Storage operation failed".into())) + } +} + +pub struct AtpJson(pub T); + +impl FromRequest for AtpJson +where + T: DeserializeOwned, + S: Send + Sync, +{ + type Rejection = (StatusCode, Json); + + async fn from_request(req: Request, state: &S) -> Result { + match Json::::from_request(req, state).await { + Ok(Json(value)) => Ok(AtpJson(value)), + Err(rejection) => { + let message = extract_json_error_message(&rejection); + Err(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "InvalidRequest", + "message": message + })), + )) + } + } + } +} + +fn extract_json_error_message(rejection: &JsonRejection) -> String { + match rejection { + JsonRejection::JsonDataError(e) => { + let inner = e.body_text(); + if inner.contains("missing field") { + let field = inner + .split("missing field `") + .nth(1) + .and_then(|s| s.split('`').next()) + .unwrap_or("unknown"); + format!("Missing required field: {}", field) + } else if inner.contains("invalid type") { + format!("Invalid field type: {}", inner) + } else { + inner + } + } + JsonRejection::JsonSyntaxError(_) => "Invalid JSON syntax".to_string(), + JsonRejection::MissingJsonContentType(_) => { + "Content-Type must be application/json".to_string() + } + JsonRejection::BytesRejection(_) => "Failed to read request body".to_string(), + _ => "Invalid request body".to_string(), + } +} diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs index 05dfce6..ce73411 100644 --- a/src/api/identity/account.rs +++ b/src/api/identity/account.rs @@ -1,8 +1,10 @@ use super::did::verify_did_web; +use crate::api::error::ApiError; use crate::api::repo::record::utils::create_signed_commit; use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token}; use crate::plc::{PlcClient, create_genesis_operation, signing_key_to_did_key}; use crate::state::{AppState, RateLimitKind}; +use crate::types::{Did, Handle, PlainPassword}; use crate::validation::validate_password; use axum::{ Json, @@ -10,13 +12,13 @@ use axum::{ http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; +use serde_json::json; use bcrypt::{DEFAULT_COST, hash}; use jacquard::types::{integer::LimitedU32, string::Tid}; use jacquard_repo::{mst::Mst, storage::BlockStore}; use k256::{SecretKey, ecdsa::SigningKey}; use rand::rngs::OsRng; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::sync::Arc; use tracing::{debug, error, info, warn}; @@ -40,7 +42,7 @@ fn extract_client_ip(headers: &HeaderMap) -> String { pub struct CreateAccountInput { pub handle: String, pub email: Option, - pub password: String, + pub password: PlainPassword, pub invite_code: Option, pub did: Option, pub did_type: Option, @@ -54,8 +56,8 @@ pub struct CreateAccountInput { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateAccountOutput { - pub handle: String, - pub did: String, + pub handle: Handle, + pub did: Did, #[serde(skip_serializing_if = "Option::is_none")] pub did_doc: Option, pub access_jwt: String, @@ -88,14 +90,8 @@ pub async fn create_account( .await { warn!(ip = %client_ip, "Account creation rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many account creation attempts. Please try again later." - })), - ) - .into_response(); + return ApiError::RateLimitExceeded(Some("Too many account creation attempts. Please try again later.".into(),)) + .into_response(); } let migration_auth = if let Some(token) = @@ -113,14 +109,11 @@ pub async fn create_account( } Err(e) => { error!("Service token verification failed: {:?}", e); - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "AuthenticationFailed", - "message": format!("Service token verification failed: {}", e) - })), - ) - .into_response(); + return ApiError::AuthenticationFailed(Some(format!( + "Service token verification failed: {}", + e + ))) + .into_response(); } } } else { @@ -152,14 +145,11 @@ pub async fn create_account( "[MIGRATION] createAccount: Service token mismatch - token_did={} provided_did={}", auth_did, provided_did ); - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "AuthorizationError", - "message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did) - })), - ) - .into_response(); + return ApiError::AuthorizationError(format!( + "Service token issuer {} does not match DID {}", + auth_did, provided_did + )) + .into_response(); } if is_did_web_byod { info!(did = %provided_did, "Processing did:web BYOD account creation"); @@ -188,44 +178,26 @@ pub async fn create_account( }; match crate::api::validation::validate_short_handle(handle_to_validate) { Ok(h) => h, - Err(crate::api::validation::HandleValidationError::Reserved) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "HandleNotAvailable", "message": "Reserved handle"})), - ) - .into_response(); - } Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": e.to_string()})), - ) - .into_response(); + return ApiError::from(e).into_response(); } } } else { if input.handle.contains(' ') || input.handle.contains('\t') { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": "Handle cannot contain spaces"})), - ) - .into_response(); + return ApiError::InvalidRequest("Handle cannot contain spaces".into()).into_response(); } for c in input.handle.chars() { if !c.is_ascii_alphanumeric() && c != '.' && c != '-' { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": format!("Handle contains invalid character: {}", c)})), - ) - .into_response(); + return ApiError::InvalidRequest(format!( + "Handle contains invalid character: {}", + c + )) + .into_response(); } } let handle_lower = input.handle.to_lowercase(); if crate::moderation::has_explicit_slur(&handle_lower) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": "Inappropriate language in handle"})), - ) + return ApiError::InvalidRequest("Inappropriate language in handle".into()) .into_response(); } handle_lower @@ -238,20 +210,12 @@ pub async fn create_account( if let Some(ref email) = email && !crate::api::validation::is_valid_email(email) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), - ) - .into_response(); + return ApiError::InvalidEmail.into_response(); } let verification_channel = input.verification_channel.as_deref().unwrap_or("email"); let valid_channels = ["email", "discord", "telegram", "signal"]; if !valid_channels.contains(&verification_channel) && !is_migration { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel. Must be one of: email, discord, telegram, signal"})), - ) - .into_response(); + return ApiError::InvalidVerificationChannel.into_response(); } let verification_recipient = if is_migration { None @@ -259,36 +223,21 @@ pub async fn create_account( Some(match verification_channel { "email" => match &input.email { Some(email) if !email.trim().is_empty() => email.trim().to_string(), - _ => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})), - ).into_response(), + _ => return ApiError::MissingEmail.into_response(), }, "discord" => match &input.discord_id { Some(id) if !id.trim().is_empty() => id.trim().to_string(), - _ => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})), - ).into_response(), + _ => return ApiError::MissingDiscordId.into_response(), }, "telegram" => match &input.telegram_username { Some(username) if !username.trim().is_empty() => username.trim().to_string(), - _ => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})), - ).into_response(), + _ => return ApiError::MissingTelegramUsername.into_response(), }, "signal" => match &input.signal_number { Some(number) if !number.trim().is_empty() => number.trim().to_string(), - _ => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})), - ).into_response(), + _ => return ApiError::MissingSignalNumber.into_response(), }, - _ => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})), - ).into_response(), + _ => return ApiError::InvalidVerificationChannel.into_response(), }) }; let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); @@ -319,22 +268,11 @@ pub async fn create_account( match reserved { Ok(Some(row)) => (row.private_key_bytes, Some(row.id)), Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidSigningKey", - "message": "Signing key not found, already used, or expired" - })), - ) - .into_response(); + return ApiError::InvalidSigningKey.into_response(); } Err(e) => { error!("Error looking up reserved signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } } else { @@ -345,25 +283,14 @@ pub async fn create_account( Ok(k) => k, Err(e) => { error!("Error creating signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let did_type = input.did_type.as_deref().unwrap_or("plc"); let did = match did_type { "web" => { if !crate::api::server::meta::is_self_hosted_did_web_enabled() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "SelfHostedDidWebDisabled", - "message": "This PDS does not offer self-hosted did:web identities. Please use did:plc or bring your own did:web." - })), - ) - .into_response(); + return ApiError::SelfHostedDidWebDisabled.into_response(); } let subdomain_host = format!("{}.{}", input.handle, hostname); let encoded_subdomain = subdomain_host.replace(':', "%3A"); @@ -375,31 +302,21 @@ pub async fn create_account( let d = match &input.did { Some(d) if !d.trim().is_empty() => d, _ => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})), + return ApiError::InvalidRequest( + "External did:web requires the 'did' field to be provided".into(), ) - .into_response(); + .into_response(); } }; if !d.starts_with("did:web:") { - return ( - StatusCode::BAD_REQUEST, - Json( - json!({"error": "InvalidDid", "message": "External DID must be a did:web"}), - ), - ) + return ApiError::InvalidDid("External DID must be a did:web".into()) .into_response(); } if !is_did_web_byod && let Err(e) = verify_did_web(d, &hostname, &input.handle, input.signing_key.as_deref()).await { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidDid", "message": e})), - ) - .into_response(); + return ApiError::InvalidDid(e).into_response(); } info!(did = %d, "Creating external did:web account"); d.clone() @@ -419,19 +336,14 @@ pub async fn create_account( ) .await { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidDid", "message": e})), - ) - .into_response(); + return ApiError::InvalidDid(e).into_response(); } d.clone() } else if !d.trim().is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidDid", "message": "Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth."})), + return ApiError::InvalidDid( + "Only did:web DIDs can be provided; leave empty for did:plc. For migration with existing did:plc, provide service auth.".into() ) - .into_response(); + .into_response(); } else { let rotation_key = std::env::var("PLC_ROTATION_KEY") .unwrap_or_else(|_| signing_key_to_did_key(&signing_key)); @@ -444,11 +356,10 @@ pub async fn create_account( Ok(r) => r, Err(e) => { error!("Error creating PLC genesis operation: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})), - ) - .into_response(); + return ApiError::InternalError(Some( + "Failed to create PLC operation".into(), + )) + .into_response(); } }; let plc_client = PlcClient::with_cache(None, Some(state.cache.clone())); @@ -457,14 +368,11 @@ pub async fn create_account( .await { error!("Failed to submit PLC genesis operation: {:?}", e); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({ - "error": "UpstreamError", - "message": format!("Failed to register DID with PLC directory: {}", e) - })), - ) - .into_response(); + return ApiError::UpstreamErrorMsg(format!( + "Failed to register DID with PLC directory: {}", + e + )) + .into_response(); } info!(did = %genesis_result.did, "Successfully registered DID with PLC directory"); genesis_result.did @@ -481,11 +389,10 @@ pub async fn create_account( Ok(r) => r, Err(e) => { error!("Error creating PLC genesis operation: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})), - ) - .into_response(); + return ApiError::InternalError(Some( + "Failed to create PLC operation".into(), + )) + .into_response(); } }; let plc_client = PlcClient::with_cache(None, Some(state.cache.clone())); @@ -494,14 +401,11 @@ pub async fn create_account( .await { error!("Failed to submit PLC genesis operation: {:?}", e); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({ - "error": "UpstreamError", - "message": format!("Failed to register DID with PLC directory: {}", e) - })), - ) - .into_response(); + return ApiError::UpstreamErrorMsg(format!( + "Failed to register DID with PLC directory: {}", + e + )) + .into_response(); } info!(did = %genesis_result.did, "Successfully registered DID with PLC directory"); genesis_result.did @@ -512,11 +416,7 @@ pub async fn create_account( Ok(tx) => tx, Err(e) => { error!("Error starting transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if is_migration { @@ -542,26 +442,14 @@ pub async fn create_account( .map(|c| c.contains("handle")) .unwrap_or(false) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "HandleTaken", "message": "Handle already taken by another account"})), - ) - .into_response(); + return ApiError::HandleTaken.into_response(); } error!("Error reactivating account: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Err(e) = tx.commit().await { error!("Error committing reactivation: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let key_row: Option<(Vec, i32)> = sqlx::query_as( "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", @@ -576,21 +464,16 @@ pub async fn create_account( Ok(k) => k, Err(e) => { error!("Error decrypting key for reactivated account: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } } None => { error!("No signing key found for reactivated account"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Account signing key not found"})), - ) - .into_response(); + return ApiError::InternalError(Some( + "Account signing key not found".into(), + )) + .into_response(); } }; let access_meta = @@ -598,11 +481,7 @@ pub async fn create_account( Ok(m) => m, Err(e) => { error!("Error creating access token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let refresh_meta = match crate::auth::create_refresh_token_with_metadata( @@ -612,11 +491,7 @@ pub async fn create_account( Ok(m) => m, Err(e) => { error!("Error creating refresh token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let session_result: Result<_, sqlx::Error> = sqlx::query( @@ -631,17 +506,13 @@ pub async fn create_account( .await; if let Err(e) = session_result { error!("Error creating session: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } return ( - StatusCode::OK, + axum::http::StatusCode::OK, Json(CreateAccountOutput { - handle: handle.clone(), - did: did.clone(), + handle: handle.clone().into(), + did: did.clone().into(), did_doc: state.did_resolver.resolve_did_document(&did).await, access_jwt: access_meta.token, refresh_jwt: refresh_meta.token, @@ -651,11 +522,7 @@ pub async fn create_account( ) .into_response(); } else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "AccountAlreadyExists", "message": "An active account with this DID already exists"})), - ) - .into_response(); + return ApiError::AccountAlreadyExists.into_response(); } } } @@ -666,11 +533,7 @@ pub async fn create_account( .await .unwrap_or(None); if exists_result.is_some() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "HandleTaken", "message": "Handle already taken"})), - ) - .into_response(); + return ApiError::HandleTaken.into_response(); } let invite_code_required = std::env::var("INVITE_CODE_REQUIRED") .map(|v| v == "true" || v == "1") @@ -682,11 +545,7 @@ pub async fn create_account( .map(|c| c.trim().is_empty()) .unwrap_or(true) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidInviteCode", "message": "Invite code is required"})), - ) - .into_response(); + return ApiError::InviteCodeRequired.into_response(); } if let Some(code) = &input.invite_code && !code.trim().is_empty() @@ -700,7 +559,7 @@ pub async fn create_account( match invite_query { Ok(Some(row)) => { if row.available_uses <= 0 { - return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidInviteCode", "message": "Invite code exhausted"}))).into_response(); + return ApiError::InvalidInviteCode.into_response(); } let update_invite = sqlx::query!( "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1", @@ -710,39 +569,20 @@ pub async fn create_account( .await; if let Err(e) = update_invite { error!("Error updating invite code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidInviteCode", "message": "Invite code not found"})), - ) - .into_response(); + return ApiError::InvalidInviteCode.into_response(); } Err(e) => { error!("Error checking invite code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } } if let Err(e) = validate_password(&input.password) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidPassword", - "message": e.to_string() - })), - ) - .into_response(); + return ApiError::InvalidRequest(e.to_string()).into_response(); } let password_clone = input.password.clone(); @@ -751,19 +591,11 @@ pub async fn create_account( Ok(Ok(h)) => h, Ok(Err(e)) => { error!("Error hashing password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } Err(e) => { error!("Failed to spawn blocking task: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users") @@ -823,40 +655,15 @@ pub async fn create_account( { let constraint = db_err.constraint().unwrap_or(""); if constraint.contains("handle") || constraint.contains("users_handle") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "HandleNotAvailable", - "message": "Handle already taken" - })), - ) - .into_response(); + return ApiError::HandleNotAvailable(None).into_response(); } else if constraint.contains("email") || constraint.contains("users_email") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidEmail", - "message": "Email already registered" - })), - ) - .into_response(); + return ApiError::EmailTaken.into_response(); } else if constraint.contains("did") || constraint.contains("users_did") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "AccountAlreadyExists", - "message": "An account with this DID already exists" - })), - ) - .into_response(); + return ApiError::AccountAlreadyExists.into_response(); } } error!("Error inserting user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -864,11 +671,7 @@ pub async fn create_account( Ok(enc) => enc, Err(e) => { error!("Error encrypting user key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let key_insert = sqlx::query!( @@ -881,11 +684,7 @@ pub async fn create_account( .await; if let Err(e) = key_insert { error!("Error inserting user key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Some(key_id) = reserved_key_id { let mark_used = sqlx::query!( @@ -896,11 +695,7 @@ pub async fn create_account( .await; if let Err(e) = mark_used { error!("Error marking reserved key as used: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } let mst = Mst::new(Arc::new(state.block_store.clone())); @@ -908,11 +703,7 @@ pub async fn create_account( Ok(c) => c, Err(e) => { error!("Error persisting MST: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let rev = Tid::now(LimitedU32::MIN); @@ -921,22 +712,14 @@ pub async fn create_account( Ok(result) => result, Err(e) => { error!("Error creating genesis commit: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let commit_cid = match state.block_store.put(&commit_bytes).await { Ok(c) => c, Err(e) => { error!("Error saving genesis commit: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let commit_cid_str = commit_cid.to_string(); @@ -951,11 +734,7 @@ pub async fn create_account( .await; if let Err(e) = repo_insert { error!("Error initializing repo: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; if let Err(e) = sqlx::query!( @@ -971,11 +750,7 @@ pub async fn create_account( .await { error!("Error inserting user_blocks: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Some(code) = &input.invite_code && !code.trim().is_empty() @@ -989,11 +764,7 @@ pub async fn create_account( .await; if let Err(e) = use_insert { error!("Error recording invite usage: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() { @@ -1016,11 +787,7 @@ pub async fn create_account( } if let Err(e) = tx.commit().await { error!("Error committing transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if !is_migration && !is_did_web_byod { if let Err(e) = @@ -1117,11 +884,7 @@ pub async fn create_account( Ok(m) => m, Err(e) => { error!("createAccount: Error creating access token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let refresh_meta = @@ -1129,11 +892,7 @@ pub async fn create_account( Ok(m) => m, Err(e) => { error!("createAccount: Error creating refresh token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if let Err(e) = sqlx::query!( @@ -1148,11 +907,7 @@ pub async fn create_account( .await { error!("createAccount: Error creating session: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let did_doc = state.did_resolver.resolve_did_document(&did).await; @@ -1167,8 +922,8 @@ pub async fn create_account( ( StatusCode::OK, Json(CreateAccountOutput { - handle: handle.clone(), - did, + handle: handle.clone().into(), + did: did.into(), did_doc, access_jwt: access_meta.token, refresh_jwt: refresh_meta.token, diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs index e7a81b7..bf0d1be 100644 --- a/src/api/identity/did.rs +++ b/src/api/identity/did.rs @@ -1,4 +1,4 @@ -use crate::api::ApiError; +use crate::api::{ApiError, DidResponse, EmptyResponse}; use crate::plc::signing_key_to_did_key; use crate::state::AppState; use axum::{ @@ -34,15 +34,11 @@ pub async fn resolve_handle( ) -> Response { let handle = params.handle.trim(); if handle.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "handle is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("handle is required".into()).into_response(); } let cache_key = format!("handle:{}", handle); if let Some(did) = state.cache.get(&cache_key).await { - return (StatusCode::OK, Json(json!({ "did": did }))).into_response(); + return DidResponse::new(did).into_response(); } let user = sqlx::query!("SELECT did FROM users WHERE handle = $1", handle) .fetch_optional(&state.db) @@ -53,7 +49,7 @@ pub async fn resolve_handle( .cache .set(&cache_key, &row.did, std::time::Duration::from_secs(300)) .await; - (StatusCode::OK, Json(json!({ "did": row.did }))).into_response() + DidResponse::new(row.did).into_response() } Ok(None) => match crate::handle::resolve_handle(handle).await { Ok(did) => { @@ -61,21 +57,13 @@ pub async fn resolve_handle( .cache .set(&cache_key, &did, std::time::Duration::from_secs(300)) .await; - (StatusCode::OK, Json(json!({ "did": did }))).into_response() + DidResponse::new(did).into_response() } - Err(_) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "HandleNotFound", "message": "Unable to resolve handle"})), - ) - .into_response(), + Err(_) => ApiError::HandleNotFound.into_response(), }, Err(e) => { error!("DB error resolving handle: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -150,32 +138,21 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) let (user_id, did, migrated_to_pds) = match user { Ok(Some(row)) => (row.id, row.did, row.migrated_to_pds), Ok(None) => { - return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response(); + return ApiError::NotFoundMsg("User not found".into()).into_response(); } Err(e) => { error!("DB Error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if !did.starts_with("did:web:") { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "NotFound", "message": "User is not did:web"})), - ) - .into_response(); + return ApiError::NotFoundMsg("User is not did:web".into()).into_response(); } let subdomain_host = format!("{}.{}", handle, hostname); let encoded_subdomain = subdomain_host.replace(':', "%3A"); let expected_self_hosted = format!("did:web:{}", encoded_subdomain); if did != expected_self_hosted { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "NotFound", "message": "External did:web - DID document hosted by user"})), - ) + return ApiError::NotFoundMsg("External did:web - DID document hosted by user".into()) .into_response(); } @@ -235,30 +212,18 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str) Ok(Some(row)) => match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { Ok(k) => k, Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let public_key_multibase = match get_public_key_multibase(&key_bytes) { Ok(pk) => pk, Err(e) => { tracing::error!("Failed to generate public key multibase: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -307,23 +272,15 @@ pub async fn user_did_doc(State(state): State, Path(handle): Path (row.id, row.did, row.migrated_to_pds), Ok(None) => { - return (StatusCode::NOT_FOUND, Json(json!({"error": "NotFound"}))).into_response(); + return ApiError::NotFoundMsg("User not found".into()).into_response(); } Err(e) => { error!("DB Error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if !did.starts_with("did:web:") { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "NotFound", "message": "User is not did:web"})), - ) - .into_response(); + return ApiError::NotFoundMsg("User is not did:web".into()).into_response(); } let encoded_hostname = hostname.replace(':', "%3A"); let old_path_format = format!("did:web:{}:u:{}", encoded_hostname, handle); @@ -331,10 +288,7 @@ pub async fn user_did_doc(State(state): State, Path(handle): Path, Path(handle): Path match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { Ok(k) => k, Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let public_key_multibase = match get_public_key_multibase(&key_bytes) { Ok(pk) => pk, Err(e) => { tracing::error!("Failed to generate public key multibase: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -587,11 +529,7 @@ pub async fn get_recommended_did_credentials( ) { Some(t) => t, None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); + return ApiError::AuthenticationRequired.into_response(); } }; let auth_user = @@ -601,20 +539,20 @@ pub async fn get_recommended_did_credentials( }; let user = match sqlx::query!( "SELECT handle FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1", - auth_user.did + &auth_user.did ) .fetch_optional(&state.db) .await { Ok(Some(row)) => row, - _ => return ApiError::InternalError.into_response(), + _ => return ApiError::InternalError(None).into_response(), }; let key_bytes = match auth_user.key_bytes { Some(kb) => kb, None => { - return ApiError::AuthenticationFailedMsg( + return ApiError::AuthenticationFailed(Some( "OAuth tokens cannot get DID credentials".into(), - ) + )) .into_response(); } }; @@ -622,7 +560,7 @@ pub async fn get_recommended_did_credentials( let pds_endpoint = format!("https://{}", hostname); let signing_key = match k256::ecdsa::SigningKey::from_slice(&key_bytes) { Ok(k) => k, - Err(_) => return ApiError::InternalError.into_response(), + Err(_) => return ApiError::InternalError(None).into_response(), }; let did_key = signing_key_to_did_key(&signing_key); let rotation_keys = if auth_user.did.starts_with("did:web:") { @@ -689,28 +627,22 @@ pub async fn update_handle( .check_rate_limit(crate::state::RateLimitKind::HandleUpdate, &did) .await { - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({"error": "RateLimitExceeded", "message": "Too many handle updates. Try again later."})), - ) - .into_response(); + return ApiError::RateLimitExceeded(Some("Too many handle updates. Try again later.".into(),)) + .into_response(); } if !state .check_rate_limit(crate::state::RateLimitKind::HandleUpdateDaily, &did) .await { - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({"error": "RateLimitExceeded", "message": "Daily handle update limit exceeded."})), - ) + return ApiError::RateLimitExceeded(Some("Daily handle update limit exceeded.".into())) .into_response(); } - let user_row = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did) + let user_row = match sqlx::query!("SELECT id, handle FROM users WHERE did = $1", did.as_str()) .fetch_optional(&state.db) .await { Ok(Some(row)) => row, - _ => return ApiError::InternalError.into_response(), + _ => return ApiError::InternalError(None).into_response(), }; let user_id = user_row.id; let current_handle = user_row.handle; @@ -722,35 +654,21 @@ pub async fn update_handle( .chars() .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-') { - return ( - StatusCode::BAD_REQUEST, - Json( - json!({"error": "InvalidHandle", "message": "Handle contains invalid characters"}), - ), - ) + return ApiError::InvalidHandle(Some("Handle contains invalid characters".into())) .into_response(); } for segment in new_handle.split('.') { if segment.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": "Handle contains empty segment"})), - ) + return ApiError::InvalidHandle(Some("Handle contains empty segment".into())) .into_response(); } if segment.starts_with('-') || segment.ends_with('-') { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": "Handle segment cannot start or end with hyphen"})), - ) - .into_response(); + return ApiError::InvalidHandle(Some("Handle segment cannot start or end with hyphen".into(),)) + .into_response(); } } if crate::moderation::has_explicit_slur(&new_handle) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": "Inappropriate language in handle"})), - ) + return ApiError::InvalidHandle(Some("Inappropriate language in handle".into())) .into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); @@ -774,31 +692,17 @@ pub async fn update_handle( { warn!("Failed to sequence identity event for handle update: {}", e); } - return (StatusCode::OK, Json(json!({}))).into_response(); + return EmptyResponse::ok().into_response(); } if short_part.contains('.') { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidHandle", - "message": "Nested subdomains are not allowed. Use a simple handle without dots." - })), - ) - .into_response(); + return ApiError::InvalidHandle(Some("Nested subdomains are not allowed. Use a simple handle without dots.".into(),)) + .into_response(); } if short_part.len() < 3 { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": "Handle too short"})), - ) - .into_response(); + return ApiError::InvalidHandle(Some("Handle too short".into())).into_response(); } if short_part.len() > 18 { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": "Handle too long"})), - ) - .into_response(); + return ApiError::InvalidHandle(Some("Handle too long".into())).into_response(); } full_handle } else { @@ -809,41 +713,26 @@ pub async fn update_handle( { warn!("Failed to sequence identity event for handle update: {}", e); } - return (StatusCode::OK, Json(json!({}))).into_response(); + return EmptyResponse::ok().into_response(); } match crate::handle::verify_handle_ownership(&new_handle, &did).await { Ok(()) => {} Err(crate::handle::HandleResolutionError::NotFound) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "HandleNotAvailable", - "message": "Handle verification failed. Please set up DNS TXT record at _atproto.{} or serve your DID at https://{}/.well-known/atproto-did", - "handle": new_handle - })), - ) - .into_response(); + return ApiError::HandleNotAvailable(None).into_response(); } Err(crate::handle::HandleResolutionError::DidMismatch { expected, actual }) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "HandleNotAvailable", - "message": format!("Handle points to different DID. Expected {}, got {}", expected, actual) - })), - ) - .into_response(); + return ApiError::HandleNotAvailable(Some( + format!("Handle points to different DID. Expected {}, got {}", expected, actual), + )) + .into_response(); } Err(e) => { warn!("Handle verification failed: {}", e); - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "HandleNotAvailable", - "message": format!("Handle verification failed: {}", e) - })), - ) - .into_response(); + return ApiError::HandleNotAvailable(Some(format!( + "Handle verification failed: {}", + e + ))) + .into_response(); } } new_handle.clone() @@ -856,11 +745,7 @@ pub async fn update_handle( .fetch_optional(&state.db) .await; if let Ok(Some(_)) = existing { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "HandleTaken", "message": "Handle is already in use"})), - ) - .into_response(); + return ApiError::HandleTaken.into_response(); } let result = sqlx::query!( "UPDATE users SET handle = $1 WHERE id = $2", @@ -886,15 +771,11 @@ pub async fn update_handle( if let Err(e) = update_plc_handle(&state, &did, &handle).await { warn!("Failed to update PLC handle: {}", e); } - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } Err(e) => { error!("DB error updating handle: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } diff --git a/src/api/identity/plc/request.rs b/src/api/identity/plc/request.rs index 4d6e37a..62c4e54 100644 --- a/src/api/identity/plc/request.rs +++ b/src/api/identity/plc/request.rs @@ -1,13 +1,11 @@ -use crate::api::ApiError; +use crate::api::EmptyResponse; +use crate::api::error::ApiError; use crate::state::AppState; use axum::{ - Json, extract::State, - http::StatusCode, response::{IntoResponse, Response}, }; use chrono::{Duration, Utc}; -use serde_json::json; use tracing::{error, info, warn}; fn generate_plc_token() -> String { @@ -36,7 +34,7 @@ pub async fn request_plc_operation_signature( ) { return e; } - let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth_user.did) + let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", &auth_user.did) .fetch_optional(&state.db) .await { @@ -44,7 +42,7 @@ pub async fn request_plc_operation_signature( Ok(None) => return ApiError::AccountNotFound.into_response(), Err(e) => { error!("DB error: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; let _ = sqlx::query!( @@ -68,11 +66,7 @@ pub async fn request_plc_operation_signature( .await { error!("Failed to create PLC token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); if let Err(e) = @@ -84,5 +78,5 @@ pub async fn request_plc_operation_signature( "PLC operation signature requested for user {}", auth_user.did ); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } diff --git a/src/api/identity/plc/sign.rs b/src/api/identity/plc/sign.rs index 4e48858..736ecd0 100644 --- a/src/api/identity/plc/sign.rs +++ b/src/api/identity/plc/sign.rs @@ -1,8 +1,6 @@ use crate::api::ApiError; -use crate::circuit_breaker::{CircuitBreakerError, with_circuit_breaker}; -use crate::plc::{ - PlcClient, PlcError, PlcOpOrTombstone, PlcService, create_update_op, sign_operation, -}; +use crate::circuit_breaker::with_circuit_breaker; +use crate::plc::{PlcClient, PlcError, PlcService, create_update_op, sign_operation}; use crate::state::AppState; use axum::{ Json, @@ -13,9 +11,9 @@ use axum::{ use chrono::Utc; use k256::ecdsa::SigningKey; use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; +use serde_json::Value; use std::collections::HashMap; -use tracing::{error, info, warn}; +use tracing::{error, info}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -84,11 +82,7 @@ pub async fn sign_plc_operation( { Ok(Some(row)) => row, _ => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } }; let token_row = match sqlx::query!( @@ -101,22 +95,11 @@ pub async fn sign_plc_operation( { Ok(Some(row)) => row, Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidToken", - "message": "Invalid or expired token" - })), - ) - .into_response(); + return ApiError::InvalidToken(Some("Invalid or expired token".into())).into_response(); } Err(e) => { error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if Utc::now() > token_row.expires_at { @@ -126,14 +109,7 @@ pub async fn sign_plc_operation( ) .execute(&state.db) .await; - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "ExpiredToken", - "message": "Token has expired" - })), - ) - .into_response(); + return ApiError::ExpiredToken(Some("Token has expired".into())).into_response(); } let key_row = match sqlx::query!( "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1", @@ -144,11 +120,7 @@ pub async fn sign_plc_operation( { Ok(Some(row)) => row, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "User signing key not found"})), - ) - .into_response(); + return ApiError::InternalError(Some("User signing key not found".into())).into_response(); } }; let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) @@ -156,75 +128,28 @@ pub async fn sign_plc_operation( Ok(k) => k, Err(e) => { error!("Failed to decrypt user key: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let signing_key = match SigningKey::from_slice(&key_bytes) { Ok(k) => k, Err(e) => { error!("Failed to create signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let plc_client = PlcClient::with_cache(None, Some(state.cache.clone())); let did_clone = did.clone(); - let result: Result> = - with_circuit_breaker(&state.circuit_breakers.plc_directory, || async { - plc_client.get_last_op(&did_clone).await - }) - .await; - let last_op = match result { + let last_op = match with_circuit_breaker(&state.circuit_breakers.plc_directory, || async { + plc_client.get_last_op(&did_clone).await + }) + .await + { Ok(op) => op, - Err(CircuitBreakerError::CircuitOpen(e)) => { - warn!("PLC directory circuit breaker open: {}", e); - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ - "error": "ServiceUnavailable", - "message": "PLC directory service temporarily unavailable" - })), - ) - .into_response(); - } - Err(CircuitBreakerError::OperationFailed(PlcError::NotFound)) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({ - "error": "NotFound", - "message": "DID not found in PLC directory" - })), - ) - .into_response(); - } - Err(CircuitBreakerError::OperationFailed(e)) => { - error!("Failed to fetch PLC operation: {:?}", e); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({ - "error": "UpstreamError", - "message": "Failed to communicate with PLC directory" - })), - ) - .into_response(); - } + Err(e) => return ApiError::from(e).into_response(), }; if last_op.is_tombstone() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "DID is tombstoned" - })), - ) - .into_response(); + return ApiError::from(PlcError::Tombstoned).into_response(); } let services = input.services.map(|s| { s.into_iter() @@ -248,33 +173,18 @@ pub async fn sign_plc_operation( ) { Ok(op) => op, Err(PlcError::Tombstoned) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Cannot update tombstoned DID" - })), - ) - .into_response(); + return ApiError::InvalidRequest("Cannot update tombstoned DID".into()).into_response(); } Err(e) => { error!("Failed to create PLC operation: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let signed_op = match sign_operation(&unsigned_op, &signing_key) { Ok(op) => op, Err(e) => { error!("Failed to sign PLC operation: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let _ = sqlx::query!( diff --git a/src/api/identity/plc/submit.rs b/src/api/identity/plc/submit.rs index db6f607..0f68af8 100644 --- a/src/api/identity/plc/submit.rs +++ b/src/api/identity/plc/submit.rs @@ -1,16 +1,15 @@ -use crate::api::ApiError; -use crate::circuit_breaker::{CircuitBreakerError, with_circuit_breaker}; -use crate::plc::{PlcClient, PlcError, signing_key_to_did_key, validate_plc_operation}; +use crate::api::{ApiError, EmptyResponse}; +use crate::circuit_breaker::with_circuit_breaker; +use crate::plc::{PlcClient, signing_key_to_did_key, validate_plc_operation}; use crate::state::AppState; use axum::{ Json, extract::State, - http::StatusCode, response::{IntoResponse, Response}, }; use k256::ecdsa::SigningKey; use serde::Deserialize; -use serde_json::{Value, json}; +use serde_json::Value; use tracing::{error, info, warn}; #[derive(Debug, Deserialize)] @@ -64,11 +63,7 @@ pub async fn submit_plc_operation( { Ok(Some(row)) => row, _ => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } }; let key_row = match sqlx::query!( @@ -80,11 +75,7 @@ pub async fn submit_plc_operation( { Ok(Some(row)) => row, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "User signing key not found"})), - ) - .into_response(); + return ApiError::InternalError(Some("User signing key not found".into())).into_response(); } }; let key_bytes = match crate::config::decrypt_key(&key_row.key_bytes, key_row.encryption_version) @@ -92,22 +83,14 @@ pub async fn submit_plc_operation( Ok(k) => k, Err(e) => { error!("Failed to decrypt user key: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let signing_key = match SigningKey::from_slice(&key_bytes) { Ok(k) => k, Err(e) => { error!("Failed to create signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let user_did_key = signing_key_to_did_key(&signing_key); @@ -118,14 +101,10 @@ pub async fn submit_plc_operation( .iter() .any(|k| k.as_str() == Some(&server_rotation_key)); if !has_server_key { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Rotation keys do not include server's rotation key" - })), + return ApiError::InvalidRequest( + "Rotation keys do not include server's rotation key".into(), ) - .into_response(); + .into_response(); } } if let Some(services) = op.get("services").and_then(|v| v.as_object()) @@ -134,23 +113,11 @@ pub async fn submit_plc_operation( let service_type = pds.get("type").and_then(|v| v.as_str()); let endpoint = pds.get("endpoint").and_then(|v| v.as_str()); if service_type != Some("AtprotoPersonalDataServer") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Incorrect type on atproto_pds service" - })), - ) + return ApiError::InvalidRequest("Incorrect type on atproto_pds service".into()) .into_response(); } if endpoint != Some(&public_url) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Incorrect endpoint on atproto_pds service" - })), - ) + return ApiError::InvalidRequest("Incorrect endpoint on atproto_pds service".into()) .into_response(); } } @@ -158,13 +125,7 @@ pub async fn submit_plc_operation( && let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str()) && atproto_key != user_did_key { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Incorrect signing key in verificationMethods" - })), - ) + return ApiError::InvalidRequest("Incorrect signing key in verificationMethods".into()) .into_response(); } if let Some(also_known_as) = (!user.handle.is_empty()) @@ -174,50 +135,21 @@ pub async fn submit_plc_operation( let expected_handle = format!("at://{}", user.handle); let first_aka = also_known_as.first().and_then(|v| v.as_str()); if first_aka != Some(&expected_handle) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Incorrect handle in alsoKnownAs" - })), - ) + return ApiError::InvalidRequest("Incorrect handle in alsoKnownAs".into()) .into_response(); } } let plc_client = PlcClient::with_cache(None, Some(state.cache.clone())); let operation_clone = input.operation.clone(); let did_clone = did.clone(); - let result: Result<(), CircuitBreakerError> = - with_circuit_breaker(&state.circuit_breakers.plc_directory, || async { - plc_client - .send_operation(&did_clone, &operation_clone) - .await - }) - .await; - match result { - Ok(()) => {} - Err(CircuitBreakerError::CircuitOpen(e)) => { - warn!("PLC directory circuit breaker open: {}", e); - return ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ - "error": "ServiceUnavailable", - "message": "PLC directory service temporarily unavailable" - })), - ) - .into_response(); - } - Err(CircuitBreakerError::OperationFailed(e)) => { - error!("PLC operation failed: {:?}", e); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({ - "error": "UpstreamError", - "message": format!("Failed to submit to PLC directory: {}", e) - })), - ) - .into_response(); - } + if let Err(e) = with_circuit_breaker(&state.circuit_breakers.plc_directory, || async { + plc_client + .send_operation(&did_clone, &operation_clone) + .await + }) + .await + { + return ApiError::from(e).into_response(); } match sqlx::query!( "INSERT INTO repo_seq (did, event_type, handle) VALUES ($1, 'identity', $2) RETURNING seq", @@ -244,5 +176,5 @@ pub async fn submit_plc_operation( warn!(did = %did, "Failed to refresh DID cache after PLC update"); } info!(did = %did, "PLC operation submitted successfully"); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } diff --git a/src/api/mod.rs b/src/api/mod.rs index 41a395f..3ea628e 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -10,10 +10,15 @@ pub mod notification_prefs; pub mod proxy; pub mod proxy_client; pub mod repo; +pub mod responses; pub mod server; pub mod temp; pub mod validation; pub mod verification; pub use error::ApiError; +pub use responses::{ + DidResponse, EmptyResponse, EnabledResponse, HasPasswordResponse, OptionsResponse, + StatusResponse, SuccessResponse, TokenRequiredResponse, VerifiedResponse, +}; pub use proxy_client::{AtUriParts, proxy_client, validate_at_uri, validate_did, validate_limit}; diff --git a/src/api/moderation/mod.rs b/src/api/moderation/mod.rs index 3ff8c60..9d9b244 100644 --- a/src/api/moderation/mod.rs +++ b/src/api/moderation/mod.rs @@ -64,7 +64,7 @@ pub async fn create_report( .await; } - create_report_locally(&state, did, auth_user.is_takendown, input).await + create_report_locally(&state, did, auth_user.is_takendown(), input).await } async fn proxy_to_report_service( @@ -76,10 +76,7 @@ async fn proxy_to_report_service( ) -> Response { if let Err(e) = is_ssrf_safe(service_url) { error!("Report service URL failed SSRF check: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Invalid report service configuration"})), - ) + return ApiError::InternalError(Some("Invalid report service configuration".into())) .into_response(); } @@ -101,20 +98,20 @@ async fn proxy_to_report_service( Ok(key) => key, Err(e) => { error!(error = ?e, "Failed to decrypt user key for report service auth"); - return ApiError::AuthenticationFailedMsg( + return ApiError::AuthenticationFailed(Some( "Failed to get signing key".into(), - ) + )) .into_response(); } } } Ok(None) => { - return ApiError::AuthenticationFailedMsg("User has no signing key".into()) + return ApiError::AuthenticationFailed(Some("User has no signing key".into())) .into_response(); } Err(e) => { error!(error = ?e, "DB error fetching user key for report"); - return ApiError::AuthenticationFailedMsg("Failed to get signing key".into()) + return ApiError::AuthenticationFailed(Some("Failed to get signing key".into())) .into_response(); } } @@ -130,11 +127,7 @@ async fn proxy_to_report_service( Ok(t) => t, Err(e) => { error!("Failed to create service token for report: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -208,10 +201,7 @@ async fn create_report_locally( const REASON_APPEAL: &str = "com.atproto.moderation.defs#reasonAppeal"; if is_takendown && input.reason_type != REASON_APPEAL { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Report not accepted from takendown account"})), - ) + return ApiError::InvalidRequest("Report not accepted from takendown account".into()) .into_response(); } @@ -226,11 +216,7 @@ async fn create_report_locally( ]; if !valid_reason_types.contains(&input.reason_type.as_str()) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Invalid reasonType"})), - ) - .into_response(); + return ApiError::InvalidRequest("Invalid reasonType".into()).into_response(); } let created_at = chrono::Utc::now(); @@ -251,11 +237,7 @@ async fn create_report_locally( if let Err(e) = insert { error!("Failed to insert report: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } info!( diff --git a/src/api/notification_prefs.rs b/src/api/notification_prefs.rs index 4ad793c..17be0cc 100644 --- a/src/api/notification_prefs.rs +++ b/src/api/notification_prefs.rs @@ -1,9 +1,10 @@ +use crate::api::error::ApiError; use crate::auth::validate_bearer_token; use crate::state::AppState; use axum::{ Json, extract::State, - http::{HeaderMap, StatusCode}, + http::HeaderMap, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; @@ -29,20 +30,12 @@ pub async fn get_notification_prefs(State(state): State, headers: Head headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, - None => return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), - ) - .into_response(), + None => return ApiError::AuthenticationRequired.into_response(), }; let user = match validate_bearer_token(&state.db, &token).await { Ok(u) => u, Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})), - ) - .into_response(); + return ApiError::AuthenticationFailed(None).into_response(); } }; let row = @@ -66,13 +59,9 @@ pub async fn get_notification_prefs(State(state): State, headers: Head .await { Ok(r) => r, - Err(e) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json( - json!({"error": "InternalError", "message": format!("Database error: {}", e)}), - ), - ) - .into_response(), + Err(e) => { + return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response() + } }; let email: String = row.get("email"); let channel: String = row.get("channel"); @@ -120,36 +109,24 @@ pub async fn get_notification_history( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, - None => return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), - ) - .into_response(), + None => return ApiError::AuthenticationRequired.into_response(), }; let user = match validate_bearer_token(&state.db, &token).await { Ok(u) => u, Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})), - ) - .into_response(); + return ApiError::AuthenticationFailed(None).into_response(); } }; let user_id: uuid::Uuid = - match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", user.did) + match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", &user.did) .fetch_one(&state.db) .await { Ok(id) => id, - Err(e) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json( - json!({"error": "InternalError", "message": format!("Database error: {}", e)}), - ), - ) - .into_response(), + Err(e) => { + return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response() + } }; let rows = @@ -173,13 +150,9 @@ pub async fn get_notification_history( .await { Ok(r) => r, - Err(e) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json( - json!({"error": "InternalError", "message": format!("Database error: {}", e)}), - ), - ) - .into_response(), + Err(e) => { + return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response() + } }; let sensitive_types = [ @@ -288,39 +261,27 @@ pub async fn update_notification_prefs( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) { Some(t) => t, - None => return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired", "message": "Authentication required"})), - ) - .into_response(), + None => return ApiError::AuthenticationRequired.into_response(), }; let user = match validate_bearer_token(&state.db, &token).await { Ok(u) => u, Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid token"})), - ) - .into_response(); + return ApiError::AuthenticationFailed(None).into_response(); } }; let user_row = match sqlx::query!( "SELECT id, handle, email FROM users WHERE did = $1", - user.did + &user.did ) .fetch_one(&state.db) .await { Ok(row) => row, - Err(e) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json( - json!({"error": "InternalError", "message": format!("Database error: {}", e)}), - ), - ) - .into_response(), + Err(e) => { + return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response() + } }; let user_id = user_row.id; @@ -332,14 +293,10 @@ pub async fn update_notification_prefs( if let Some(ref channel) = input.preferred_channel { let valid_channels = ["email", "discord", "telegram", "signal"]; if !valid_channels.contains(&channel.as_str()) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Invalid channel. Must be one of: email, discord, telegram, signal" - })), + return ApiError::InvalidRequest( + "Invalid channel. Must be one of: email, discord, telegram, signal".into(), ) - .into_response(); + .into_response(); } if let Err(e) = sqlx::query( r#"UPDATE users SET preferred_comms_channel = $1::comms_channel, updated_at = NOW() WHERE did = $2"# @@ -349,11 +306,7 @@ pub async fn update_notification_prefs( .execute(&state.db) .await { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), - ) - .into_response(); + return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response(); } info!(did = %user.did, channel = %channel, "Updated preferred notification channel"); } @@ -361,19 +314,11 @@ pub async fn update_notification_prefs( if let Some(ref new_email) = input.email { let email_clean = new_email.trim().to_lowercase(); if email_clean.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Email cannot be empty"})), - ) - .into_response(); + return ApiError::InvalidRequest("Email cannot be empty".into()).into_response(); } if !crate::api::validation::is_valid_email(&email_clean) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), - ) - .into_response(); + return ApiError::InvalidEmail.into_response(); } if current_email.as_ref().map(|e| e.to_lowercase()) == Some(email_clean.clone()) { @@ -388,11 +333,7 @@ pub async fn update_notification_prefs( .await; if let Ok(Some(_)) = exists { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "EmailTaken", "message": "Email already in use"})), - ) - .into_response(); + return ApiError::EmailTaken.into_response(); } if let Err(e) = request_channel_verification( @@ -405,11 +346,7 @@ pub async fn update_notification_prefs( ) .await { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": e})), - ) - .into_response(); + return ApiError::InternalError(Some(e)).into_response(); } verification_required.push("email".to_string()); info!(did = %user.did, "Requested email verification"); @@ -425,11 +362,7 @@ pub async fn update_notification_prefs( .execute(&state.db) .await { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), - ) - .into_response(); + return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response(); } info!(did = %user.did, "Cleared Discord ID"); } else { @@ -438,11 +371,7 @@ pub async fn update_notification_prefs( ) .await { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": e})), - ) - .into_response(); + return ApiError::InternalError(Some(e)).into_response(); } verification_required.push("discord".to_string()); info!(did = %user.did, "Requested Discord verification"); @@ -459,11 +388,7 @@ pub async fn update_notification_prefs( .execute(&state.db) .await { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), - ) - .into_response(); + return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response(); } info!(did = %user.did, "Cleared Telegram username"); } else { @@ -477,11 +402,7 @@ pub async fn update_notification_prefs( ) .await { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": e})), - ) - .into_response(); + return ApiError::InternalError(Some(e)).into_response(); } verification_required.push("telegram".to_string()); info!(did = %user.did, "Requested Telegram verification"); @@ -497,11 +418,7 @@ pub async fn update_notification_prefs( .execute(&state.db) .await { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Database error: {}", e)})), - ) - .into_response(); + return ApiError::InternalError(Some(format!("Database error: {}", e))).into_response(); } info!(did = %user.did, "Cleared Signal number"); } else { @@ -509,11 +426,7 @@ pub async fn update_notification_prefs( request_channel_verification(&state.db, user_id, &user.did, "signal", signal, None) .await { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": e})), - ) - .into_response(); + return ApiError::InternalError(Some(e)).into_response(); } verification_required.push("signal".to_string()); info!(did = %user.did, "Requested Signal verification"); diff --git a/src/api/proxy.rs b/src/api/proxy.rs index 370e623..1ea3cab 100644 --- a/src/api/proxy.rs +++ b/src/api/proxy.rs @@ -1,9 +1,9 @@ use std::convert::Infallible; +use crate::api::error::ApiError; use crate::api::proxy_client::proxy_client; use crate::state::AppState; use axum::{ - Json, body::Bytes, extract::{RawQuery, Request, State}, handler::Handler, @@ -11,7 +11,6 @@ use axum::{ response::{IntoResponse, Response}, }; use futures_util::future::Either; -use serde_json::json; use tower::{Service, util::BoxCloneSyncService}; use tracing::{error, info, warn}; @@ -120,44 +119,23 @@ async fn proxy_handler( let method = uri.path().trim_start_matches("/"); if is_protected_method(&method) { warn!(method = %method, "Attempted to proxy protected method"); - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Cannot proxy protected method: {}", method) - })), - ) + return ApiError::InvalidRequest(format!("Cannot proxy protected method: {}", method)) .into_response(); } - let proxy_header = match headers.get("atproto-proxy").and_then(|h| h.to_str().ok()) { - Some(h) => h.to_string(), - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Missing required atproto-proxy header" - })), - ) - .into_response(); - } + let Some(proxy_header) = headers + .get("atproto-proxy") + .and_then(|h| h.to_str().ok()) + .map(String::from) + else { + return ApiError::InvalidRequest("Missing required atproto-proxy header".into()) + .into_response(); }; let did = proxy_header.split('#').next().unwrap_or(&proxy_header); - let resolved = match state.did_resolver.resolve_did(did).await { - Some(r) => r, - None => { - error!(did = %did, "Could not resolve service DID"); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({ - "error": "UpstreamFailure", - "message": "Could not resolve service DID" - })), - ) - .into_response(); - } + let Some(resolved) = state.did_resolver.resolve_did(did).await else { + error!(did = %did, "Could not resolve service DID"); + return ApiError::UpstreamFailure.into_response(); }; let target_url = match &query { @@ -220,14 +198,8 @@ async fn proxy_handler( "{} error=\"invalid_token\", error_description=\"Token has expired\"", scheme ); - let mut response = ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "ExpiredToken", - "message": "Token has expired" - })), - ) - .into_response(); + let mut response = + ApiError::ExpiredToken(Some("Token has expired".into())).into_response(); response .headers_mut() .insert("WWW-Authenticate", www_auth.parse().unwrap()); diff --git a/src/api/repo/blob.rs b/src/api/repo/blob.rs index b0cbe0a..f2010fa 100644 --- a/src/api/repo/blob.rs +++ b/src/api/repo/blob.rs @@ -1,3 +1,4 @@ +use crate::api::error::ApiError; use crate::auth::{ServiceTokenVerifier, is_service_token}; use crate::delegation::{self, DelegationActionType}; use crate::state::AppState; @@ -23,17 +24,10 @@ pub async fn upload_blob( headers: axum::http::HeaderMap, body: Body, ) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( + let Some(token) = crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + ) else { + return ApiError::AuthenticationRequired.into_response(); }; let is_service_auth = is_service_token(&token); @@ -51,11 +45,11 @@ pub async fn upload_blob( } Err(e) => { error!("Service token verification failed: {:?}", e); - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": format!("Service token verification failed: {}", e)})), - ) - .into_response(); + return ApiError::AuthenticationFailed(Some(format!( + "Service token verification failed: {}", + e + ))) + .into_response(); } } } else { @@ -74,22 +68,18 @@ pub async fn upload_blob( } let deactivated = sqlx::query_scalar!( "SELECT deactivated_at FROM users WHERE did = $1", - user.did + &user.did ) .fetch_optional(&state.db) .await .ok() .flatten() .flatten(); - let ctrl_did = user.controller_did.clone(); - (user.did, deactivated.is_some(), ctrl_did) + let ctrl_did = user.controller_did.map(|d| d.to_string()); + (user.did.to_string(), deactivated.is_some(), ctrl_did) } Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); + return ApiError::AuthenticationFailed(None).into_response(); } } }; @@ -98,14 +88,7 @@ pub async fn upload_blob( .await .unwrap_or(false) { - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "AccountMigrated", - "message": "Account has been migrated to another PDS. Blob operations are not allowed." - })), - ) - .into_response(); + return ApiError::Forbidden.into_response(); } let mime_type = headers @@ -120,11 +103,7 @@ pub async fn upload_blob( let user_id = match user_query { Ok(Some(row)) => row.id, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -143,22 +122,18 @@ pub async fn upload_blob( Ok(result) => result, Err(e) => { error!("Failed to stream blob to storage: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to store blob"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to store blob".into())).into_response(); } }; let size = upload_result.size; if size > max_size { let _ = state.blob_store.delete(&temp_key).await; - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json(json!({"error": "BlobTooLarge", "message": format!("Blob size {} exceeds maximum of {} bytes", size, max_size)})), - ) - .into_response(); + return ApiError::InvalidRequest(format!( + "Blob size {} exceeds maximum of {} bytes", + size, max_size + )) + .into_response(); } let multihash = match Multihash::wrap(0x12, &upload_result.sha256_hash) { @@ -166,11 +141,7 @@ pub async fn upload_blob( Err(e) => { let _ = state.blob_store.delete(&temp_key).await; error!("Failed to create multihash for blob: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to hash blob"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to hash blob".into())).into_response(); } }; let cid = Cid::new_v1(0x55, multihash); @@ -187,11 +158,7 @@ pub async fn upload_blob( Err(e) => { let _ = state.blob_store.delete(&temp_key).await; error!("Failed to begin transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -212,22 +179,14 @@ pub async fn upload_blob( Err(e) => { let _ = state.blob_store.delete(&temp_key).await; error!("Failed to insert blob record: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if was_inserted && let Err(e) = state.blob_store.copy(&temp_key, &storage_key).await { let _ = state.blob_store.delete(&temp_key).await; error!("Failed to copy blob to final location: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to store blob"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to store blob".into())).into_response(); } let _ = state.blob_store.delete(&temp_key).await; @@ -240,11 +199,7 @@ pub async fn upload_blob( storage_key, cleanup_err ); } - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Some(ref controller) = controller_did { @@ -303,41 +258,26 @@ pub async fn list_missing_blobs( headers: axum::http::HeaderMap, Query(params): Query, ) -> Response { - let token = match crate::auth::extract_bearer_token_from_header( + let Some(token) = crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + ) else { + return ApiError::AuthenticationRequired.into_response(); }; let auth_user = match crate::auth::validate_bearer_token_allow_deactivated(&state.db, &token).await { Ok(user) => user, Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); + return ApiError::AuthenticationFailed(None).into_response(); } }; let did = auth_user.did; - let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did) + let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did.as_str()) .fetch_optional(&state.db) .await; let user_id = match user_query { Ok(Some(row)) => row.id, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let limit = params.limit.unwrap_or(500).clamp(1, 1000); @@ -361,11 +301,7 @@ pub async fn list_missing_blobs( Ok(r) => r, Err(e) => { error!("DB error fetching missing blobs: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let has_more = rows.len() > limit as usize; diff --git a/src/api/repo/import.rs b/src/api/repo/import.rs index e22f326..ab3c0dc 100644 --- a/src/api/repo/import.rs +++ b/src/api/repo/import.rs @@ -1,13 +1,12 @@ -use crate::api::ApiError; +use crate::api::error::ApiError; use crate::api::repo::record::create_signed_commit; +use crate::api::EmptyResponse; use crate::state::AppState; use crate::sync::import::{ImportError, apply_import, parse_car}; use crate::sync::verify::CarVerifier; use axum::{ - Json, body::Bytes, extract::State, - http::StatusCode, response::{IntoResponse, Response}, }; use jacquard::types::{integer::LimitedU32, string::Tid}; @@ -28,13 +27,7 @@ pub async fn import_repo( .map(|v| v != "false" && v != "0") .unwrap_or(true); if !accepting_imports { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Service is not accepting repo imports" - })), - ) + return ApiError::InvalidRequest("Service is not accepting repo imports".into()) .into_response(); } let max_size: usize = std::env::var("MAX_IMPORT_SIZE") @@ -42,14 +35,11 @@ pub async fn import_repo( .and_then(|s| s.parse().ok()) .unwrap_or(DEFAULT_MAX_IMPORT_SIZE); if body.len() > max_size { - return ( - StatusCode::PAYLOAD_TOO_LARGE, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Import size exceeds limit of {} bytes", max_size) - })), - ) - .into_response(); + return ApiError::PayloadTooLarge(format!( + "Import size exceeds limit of {} bytes", + max_size + )) + .into_response(); } let token = match crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), @@ -72,64 +62,30 @@ pub async fn import_repo( { Ok(Some(row)) => row, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error fetching user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if user.takedown_ref.is_some() { - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "AccountTakenDown", - "message": "Account has been taken down" - })), - ) - .into_response(); + return ApiError::AccountTakedown.into_response(); } let user_id = user.id; let (root, blocks) = match parse_car(&body).await { Ok((r, b)) => (r, b), Err(ImportError::InvalidRootCount) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Expected exactly one root in CAR file" - })), - ) + return ApiError::InvalidRequest("Expected exactly one root in CAR file".into()) .into_response(); } Err(ImportError::CarParse(msg)) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Failed to parse CAR file: {}", msg) - })), - ) + return ApiError::InvalidRequest(format!("Failed to parse CAR file: {}", msg)) .into_response(); } Err(e) => { error!("CAR parsing error: {:?}", e); - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Invalid CAR file: {}", e) - })), - ) - .into_response(); + return ApiError::InvalidRequest(format!("Invalid CAR file: {}", e)).into_response(); } }; info!( @@ -138,44 +94,21 @@ pub async fn import_repo( blocks.len(), root ); - let root_block = match blocks.get(&root) { - Some(b) => b, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Root block not found in CAR file" - })), - ) - .into_response(); - } + let Some(root_block) = blocks.get(&root) else { + return ApiError::InvalidRequest("Root block not found in CAR file".into()).into_response(); }; let commit_did = match jacquard_repo::commit::Commit::from_cbor(root_block) { Ok(commit) => commit.did().to_string(), Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Invalid commit: {}", e) - })), - ) - .into_response(); + return ApiError::InvalidRequest(format!("Invalid commit: {}", e)).into_response(); } }; if commit_did != *did { - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "InvalidRequest", - "message": format!( - "CAR file is for DID {} but you are authenticated as {}", - commit_did, did - ) - })), - ) - .into_response(); + return ApiError::InvalidRepo(format!( + "CAR file is for DID {} but you are authenticated as {}", + commit_did, did + )) + .into_response(); } let skip_verification = std::env::var("SKIP_IMPORT_VERIFICATION") .map(|v| v == "true" || v == "1") @@ -197,37 +130,19 @@ pub async fn import_repo( commit_did, expected_did, }) => { - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "InvalidRequest", - "message": format!( - "CAR file is for DID {} but you are authenticated as {}", - commit_did, expected_did - ) - })), - ) - .into_response(); + return ApiError::InvalidRepo(format!( + "CAR file is for DID {} but you are authenticated as {}", + commit_did, expected_did + )) + .into_response(); } Err(crate::sync::verify::VerifyError::MstValidationFailed(msg)) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("MST validation failed: {}", msg) - })), - ) + return ApiError::InvalidRequest(format!("MST validation failed: {}", msg)) .into_response(); } Err(e) => { error!("CAR structure verification error: {:?}", e); - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("CAR verification failed: {}", e) - })), - ) + return ApiError::InvalidRequest(format!("CAR verification failed: {}", e)) .into_response(); } } @@ -245,68 +160,36 @@ pub async fn import_repo( commit_did, expected_did, }) => { - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "InvalidRequest", - "message": format!( - "CAR file is for DID {} but you are authenticated as {}", - commit_did, expected_did - ) - })), - ) - .into_response(); + return ApiError::InvalidRepo(format!( + "CAR file is for DID {} but you are authenticated as {}", + commit_did, expected_did + )) + .into_response(); } Err(crate::sync::verify::VerifyError::InvalidSignature) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidSignature", - "message": "CAR file commit signature verification failed" - })), + return ApiError::InvalidRequest( + "CAR file commit signature verification failed".into(), ) - .into_response(); + .into_response(); } Err(crate::sync::verify::VerifyError::DidResolutionFailed(msg)) => { warn!("DID resolution failed during import verification: {}", msg); - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Failed to verify DID: {}", msg) - })), - ) + return ApiError::InvalidRequest(format!("Failed to verify DID: {}", msg)) .into_response(); } Err(crate::sync::verify::VerifyError::NoSigningKey) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "DID document does not contain a signing key" - })), + return ApiError::InvalidRequest( + "DID document does not contain a signing key".into(), ) - .into_response(); + .into_response(); } Err(crate::sync::verify::VerifyError::MstValidationFailed(msg)) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("MST validation failed: {}", msg) - })), - ) + return ApiError::InvalidRequest(format!("MST validation failed: {}", msg)) .into_response(); } Err(e) => { error!("CAR verification error: {:?}", e); - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("CAR verification failed: {}", e) - })), - ) + return ApiError::InvalidRequest(format!("CAR verification failed: {}", e)) .into_response(); } } @@ -364,19 +247,12 @@ pub async fn import_repo( Ok(Some(row)) => row, Ok(None) => { error!("No signing key found for user {}", did); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Signing key not found"})), - ) + return ApiError::InternalError(Some("Signing key not found".into())) .into_response(); } Err(e) => { error!("DB error fetching signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let key_bytes = @@ -384,22 +260,14 @@ pub async fn import_repo( Ok(k) => k, Err(e) => { error!("Failed to decrypt signing key: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let signing_key = match SigningKey::from_slice(&key_bytes) { Ok(k) => k, Err(e) => { error!("Invalid signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let new_rev = Tid::now(LimitedU32::MIN); @@ -414,22 +282,14 @@ pub async fn import_repo( Ok(result) => result, Err(e) => { error!("Failed to create new commit: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let new_root_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { Ok(cid) => cid, Err(e) => { error!("Failed to store new commit block: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let new_root_str = new_root_cid.to_string(); @@ -443,11 +303,7 @@ pub async fn import_repo( .await { error!("Failed to update repo root: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let mut all_block_cids: Vec> = blocks.keys().map(|c| c.to_bytes()).collect(); all_block_cids.push(new_root_cid.to_bytes()); @@ -464,11 +320,7 @@ pub async fn import_repo( .await { error!("Failed to insert user_blocks: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } info!( "Created new commit for imported repo: cid={}, rev={}", @@ -499,79 +351,41 @@ pub async fn import_repo( ); } } - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() + } + Err(ImportError::SizeLimitExceeded) => { + ApiError::PayloadTooLarge(format!("Import exceeds block limit of {}", max_blocks)) + .into_response() + } + Err(ImportError::RepoNotFound) => { + ApiError::RepoNotFound(Some("Repository not initialized for this account".into())) + .into_response() + } + Err(ImportError::InvalidCbor(msg)) => { + ApiError::InvalidRequest(format!("Invalid CBOR data: {}", msg)).into_response() + } + Err(ImportError::InvalidCommit(msg)) => { + ApiError::InvalidRequest(format!("Invalid commit structure: {}", msg)).into_response() + } + Err(ImportError::BlockNotFound(cid)) => { + ApiError::InvalidRequest(format!("Referenced block not found in CAR: {}", cid)) + .into_response() + } + Err(ImportError::ConcurrentModification) => ApiError::InvalidSwap(Some("Repository is being modified by another operation, please retry".into(),)) + .into_response(), + Err(ImportError::VerificationFailed(ve)) => { + ApiError::InvalidRequest(format!("CAR verification failed: {}", ve)).into_response() + } + Err(ImportError::DidMismatch { car_did, auth_did }) => { + ApiError::InvalidRequest(format!( + "CAR is for {} but authenticated as {}", + car_did, auth_did + )) + .into_response() } - Err(ImportError::SizeLimitExceeded) => ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Import exceeds block limit of {}", max_blocks) - })), - ) - .into_response(), - Err(ImportError::RepoNotFound) => ( - StatusCode::NOT_FOUND, - Json(json!({ - "error": "RepoNotFound", - "message": "Repository not initialized for this account" - })), - ) - .into_response(), - Err(ImportError::InvalidCbor(msg)) => ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Invalid CBOR data: {}", msg) - })), - ) - .into_response(), - Err(ImportError::InvalidCommit(msg)) => ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Invalid commit structure: {}", msg) - })), - ) - .into_response(), - Err(ImportError::BlockNotFound(cid)) => ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Referenced block not found in CAR: {}", cid) - })), - ) - .into_response(), - Err(ImportError::ConcurrentModification) => ( - StatusCode::CONFLICT, - Json(json!({ - "error": "ConcurrentModification", - "message": "Repository is being modified by another operation, please retry" - })), - ) - .into_response(), - Err(ImportError::VerificationFailed(ve)) => ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "VerificationFailed", - "message": format!("CAR verification failed: {}", ve) - })), - ) - .into_response(), - Err(ImportError::DidMismatch { car_did, auth_did }) => ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "DidMismatch", - "message": format!("CAR is for {} but authenticated as {}", car_did, auth_did) - })), - ) - .into_response(), Err(e) => { error!("Import error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } diff --git a/src/api/repo/meta.rs b/src/api/repo/meta.rs index 2fa5067..711a32b 100644 --- a/src/api/repo/meta.rs +++ b/src/api/repo/meta.rs @@ -1,8 +1,9 @@ +use crate::api::error::ApiError; use crate::state::AppState; +use crate::types::AtIdentifier; use axum::{ Json, extract::{Query, State}, - http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; @@ -10,7 +11,7 @@ use serde_json::json; #[derive(Deserialize)] pub struct DescribeRepoInput { - pub repo: String, + pub repo: AtIdentifier, } pub async fn describe_repo( @@ -18,19 +19,20 @@ pub async fn describe_repo( Query(input): Query, ) -> Response { let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let user_row = if input.repo.starts_with("did:") { + let user_row = if input.repo.is_did() { sqlx::query!( "SELECT id, handle, did FROM users WHERE did = $1", - input.repo + input.repo.as_str() ) .fetch_optional(&state.db) .await .map(|opt| opt.map(|r| (r.id, r.handle, r.did))) } else { - let handle = if !input.repo.contains('.') { - format!("{}.{}", input.repo, hostname) + let repo_str = input.repo.as_str(); + let handle = if !repo_str.contains('.') { + format!("{}.{}", repo_str, hostname) } else { - input.repo.clone() + repo_str.to_string() }; sqlx::query!( "SELECT id, handle, did FROM users WHERE handle = $1", @@ -43,18 +45,10 @@ pub async fn describe_repo( let (user_id, handle, did) = match user_row { Ok(Some((id, handle, did))) => (id, handle, did), Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "RepoNotFound", "message": "Repo not found"})), - ) - .into_response(); + return ApiError::RepoNotFound(Some("Repo not found".into())).into_response(); } Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let collections_query = sqlx::query!( diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs index 97e607b..299a3c0 100644 --- a/src/api/repo/record/batch.rs +++ b/src/api/repo/record/batch.rs @@ -1,10 +1,11 @@ use super::validation::validate_record_with_status; use super::write::has_verified_comms_channel; +use crate::api::error::ApiError; use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; use crate::delegation::{self, DelegationActionType}; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; -use crate::validation::ValidationStatus; +use crate::types::{AtIdentifier, AtUri, Nsid, Rkey}; use axum::{ Json, extract::State, @@ -12,10 +13,6 @@ use axum::{ response::{IntoResponse, Response}, }; use cid::Cid; -use jacquard::types::{ - integer::LimitedU32, - string::{Nsid, Tid}, -}; use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -30,24 +27,24 @@ const MAX_BATCH_WRITES: usize = 200; pub enum WriteOp { #[serde(rename = "com.atproto.repo.applyWrites#create")] Create { - collection: String, - rkey: Option, + collection: Nsid, + rkey: Option, value: serde_json::Value, }, #[serde(rename = "com.atproto.repo.applyWrites#update")] Update { - collection: String, - rkey: String, + collection: Nsid, + rkey: Rkey, value: serde_json::Value, }, #[serde(rename = "com.atproto.repo.applyWrites#delete")] - Delete { collection: String, rkey: String }, + Delete { collection: Nsid, rkey: Rkey }, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ApplyWritesInput { - pub repo: String, + pub repo: AtIdentifier, pub validate: Option, pub writes: Vec, pub swap_commit: Option, @@ -58,14 +55,14 @@ pub struct ApplyWritesInput { pub enum WriteResult { #[serde(rename = "com.atproto.repo.applyWrites#createResult")] CreateResult { - uri: String, + uri: AtUri, cid: String, #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")] validation_status: Option, }, #[serde(rename = "com.atproto.repo.applyWrites#updateResult")] UpdateResult { - uri: String, + uri: AtUri, cid: String, #[serde(rename = "validationStatus", skip_serializing_if = "Option::is_none")] validation_status: Option, @@ -96,51 +93,28 @@ pub async fn apply_writes( input.repo, input.writes.len() ); - let token = match crate::auth::extract_bearer_token_from_header( + let Some(token) = crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + ) else { + return ApiError::AuthenticationRequired.into_response(); }; let auth_user = match crate::auth::validate_bearer_token(&state.db, &token).await { Ok(user) => user, - Err(_) => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); - } + Err(_) => return ApiError::AuthenticationFailed(None).into_response(), }; let did = auth_user.did.clone(); let is_oauth = auth_user.is_oauth; let scope = auth_user.scope; let controller_did = auth_user.controller_did.clone(); - if input.repo != did { - return ( - StatusCode::FORBIDDEN, - Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})), - ) + if input.repo.as_str() != did { + return ApiError::InvalidRepo("Repo does not match authenticated user".into()) .into_response(); } if crate::util::is_account_migrated(&state.db, &did) .await .unwrap_or(false) { - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "AccountMigrated", - "message": "Account has been migrated to another PDS. Repo operations are not allowed." - })), - ) - .into_response(); + return ApiError::AccountMigrated.into_response(); } let is_verified = has_verified_comms_channel(&state.db, &did) .await @@ -149,27 +123,13 @@ pub async fn apply_writes( .await .unwrap_or(false); if !is_verified && !is_delegated { - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "AccountNotVerified", - "message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records" - })), - ) - .into_response(); + return ApiError::AccountNotVerified.into_response(); } if input.writes.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "writes array is empty"})), - ) - .into_response(); + return ApiError::InvalidRequest("writes array is empty".into()).into_response(); } if input.writes.len() > MAX_BATCH_WRITES { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": format!("Too many writes (max {})", MAX_BATCH_WRITES)})), - ) + return ApiError::InvalidRequest(format!("Too many writes (max {})", MAX_BATCH_WRITES)) .into_response(); } @@ -179,34 +139,34 @@ pub async fn apply_writes( .unwrap_or(false); if is_oauth || has_custom_scope { use std::collections::HashSet; - let create_collections: HashSet<&str> = input + let create_collections: HashSet<&Nsid> = input .writes .iter() .filter_map(|w| { if let WriteOp::Create { collection, .. } = w { - Some(collection.as_str()) + Some(collection) } else { None } }) .collect(); - let update_collections: HashSet<&str> = input + let update_collections: HashSet<&Nsid> = input .writes .iter() .filter_map(|w| { if let WriteOp::Update { collection, .. } = w { - Some(collection.as_str()) + Some(collection) } else { None } }) .collect(); - let delete_collections: HashSet<&str> = input + let delete_collections: HashSet<&Nsid> = input .writes .iter() .filter_map(|w| { if let WriteOp::Delete { collection, .. } = w { - Some(collection.as_str()) + Some(collection) } else { None } @@ -245,18 +205,12 @@ pub async fn apply_writes( } } - let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + let user_id: uuid::Uuid = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str()) .fetch_optional(&state.db) .await { Ok(Some(id)) => id, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "User not found"})), - ) - .into_response(); - } + _ => return ApiError::InternalError(Some("User not found".into())).into_response(), }; let root_cid_str: String = match sqlx::query_scalar!( "SELECT repo_root_cid FROM repos WHERE user_id = $1", @@ -266,53 +220,27 @@ pub async fn apply_writes( .await { Ok(Some(cid_str)) => cid_str, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Repo root not found"})), - ) - .into_response(); - } + _ => return ApiError::InternalError(Some("Repo root not found".into())).into_response(), }; let current_root_cid = match Cid::from_str(&root_cid_str) { Ok(c) => c, Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})), - ) - .into_response(); + return ApiError::InternalError(Some("Invalid repo root CID".into())).into_response() } }; if let Some(swap_commit) = &input.swap_commit && Cid::from_str(swap_commit).ok() != Some(current_root_cid) { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), - ) - .into_response(); + return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response(); } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = match tracking_store.get(¤t_root_cid).await { Ok(Some(b)) => b, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Commit block not found"})), - ) - .into_response(); - } + _ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(), }; let commit = match Commit::from_cbor(&commit_bytes) { Ok(c) => c, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to parse commit"})), - ) - .into_response(); - } + _ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(), }; let original_mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); let mut mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); @@ -334,7 +262,7 @@ pub async fn apply_writes( match validate_record_with_status( value, collection, - rkey.as_deref(), + rkey.as_ref().map(|r| r.as_str()), require_lexicon, ) { Ok(status) => Some(status), @@ -342,51 +270,38 @@ pub async fn apply_writes( } }; all_blob_cids.extend(extract_blob_cids(value)); - let rkey = rkey - .clone() - .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); + let rkey = rkey.clone().unwrap_or_else(Rkey::generate); let record_ipld = crate::util::json_to_ipld(value); let mut record_bytes = Vec::new(); if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { - return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); + return ApiError::InvalidRecord("Failed to serialize record".into()) + .into_response(); } let record_cid = match tracking_store.put(&record_bytes).await { Ok(c) => c, - Err(_) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json( - json!({"error": "InternalError", "message": "Failed to store record"}), - ), - ) - .into_response(), - }; - let collection_nsid = match collection.parse::() { - Ok(n) => n, - Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(), + Err(_) => { + return ApiError::InternalError(Some("Failed to store record".into())) + .into_response() + } }; - let key = format!("{}/{}", collection_nsid, rkey); + let key = format!("{}/{}", collection, rkey); modified_keys.push(key.clone()); mst = match mst.add(&key, record_cid).await { Ok(m) => m, - Err(_) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to add to MST"})), - ) - .into_response(), + Err(_) => { + return ApiError::InternalError(Some("Failed to add to MST".into())) + .into_response() + } }; - let uri = format!("at://{}/{}/{}", did, collection, rkey); + let uri = AtUri::from_parts(&did, collection, &rkey); results.push(WriteResult::CreateResult { uri, cid: record_cid.to_string(), - validation_status: validation_status.map(|s| match s { - ValidationStatus::Valid => "valid".to_string(), - ValidationStatus::Unknown => "unknown".to_string(), - ValidationStatus::Invalid => "invalid".to_string(), - }), + validation_status: validation_status.map(|s| s.to_string()), }); ops.push(RecordOp::Create { - collection: collection.clone(), - rkey, + collection: collection.to_string(), + rkey: rkey.to_string(), cid: record_cid, }); } @@ -402,7 +317,7 @@ pub async fn apply_writes( match validate_record_with_status( value, collection, - Some(rkey), + Some(rkey.as_str()), require_lexicon, ) { Ok(status) => Some(status), @@ -413,66 +328,54 @@ pub async fn apply_writes( let record_ipld = crate::util::json_to_ipld(value); let mut record_bytes = Vec::new(); if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { - return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); + return ApiError::InvalidRecord("Failed to serialize record".into()) + .into_response(); } let record_cid = match tracking_store.put(&record_bytes).await { Ok(c) => c, - Err(_) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json( - json!({"error": "InternalError", "message": "Failed to store record"}), - ), - ) - .into_response(), - }; - let collection_nsid = match collection.parse::() { - Ok(n) => n, - Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(), + Err(_) => { + return ApiError::InternalError(Some("Failed to store record".into())) + .into_response() + } }; - let key = format!("{}/{}", collection_nsid, rkey); + let key = format!("{}/{}", collection, rkey); modified_keys.push(key.clone()); let prev_record_cid = mst.get(&key).await.ok().flatten(); mst = match mst.update(&key, record_cid).await { Ok(m) => m, - Err(_) => return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to update MST"})), - ) - .into_response(), + Err(_) => { + return ApiError::InternalError(Some("Failed to update MST".into())) + .into_response() + } }; - let uri = format!("at://{}/{}/{}", did, collection, rkey); + let uri = AtUri::from_parts(&did, collection, rkey); results.push(WriteResult::UpdateResult { uri, cid: record_cid.to_string(), - validation_status: validation_status.map(|s| match s { - ValidationStatus::Valid => "valid".to_string(), - ValidationStatus::Unknown => "unknown".to_string(), - ValidationStatus::Invalid => "invalid".to_string(), - }), + validation_status: validation_status.map(|s| s.to_string()), }); ops.push(RecordOp::Update { - collection: collection.clone(), - rkey: rkey.clone(), + collection: collection.to_string(), + rkey: rkey.to_string(), cid: record_cid, prev: prev_record_cid, }); } WriteOp::Delete { collection, rkey } => { - let collection_nsid = match collection.parse::() { - Ok(n) => n, - Err(_) => return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidCollection", "message": "Invalid collection NSID"}))).into_response(), - }; - let key = format!("{}/{}", collection_nsid, rkey); + let key = format!("{}/{}", collection, rkey); modified_keys.push(key.clone()); let prev_record_cid = mst.get(&key).await.ok().flatten(); mst = match mst.delete(&key).await { Ok(m) => m, - Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to delete from MST"}))).into_response(), + Err(_) => { + return ApiError::InternalError(Some("Failed to delete from MST".into())) + .into_response() + } }; results.push(WriteResult::DeleteResult {}); ops.push(RecordOp::Delete { - collection: collection.clone(), - rkey: rkey.clone(), + collection: collection.to_string(), + rkey: rkey.to_string(), prev: prev_record_cid, }); } @@ -480,13 +383,7 @@ pub async fn apply_writes( } let new_mst_root = match mst.persist().await { Ok(c) => c, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to persist MST"})), - ) - .into_response(); - } + Err(_) => return ApiError::InternalError(Some("Failed to persist MST".into())).into_response(), }; let mut relevant_blocks = std::collections::BTreeMap::new(); for key in &modified_keys { @@ -495,14 +392,16 @@ pub async fn apply_writes( .await .is_err() { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response(); + return ApiError::InternalError(Some("Failed to get new MST blocks for path".into())) + .into_response(); } if original_mst .blocks_for_path(key, &mut relevant_blocks) .await .is_err() { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response(); + return ApiError::InternalError(Some("Failed to get old MST blocks for path".into())) + .into_response(); } } let mut written_cids = tracking_store.get_all_relevant_cids(); @@ -533,11 +432,7 @@ pub async fn apply_writes( Ok(res) => res, Err(e) => { error!("Commit failed: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to commit changes"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to commit changes".into())).into_response(); } }; diff --git a/src/api/repo/record/delete.rs b/src/api/repo/record/delete.rs index 57319e2..235b842 100644 --- a/src/api/repo/record/delete.rs +++ b/src/api/repo/record/delete.rs @@ -1,8 +1,10 @@ +use crate::api::error::ApiError; use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log}; use crate::api::repo::record::write::{CommitInfo, prepare_repo_write}; use crate::delegation::{self, DelegationActionType}; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; +use crate::types::{AtIdentifier, Nsid, Rkey}; use axum::{ Json, extract::State, @@ -10,7 +12,6 @@ use axum::{ response::{IntoResponse, Response}, }; use cid::Cid; -use jacquard::types::string::Nsid; use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -20,9 +21,9 @@ use tracing::error; #[derive(Deserialize)] pub struct DeleteRecordInput { - pub repo: String, - pub collection: String, - pub rkey: String, + pub repo: AtIdentifier, + pub collection: Nsid, + pub rkey: Rkey, #[serde(rename = "swapRecord")] pub swap_record: Option, #[serde(rename = "swapCommit")] @@ -68,14 +69,7 @@ pub async fn delete_record( .await .unwrap_or(false) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "AccountMigrated", - "message": "Account has been migrated. Repo operations are not allowed." - })), - ) - .into_response(); + return ApiError::AccountMigrated.into_response(); } let did = auth.did; @@ -86,50 +80,25 @@ pub async fn delete_record( if let Some(swap_commit) = &input.swap_commit && Cid::from_str(swap_commit).ok() != Some(current_root_cid) { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), - ) - .into_response(); + return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response(); } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = match tracking_store.get(¤t_root_cid).await { Ok(Some(b)) => b, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Commit block not found"})), - ) - .into_response(); - } + _ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(), }; let commit = match Commit::from_cbor(&commit_bytes) { Ok(c) => c, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to parse commit"})), - ) - .into_response(); - } + _ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(), }; let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); - let collection_nsid = match input.collection.parse::() { - Ok(n) => n, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidCollection"})), - ) - .into_response(); - } - }; - let key = format!("{}/{}", collection_nsid, input.rkey); + let key = format!("{}/{}", input.collection, input.rkey); if let Some(swap_record_str) = &input.swap_record { let expected_cid = Cid::from_str(swap_record_str).ok(); let actual_cid = mst.get(&key).await.ok().flatten(); if expected_cid != actual_cid { - return (StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Record has been modified or does not exist"}))).into_response(); + return ApiError::InvalidSwap(Some("Record has been modified or does not exist".into())) + .into_response(); } } let prev_record_cid = mst.get(&key).await.ok().flatten(); @@ -140,25 +109,22 @@ pub async fn delete_record( Ok(m) => m, Err(e) => { error!("Failed to delete from MST: {:?}", e); - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": format!("Failed to delete from MST: {:?}", e)}))).into_response(); + return ApiError::InternalError(Some(format!("Failed to delete from MST: {:?}", e))) + .into_response(); } }; let new_mst_root = match new_mst.persist().await { Ok(c) => c, Err(e) => { error!("Failed to persist MST: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to persist MST"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to persist MST".into())).into_response(); } }; - let collection_for_audit = input.collection.clone(); - let rkey_for_audit = input.rkey.clone(); + let collection_for_audit = input.collection.to_string(); + let rkey_for_audit = input.rkey.to_string(); let op = RecordOp::Delete { - collection: input.collection, - rkey: input.rkey, + collection: input.collection.to_string(), + rkey: rkey_for_audit.clone(), prev: prev_record_cid, }; let mut relevant_blocks = std::collections::BTreeMap::new(); @@ -167,14 +133,16 @@ pub async fn delete_record( .await .is_err() { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response(); + return ApiError::InternalError(Some("Failed to get new MST blocks for path".into())) + .into_response(); } if mst .blocks_for_path(&key, &mut relevant_blocks) .await .is_err() { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response(); + return ApiError::InternalError(Some("Failed to get old MST blocks for path".into())) + .into_response(); } let mut written_cids = tracking_store.get_all_relevant_cids(); for cid in relevant_blocks.keys() { @@ -202,13 +170,7 @@ pub async fn delete_record( .await { Ok(res) => res, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": e})), - ) - .into_response(); - } + Err(e) => return ApiError::InternalError(Some(e)).into_response(), }; if let Some(ref controller) = controller_did { diff --git a/src/api/repo/record/read.rs b/src/api/repo/record/read.rs index d252a28..bab93d7 100644 --- a/src/api/repo/record/read.rs +++ b/src/api/repo/record/read.rs @@ -1,8 +1,10 @@ +use crate::api::error::ApiError; use crate::state::AppState; +use crate::types::{AtIdentifier, Nsid, Rkey}; use axum::{ Json, extract::{Query, State}, - http::{HeaderMap, StatusCode}, + http::HeaderMap, response::{IntoResponse, Response}, }; use base64::Engine; @@ -46,28 +48,29 @@ fn ipld_to_json(ipld: Ipld) -> Value { #[derive(Deserialize)] pub struct GetRecordInput { - pub repo: String, - pub collection: String, - pub rkey: String, + pub repo: AtIdentifier, + pub collection: Nsid, + pub rkey: Rkey, pub cid: Option, } pub async fn get_record( State(state): State, - headers: HeaderMap, + _headers: HeaderMap, Query(input): Query, ) -> Response { let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let user_id_opt = if input.repo.starts_with("did:") { - sqlx::query!("SELECT id FROM users WHERE did = $1", input.repo) + let user_id_opt = if input.repo.is_did() { + sqlx::query!("SELECT id FROM users WHERE did = $1", input.repo.as_str()) .fetch_optional(&state.db) .await .map(|opt| opt.map(|r| r.id)) } else { - let handle = if !input.repo.contains('.') { - format!("{}.{}", input.repo, hostname) + let repo_str = input.repo.as_str(); + let handle = if !repo_str.contains('.') { + format!("{}.{}", repo_str, hostname) } else { - input.repo.clone() + repo_str.to_string() }; sqlx::query!("SELECT id FROM users WHERE handle = $1", handle) .fetch_optional(&state.db) @@ -77,76 +80,45 @@ pub async fn get_record( let user_id: uuid::Uuid = match user_id_opt { Ok(Some(id)) => id, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "RepoNotFound", "message": "Repo not found"})), - ) - .into_response(); + return ApiError::RepoNotFound(Some("Repo not found".into())).into_response(); } Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let record_row = sqlx::query!( "SELECT record_cid FROM records WHERE repo_id = $1 AND collection = $2 AND rkey = $3", user_id, - input.collection, - input.rkey + input.collection.as_str(), + input.rkey.as_str() ) .fetch_optional(&state.db) .await; let record_cid_str: String = match record_row { Ok(Some(row)) => row.record_cid, _ => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "RecordNotFound", "message": "Record not found"})), - ) - .into_response(); + return ApiError::RecordNotFound.into_response(); } }; if let Some(expected_cid) = &input.cid && &record_cid_str != expected_cid { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "RecordNotFound", "message": "Record CID mismatch"})), - ) - .into_response(); + return ApiError::RecordNotFound.into_response(); } - let cid = match Cid::from_str(&record_cid_str) { - Ok(c) => c, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Invalid CID in DB"})), - ) - .into_response(); - } + let Ok(cid) = Cid::from_str(&record_cid_str) else { + return ApiError::InternalError(Some("Invalid CID in DB".into())).into_response(); }; let block = match state.block_store.get(&cid).await { Ok(Some(b)) => b, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Record block not found"})), - ) - .into_response(); + return ApiError::InternalError(Some("Record block not found".into())).into_response(); } }; let ipld: Ipld = match serde_ipld_dagcbor::from_slice(&block) { Ok(v) => v, Err(e) => { error!("Failed to deserialize record: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let value = ipld_to_json(ipld); @@ -159,14 +131,14 @@ pub async fn get_record( } #[derive(Deserialize)] pub struct ListRecordsInput { - pub repo: String, - pub collection: String, + pub repo: AtIdentifier, + pub collection: Nsid, pub limit: Option, pub cursor: Option, #[serde(rename = "rkeyStart")] - pub rkey_start: Option, + pub rkey_start: Option, #[serde(rename = "rkeyEnd")] - pub rkey_end: Option, + pub rkey_end: Option, pub reverse: Option, } #[derive(Serialize)] @@ -181,16 +153,17 @@ pub async fn list_records( Query(input): Query, ) -> Response { let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); - let user_id_opt = if input.repo.starts_with("did:") { - sqlx::query!("SELECT id FROM users WHERE did = $1", input.repo) + let user_id_opt = if input.repo.is_did() { + sqlx::query!("SELECT id FROM users WHERE did = $1", input.repo.as_str()) .fetch_optional(&state.db) .await .map(|opt| opt.map(|r| r.id)) } else { - let handle = if !input.repo.contains('.') { - format!("{}.{}", input.repo, hostname) + let repo_str = input.repo.as_str(); + let handle = if !repo_str.contains('.') { + format!("{}.{}", repo_str, hostname) } else { - input.repo.clone() + repo_str.to_string() }; sqlx::query!("SELECT id FROM users WHERE handle = $1", handle) .fetch_optional(&state.db) @@ -200,18 +173,10 @@ pub async fn list_records( let user_id: uuid::Uuid = match user_id_opt { Ok(Some(id)) => id, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "RepoNotFound", "message": "Repo not found"})), - ) - .into_response(); + return ApiError::RepoNotFound(Some("Repo not found".into())).into_response(); } Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let limit = input.limit.unwrap_or(50).clamp(1, 100); @@ -226,7 +191,7 @@ pub async fn list_records( ); sqlx::query_as(&query) .bind(user_id) - .bind(&input.collection) + .bind(input.collection.as_str()) .bind(cursor) .bind(limit_i64) .fetch_all(&state.db) @@ -255,12 +220,12 @@ pub async fn list_records( ); let mut query_builder = sqlx::query_as::<_, (String, String)>(&query) .bind(user_id) - .bind(&input.collection); + .bind(input.collection.as_str()); if let Some(start) = &input.rkey_start { - query_builder = query_builder.bind(start); + query_builder = query_builder.bind(start.as_str()); } if let Some(end) = &input.rkey_end { - query_builder = query_builder.bind(end); + query_builder = query_builder.bind(end.as_str()); } query_builder.bind(limit_i64).fetch_all(&state.db).await }; @@ -268,11 +233,7 @@ pub async fn list_records( Ok(r) => r, Err(e) => { error!("Error listing records: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let last_rkey = rows.last().map(|(rkey, _)| rkey.clone()); @@ -288,11 +249,7 @@ pub async fn list_records( Ok(b) => b, Err(e) => { error!("Error fetching blocks: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let mut records = Vec::new(); diff --git a/src/api/repo/record/validation.rs b/src/api/repo/record/validation.rs index d00ad63..fe10020 100644 --- a/src/api/repo/record/validation.rs +++ b/src/api/repo/record/validation.rs @@ -1,10 +1,6 @@ +use crate::api::error::ApiError; use crate::validation::{RecordValidator, ValidationError, ValidationStatus}; -use axum::{ - Json, - http::StatusCode, - response::{IntoResponse, Response}, -}; -use serde_json::json; +use axum::response::Response; pub fn validate_record(record: &serde_json::Value, collection: &str) -> Result<(), Box> { validate_record_with_rkey(record, collection, None) @@ -42,62 +38,27 @@ fn validation_error_to_response( } fn validation_error_to_box_response(e: ValidationError) -> Box { - match e { - ValidationError::MissingType => Box::new( - ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": "Record must have a $type field"})), + use axum::response::IntoResponse; + let msg = match e { + ValidationError::MissingType => "Record must have a $type field".to_string(), + ValidationError::TypeMismatch { expected, actual } => { + format!( + "Record $type '{}' does not match collection '{}'", + actual, expected ) - .into_response(), - ), - ValidationError::TypeMismatch { expected, actual } => Box::new( - ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": format!("Record $type '{}' does not match collection '{}'", actual, expected)})), - ) - .into_response(), - ), - ValidationError::MissingField(field) => Box::new( - ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": format!("Missing required field: {}", field)})), - ) - .into_response(), - ), - ValidationError::InvalidField { path, message } => Box::new( - ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": format!("Invalid field '{}': {}", path, message)})), - ) - .into_response(), - ), - ValidationError::InvalidDatetime { path } => Box::new( - ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": format!("Invalid datetime format at '{}'", path)})), - ) - .into_response(), - ), - ValidationError::BannedContent { path } => Box::new( - ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": format!("Unacceptable slur in record at '{}'", path)})), - ) - .into_response(), - ), - ValidationError::UnknownType(type_name) => Box::new( - ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": format!("Lexicon not found: lex:{}", type_name)})), - ) - .into_response(), - ), - e => Box::new( - ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": e.to_string()})), - ) - .into_response(), - ), - } + } + ValidationError::MissingField(field) => format!("Missing required field: {}", field), + ValidationError::InvalidField { path, message } => { + format!("Invalid field '{}': {}", path, message) + } + ValidationError::InvalidDatetime { path } => { + format!("Invalid datetime format at '{}'", path) + } + ValidationError::BannedContent { path } => { + format!("Unacceptable slur in record at '{}'", path) + } + ValidationError::UnknownType(type_name) => format!("Lexicon not found: lex:{}", type_name), + e => e.to_string(), + }; + Box::new(ApiError::InvalidRecord(msg).into_response()) } diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs index 8ff30cb..fbd9644 100644 --- a/src/api/repo/record/write.rs +++ b/src/api/repo/record/write.rs @@ -1,9 +1,10 @@ use super::validation::validate_record_with_status; +use crate::api::error::ApiError; use crate::api::repo::record::utils::{CommitParams, RecordOp, commit_and_log, extract_blob_cids}; use crate::delegation::{self, DelegationActionType}; use crate::repo::tracking::TrackingBlockStore; use crate::state::AppState; -use crate::validation::ValidationStatus; +use crate::types::{AtIdentifier, AtUri, Did, Nsid, Rkey}; use axum::{ Json, extract::State, @@ -11,10 +12,6 @@ use axum::{ response::{IntoResponse, Response}, }; use cid::Cid; -use jacquard::types::{ - integer::LimitedU32, - string::{Nsid, Tid}, -}; use jacquard_repo::{commit::Commit, mst::Mst, storage::BlockStore}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -52,12 +49,12 @@ pub async fn has_verified_comms_channel(db: &PgPool, did: &str) -> Result, - pub controller_did: Option, + pub controller_did: Option, } pub async fn prepare_repo_write( @@ -70,13 +67,7 @@ pub async fn prepare_repo_write( let extracted = crate::auth::extract_auth_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), ) - .ok_or_else(|| { - ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response() - })?; + .ok_or_else(|| ApiError::AuthenticationRequired.into_response())?; let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); let auth_user = crate::auth::validate_token_with_dpop( &state.db, @@ -90,11 +81,7 @@ pub async fn prepare_repo_write( .await .map_err(|e| { tracing::warn!(error = ?e, is_dpop = extracted.is_dpop, "Token validation failed in prepare_repo_write"); - let mut response = ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": e.to_string()})), - ) - .into_response(); + let mut response = ApiError::from(e).into_response(); if matches!(e, crate::auth::TokenValidationError::TokenExpired) { let scheme = if extracted.is_dpop { "DPoP" } else { "Bearer" }; let www_auth = format!( @@ -113,24 +100,15 @@ pub async fn prepare_repo_write( response })?; if repo_did != auth_user.did { - return Err(( - StatusCode::FORBIDDEN, - Json(json!({"error": "InvalidRepo", "message": "Repo does not match authenticated user"})), - ) - .into_response()); + return Err( + ApiError::InvalidRepo("Repo does not match authenticated user".into()).into_response(), + ); } if crate::util::is_account_migrated(&state.db, &auth_user.did) .await .unwrap_or(false) { - return Err(( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "AccountMigrated", - "message": "Account has been migrated to another PDS. Repo operations are not allowed." - })), - ) - .into_response()); + return Err(ApiError::AccountMigrated.into_response()); } let is_verified = has_verified_comms_channel(&state.db, &auth_user.did) .await @@ -139,33 +117,16 @@ pub async fn prepare_repo_write( .await .unwrap_or(false); if !is_verified && !is_delegated { - return Err(( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "AccountNotVerified", - "message": "You must verify at least one notification channel (email, Discord, Telegram, or Signal) before creating records" - })), - ) - .into_response()); + return Err(ApiError::AccountNotVerified.into_response()); } - let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", auth_user.did) + let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", &auth_user.did) .fetch_optional(&state.db) .await .map_err(|e| { error!("DB error fetching user: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() })? - .ok_or_else(|| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "User not found"})), - ) - .into_response() - })?; + .ok_or_else(|| ApiError::InternalError(Some("User not found".into())).into_response())?; let root_cid_str: String = sqlx::query_scalar!( "SELECT repo_root_cid FROM repos WHERE user_id = $1", user_id @@ -174,41 +135,26 @@ pub async fn prepare_repo_write( .await .map_err(|e| { error!("DB error fetching repo root: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() })? - .ok_or_else(|| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Repo root not found"})), - ) - .into_response() - })?; - let current_root_cid = Cid::from_str(&root_cid_str).map_err(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})), - ) - .into_response() - })?; + .ok_or_else(|| ApiError::InternalError(Some("Repo root not found".into())).into_response())?; + let current_root_cid = Cid::from_str(&root_cid_str) + .map_err(|_| ApiError::InternalError(Some("Invalid repo root CID".into())).into_response())?; Ok(RepoWriteAuth { - did: auth_user.did, + did: auth_user.did.clone(), user_id, current_root_cid, is_oauth: auth_user.is_oauth, scope: auth_user.scope, - controller_did: auth_user.controller_did, + controller_did: auth_user.controller_did.clone(), }) } #[derive(Deserialize)] #[allow(dead_code)] pub struct CreateRecordInput { - pub repo: String, - pub collection: String, - pub rkey: Option, + pub repo: AtIdentifier, + pub collection: Nsid, + pub rkey: Option, pub validate: Option, pub record: serde_json::Value, #[serde(rename = "swapCommit")] @@ -224,7 +170,7 @@ pub struct CommitInfo { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreateRecordOutput { - pub uri: String, + pub uri: AtUri, pub cid: String, pub commit: CommitInfo, #[serde(skip_serializing_if = "Option::is_none")] @@ -266,44 +212,18 @@ pub async fn create_record( if let Some(swap_commit) = &input.swap_commit && Cid::from_str(swap_commit).ok() != Some(current_root_cid) { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), - ) - .into_response(); + return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response(); } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = match tracking_store.get(¤t_root_cid).await { Ok(Some(b)) => b, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Commit block not found"})), - ) - .into_response(); - } + _ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(), }; let commit = match Commit::from_cbor(&commit_bytes) { Ok(c) => c, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to parse commit"})), - ) - .into_response(); - } + _ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(), }; let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); - let collection_nsid = match input.collection.parse::() { - Ok(n) => n, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidCollection"})), - ) - .into_response(); - } - }; let validation_status = if input.validate == Some(false) { None } else { @@ -311,59 +231,37 @@ pub async fn create_record( match validate_record_with_status( &input.record, &input.collection, - input.rkey.as_deref(), + input.rkey.as_ref().map(|r| r.as_str()), require_lexicon, ) { Ok(status) => Some(status), Err(err_response) => return *err_response, } }; - let rkey = input - .rkey - .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); + let rkey = input.rkey.unwrap_or_else(Rkey::generate); let record_ipld = crate::util::json_to_ipld(&input.record); let mut record_bytes = Vec::new(); if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})), - ) - .into_response(); + return ApiError::InvalidRecord("Failed to serialize record".into()).into_response(); } let record_cid = match tracking_store.put(&record_bytes).await { Ok(c) => c, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to save record block"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to save record block".into())).into_response() } }; - let key = format!("{}/{}", collection_nsid, rkey); + let key = format!("{}/{}", input.collection, rkey); let new_mst = match mst.add(&key, record_cid).await { Ok(m) => m, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to add to MST"})), - ) - .into_response(); - } + _ => return ApiError::InternalError(Some("Failed to add to MST".into())).into_response(), }; let new_mst_root = match new_mst.persist().await { Ok(c) => c, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to persist MST"})), - ) - .into_response(); - } + _ => return ApiError::InternalError(Some("Failed to persist MST".into())).into_response(), }; let op = RecordOp::Create { - collection: input.collection.clone(), - rkey: rkey.clone(), + collection: input.collection.to_string(), + rkey: rkey.to_string(), cid: record_cid, }; let mut relevant_blocks = std::collections::BTreeMap::new(); @@ -372,14 +270,16 @@ pub async fn create_record( .await .is_err() { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response(); + return ApiError::InternalError(Some("Failed to get new MST blocks for path".into())) + .into_response(); } if mst .blocks_for_path(&key, &mut relevant_blocks) .await .is_err() { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response(); + return ApiError::InternalError(Some("Failed to get old MST blocks for path".into())) + .into_response(); } relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes)); let mut written_cids = tracking_store.get_all_relevant_cids(); @@ -409,13 +309,7 @@ pub async fn create_record( .await { Ok(res) => res, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": e})), - ) - .into_response(); - } + Err(e) => return ApiError::InternalError(Some(e)).into_response(), }; if let Some(ref controller) = controller_did { @@ -439,17 +333,13 @@ pub async fn create_record( ( StatusCode::OK, Json(CreateRecordOutput { - uri: format!("at://{}/{}/{}", did, input.collection, rkey), + uri: AtUri::from_parts(&did, &input.collection, &rkey), cid: record_cid.to_string(), commit: CommitInfo { cid: commit_result.commit_cid.to_string(), rev: commit_result.rev, }, - validation_status: validation_status.map(|s| match s { - ValidationStatus::Valid => "valid".to_string(), - ValidationStatus::Unknown => "unknown".to_string(), - ValidationStatus::Invalid => "invalid".to_string(), - }), + validation_status: validation_status.map(|s| s.to_string()), }), ) .into_response() @@ -457,9 +347,9 @@ pub async fn create_record( #[derive(Deserialize)] #[allow(dead_code)] pub struct PutRecordInput { - pub repo: String, - pub collection: String, - pub rkey: String, + pub repo: AtIdentifier, + pub collection: Nsid, + pub rkey: Rkey, pub validate: Option, pub record: serde_json::Value, #[serde(rename = "swapCommit")] @@ -470,7 +360,7 @@ pub struct PutRecordInput { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct PutRecordOutput { - pub uri: String, + pub uri: AtUri, pub cid: String, #[serde(skip_serializing_if = "Option::is_none")] pub commit: Option, @@ -521,45 +411,19 @@ pub async fn put_record( if let Some(swap_commit) = &input.swap_commit && Cid::from_str(swap_commit).ok() != Some(current_root_cid) { - return ( - StatusCode::CONFLICT, - Json(json!({"error": "InvalidSwap", "message": "Repo has been modified"})), - ) - .into_response(); + return ApiError::InvalidSwap(Some("Repo has been modified".into())).into_response(); } let tracking_store = TrackingBlockStore::new(state.block_store.clone()); let commit_bytes = match tracking_store.get(¤t_root_cid).await { Ok(Some(b)) => b, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Commit block not found"})), - ) - .into_response(); - } + _ => return ApiError::InternalError(Some("Commit block not found".into())).into_response(), }; let commit = match Commit::from_cbor(&commit_bytes) { Ok(c) => c, - _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to parse commit"})), - ) - .into_response(); - } + _ => return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(), }; let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); - let collection_nsid = match input.collection.parse::() { - Ok(n) => n, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidCollection"})), - ) - .into_response(); - } - }; - let key = format!("{}/{}", collection_nsid, input.rkey); + let key = format!("{}/{}", input.collection, input.rkey); let validation_status = if input.validate == Some(false) { None } else { @@ -567,7 +431,7 @@ pub async fn put_record( match validate_record_with_status( &input.record, &input.collection, - Some(&input.rkey), + Some(input.rkey.as_str()), require_lexicon, ) { Ok(status) => Some(status), @@ -578,41 +442,30 @@ pub async fn put_record( let expected_cid = Cid::from_str(swap_record_str).ok(); let actual_cid = mst.get(&key).await.ok().flatten(); if expected_cid != actual_cid { - return (StatusCode::CONFLICT, Json(json!({"error": "InvalidSwap", "message": "Record has been modified or does not exist"}))).into_response(); + return ApiError::InvalidSwap(Some("Record has been modified or does not exist".into())) + .into_response(); } } let existing_cid = mst.get(&key).await.ok().flatten(); let record_ipld = crate::util::json_to_ipld(&input.record); let mut record_bytes = Vec::new(); if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})), - ) - .into_response(); + return ApiError::InvalidRecord("Failed to serialize record".into()).into_response(); } let record_cid = match tracking_store.put(&record_bytes).await { Ok(c) => c, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to save record block"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to save record block".into())).into_response() } }; if existing_cid == Some(record_cid) { return ( StatusCode::OK, Json(PutRecordOutput { - uri: format!("at://{}/{}/{}", did, input.collection, input.rkey), + uri: AtUri::from_parts(&did, &input.collection, &input.rkey), cid: record_cid.to_string(), commit: None, - validation_status: validation_status.map(|s| match s { - ValidationStatus::Valid => "valid".to_string(), - ValidationStatus::Unknown => "unknown".to_string(), - ValidationStatus::Invalid => "invalid".to_string(), - }), + validation_status: validation_status.map(|s| s.to_string()), }), ) .into_response(); @@ -621,46 +474,34 @@ pub async fn put_record( match mst.update(&key, record_cid).await { Ok(m) => m, Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to update MST"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to update MST".into())).into_response() } } } else { match mst.add(&key, record_cid).await { Ok(m) => m, Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to add to MST"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to add to MST".into())).into_response() } } }; let new_mst_root = match new_mst.persist().await { Ok(c) => c, Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to persist MST"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to persist MST".into())).into_response() } }; let op = if existing_cid.is_some() { RecordOp::Update { - collection: input.collection.clone(), - rkey: input.rkey.clone(), + collection: input.collection.to_string(), + rkey: input.rkey.to_string(), cid: record_cid, prev: existing_cid, } } else { RecordOp::Create { - collection: input.collection.clone(), - rkey: input.rkey.clone(), + collection: input.collection.to_string(), + rkey: input.rkey.to_string(), cid: record_cid, } }; @@ -670,14 +511,16 @@ pub async fn put_record( .await .is_err() { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get new MST blocks for path"}))).into_response(); + return ApiError::InternalError(Some("Failed to get new MST blocks for path".into())) + .into_response(); } if mst .blocks_for_path(&key, &mut relevant_blocks) .await .is_err() { - return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "InternalError", "message": "Failed to get old MST blocks for path"}))).into_response(); + return ApiError::InternalError(Some("Failed to get old MST blocks for path".into())) + .into_response(); } relevant_blocks.insert(record_cid, bytes::Bytes::from(record_bytes)); let mut written_cids = tracking_store.get_all_relevant_cids(); @@ -708,13 +551,7 @@ pub async fn put_record( .await { Ok(res) => res, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": e})), - ) - .into_response(); - } + Err(e) => return ApiError::InternalError(Some(e)).into_response(), }; if let Some(ref controller) = controller_did { @@ -738,17 +575,13 @@ pub async fn put_record( ( StatusCode::OK, Json(PutRecordOutput { - uri: format!("at://{}/{}/{}", did, input.collection, input.rkey), + uri: AtUri::from_parts(&did, &input.collection, &input.rkey), cid: record_cid.to_string(), commit: Some(CommitInfo { cid: commit_result.commit_cid.to_string(), rev: commit_result.rev, }), - validation_status: validation_status.map(|s| match s { - ValidationStatus::Valid => "valid".to_string(), - ValidationStatus::Unknown => "unknown".to_string(), - ValidationStatus::Invalid => "invalid".to_string(), - }), + validation_status: validation_status.map(|s| s.to_string()), }), ) .into_response() diff --git a/src/api/responses.rs b/src/api/responses.rs new file mode 100644 index 0000000..6b33634 --- /dev/null +++ b/src/api/responses.rs @@ -0,0 +1,114 @@ +use crate::types::Did; +use axum::{Json, response::IntoResponse}; +use serde::Serialize; + +#[derive(Debug, Serialize)] +pub struct EmptyResponse {} + +impl EmptyResponse { + pub fn ok() -> impl IntoResponse { + Json(Self {}) + } +} + +#[derive(Debug, Serialize)] +pub struct SuccessResponse { + pub success: bool, +} + +impl SuccessResponse { + pub fn ok() -> impl IntoResponse { + Json(Self { success: true }) + } +} + +#[derive(Debug, Serialize)] +pub struct DidResponse { + pub did: Did, +} + +impl DidResponse { + pub fn new(did: impl Into) -> impl IntoResponse { + Json(Self { did: did.into() }) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TokenRequiredResponse { + pub token_required: bool, +} + +impl TokenRequiredResponse { + pub fn new(required: bool) -> impl IntoResponse { + Json(Self { token_required: required }) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct HasPasswordResponse { + pub has_password: bool, +} + +impl HasPasswordResponse { + pub fn new(has_password: bool) -> impl IntoResponse { + Json(Self { has_password }) + } +} + +#[derive(Debug, Serialize)] +pub struct VerifiedResponse { + pub verified: bool, +} + +impl VerifiedResponse { + pub fn new(verified: bool) -> impl IntoResponse { + Json(Self { verified }) + } +} + +#[derive(Debug, Serialize)] +pub struct EnabledResponse { + pub enabled: bool, +} + +impl EnabledResponse { + pub fn new(enabled: bool) -> impl IntoResponse { + Json(Self { enabled }) + } +} + +#[derive(Debug, Serialize)] +pub struct StatusResponse { + pub status: String, +} + +impl StatusResponse { + pub fn new(status: impl Into) -> impl IntoResponse { + Json(Self { status: status.into() }) + } +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DidDocumentResponse { + pub did_document: serde_json::Value, +} + +impl DidDocumentResponse { + pub fn new(did_document: serde_json::Value) -> impl IntoResponse { + Json(Self { did_document }) + } +} + +#[derive(Debug, Serialize)] +pub struct OptionsResponse { + pub options: T, +} + +impl OptionsResponse { + pub fn new(options: T) -> Json { + Json(Self { options }) + } +} diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs index 9979455..0b12bbe 100644 --- a/src/api/server/account_status.rs +++ b/src/api/server/account_status.rs @@ -1,7 +1,9 @@ -use crate::api::ApiError; +use crate::api::error::ApiError; +use crate::api::EmptyResponse; use crate::cache::Cache; use crate::plc::PlcClient; use crate::state::AppState; +use crate::types::PlainPassword; use axum::{ Json, extract::State, @@ -15,7 +17,6 @@ use jacquard_repo::commit::Commit; use jacquard_repo::storage::BlockStore; use k256::ecdsa::SigningKey; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::str::FromStr; use std::sync::Arc; use tracing::{error, info, warn}; @@ -64,20 +65,16 @@ pub async fn check_account_status( Ok(user) => user.did, Err(e) => return ApiError::from(e).into_response(), }; - let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str()) .fetch_optional(&state.db) .await { Ok(Some(id)) => id, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; - let user_status = sqlx::query!("SELECT deactivated_at FROM users WHERE did = $1", did) + let user_status = sqlx::query!("SELECT deactivated_at FROM users WHERE did = $1", did.as_str()) .fetch_optional(&state.db) .await; let deactivated_at = match user_status { @@ -142,7 +139,7 @@ pub async fn check_account_status( .await .unwrap_or(Some(0)) .unwrap_or(0); - let valid_did = is_valid_did_for_service(&state.db, &state.cache, &did).await; + let valid_did = is_valid_did_for_service(&state.db, state.cache.clone(), did.as_str()).await; ( StatusCode::OK, Json(CheckAccountStatusOutput { @@ -160,7 +157,7 @@ pub async fn check_account_status( .into_response() } -async fn is_valid_did_for_service(db: &sqlx::PgPool, cache: &Arc, did: &str) -> bool { +async fn is_valid_did_for_service(db: &sqlx::PgPool, cache: Arc, did: &str) -> bool { assert_valid_did_document_for_service(db, cache, did, false) .await .is_ok() @@ -168,10 +165,10 @@ async fn is_valid_did_for_service(db: &sqlx::PgPool, cache: &Arc, did async fn assert_valid_did_document_for_service( db: &sqlx::PgPool, - cache: &Arc, + cache: Arc, did: &str, with_retry: bool, -) -> Result<(), (StatusCode, Json)> { +) -> Result<(), ApiError> { let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let expected_endpoint = format!("https://{}", hostname); @@ -228,17 +225,10 @@ async fn assert_valid_did_document_for_service( } } - let doc_data = match doc_data { - Some(d) => d, - None => { - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": last_error.unwrap_or_else(|| "DID document validation failed".to_string()) - })), - )); - } + let Some(doc_data) = doc_data else { + return Err(ApiError::InvalidRequest( + last_error.unwrap_or_else(|| "DID document validation failed".to_string()), + )); }; let server_rotation_key = std::env::var("PLC_ROTATION_KEY").ok(); @@ -249,12 +239,8 @@ async fn assert_valid_did_document_for_service( .map(|arr| arr.iter().filter_map(|k| k.as_str()).collect::>()) .unwrap_or_default(); if !rotation_keys.contains(&expected_rotation_key.as_str()) { - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "Server rotation key not included in PLC DID data" - })), + return Err(ApiError::InvalidRequest( + "Server rotation key not included in PLC DID data".into(), )); } } @@ -272,27 +258,18 @@ async fn assert_valid_did_document_for_service( .await .map_err(|e| { error!("Failed to fetch user key: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) + ApiError::InternalError(None) })?; if let Some(row) = user_row { let key_bytes = crate::config::decrypt_key(&row.key_bytes, row.encryption_version) .map_err(|e| { error!("Failed to decrypt user key: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) + ApiError::InternalError(None) })?; let signing_key = SigningKey::from_slice(&key_bytes).map_err(|e| { error!("Failed to create signing key: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) + ApiError::InternalError(None) })?; let expected_did_key = crate::plc::signing_key_to_did_key(&signing_key); @@ -301,12 +278,8 @@ async fn assert_valid_did_document_for_service( "DID {} has signing key {:?}, expected {}", did, doc_signing_key, expected_did_key ); - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "DID document verification method does not match expected signing key" - })), + return Err(ApiError::InvalidRequest( + "DID document verification method does not match expected signing key".into(), )); } } @@ -333,23 +306,11 @@ async fn assert_valid_did_document_for_service( }; let resp = client.get(&url).send().await.map_err(|e| { warn!("Failed to fetch did:web document for {}: {:?}", did, e); - ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Could not resolve DID document: {}", e) - })), - ) + ApiError::InvalidRequest(format!("Could not resolve DID document: {}", e)) })?; let doc: serde_json::Value = resp.json().await.map_err(|e| { warn!("Failed to parse did:web document for {}: {:?}", did, e); - ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Could not parse DID document: {}", e) - })), - ) + ApiError::InvalidRequest(format!("Could not parse DID document: {}", e)) })?; let pds_endpoint = doc @@ -370,12 +331,8 @@ async fn assert_valid_did_document_for_service( "DID {} has endpoint {:?}, expected {}", did, pds_endpoint, expected_endpoint ); - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "DID document atproto_pds service endpoint does not match PDS public url" - })), + return Err(ApiError::InvalidRequest( + "DID document atproto_pds service endpoint does not match PDS public url".into(), )); } } @@ -441,15 +398,15 @@ pub async fn activate_account( did ); let did_validation_start = std::time::Instant::now(); - if let Err((status, json)) = - assert_valid_did_document_for_service(&state.db, &state.cache, &did, true).await + if let Err(e) = + assert_valid_did_document_for_service(&state.db, state.cache.clone(), did.as_str(), true).await { info!( "[MIGRATION] activateAccount: DID document validation FAILED for {} (took {:?})", did, did_validation_start.elapsed() ); - return (status, json).into_response(); + return e.into_response(); } info!( "[MIGRATION] activateAccount: DID document validation SUCCESS for {} (took {:?})", @@ -457,7 +414,7 @@ pub async fn activate_account( did_validation_start.elapsed() ); - let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) + let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did.as_str()) .fetch_optional(&state.db) .await .ok() @@ -466,7 +423,7 @@ pub async fn activate_account( "[MIGRATION] activateAccount: Activating account did={} handle={:?}", did, handle ); - let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did) + let result = sqlx::query!("UPDATE users SET deactivated_at = NULL WHERE did = $1", did.as_str()) .execute(&state.db) .await; match result { @@ -483,7 +440,7 @@ pub async fn activate_account( did ); if let Err(e) = - crate::api::repo::record::sequence_account_event(&state, &did, true, None).await + crate::api::repo::record::sequence_account_event(&state, did.as_str(), true, None).await { warn!( "[MIGRATION] activateAccount: Failed to sequence account activation event: {}", @@ -497,7 +454,7 @@ pub async fn activate_account( did, handle ); if let Err(e) = - crate::api::repo::record::sequence_identity_event(&state, &did, handle.as_deref()) + crate::api::repo::record::sequence_identity_event(&state, did.as_str(), handle.as_deref()) .await { warn!( @@ -509,7 +466,7 @@ pub async fn activate_account( } let repo_root = sqlx::query_scalar!( "SELECT r.repo_root_cid FROM repos r JOIN users u ON r.user_id = u.id WHERE u.did = $1", - did + did.as_str() ) .fetch_optional(&state.db) .await @@ -531,7 +488,7 @@ pub async fn activate_account( }; if let Err(e) = crate::api::repo::record::sequence_sync_event( &state, - &did, + did.as_str(), &root_cid, rev.as_deref(), ) @@ -551,18 +508,14 @@ pub async fn activate_account( ); } info!("[MIGRATION] activateAccount: SUCCESS for did={}", did); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } Err(e) => { error!( "[MIGRATION] activateAccount: DB error activating account: {:?}", e ); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -621,7 +574,7 @@ pub async fn deactivate_account( let did = auth_user.did; - let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) + let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did.as_str()) .fetch_optional(&state.db) .await .ok() @@ -629,7 +582,7 @@ pub async fn deactivate_account( let result = sqlx::query!( "UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1", - did, + did.as_str(), delete_after ) .execute(&state.db) @@ -642,7 +595,7 @@ pub async fn deactivate_account( } if let Err(e) = crate::api::repo::record::sequence_account_event( &state, - &did, + did.as_str(), false, Some("deactivated"), ) @@ -650,15 +603,11 @@ pub async fn deactivate_account( { warn!("Failed to sequence account deactivated event: {}", e); } - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } Err(e) => { error!("DB error deactivating account: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -694,21 +643,17 @@ pub async fn request_account_delete( }; let did = validated.did.clone(); - if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, &did).await { - return crate::api::server::reauth::legacy_mfa_required_response(&state.db, &did).await; + if !crate::api::server::reauth::check_legacy_session_mfa(&state.db, did.as_str()).await { + return crate::api::server::reauth::legacy_mfa_required_response(&state.db, did.as_str()).await; } - let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) + let user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did.as_str()) .fetch_optional(&state.db) .await { Ok(Some(id)) => id, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let confirmation_token = Uuid::new_v4().to_string(); @@ -716,18 +661,14 @@ pub async fn request_account_delete( let insert = sqlx::query!( "INSERT INTO account_deletion_requests (token, did, expires_at) VALUES ($1, $2, $3)", confirmation_token, - did, + did.as_str(), expires_at ) .execute(&state.db) .await; if let Err(e) = insert { error!("DB error creating deletion token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); if let Err(e) = @@ -737,13 +678,13 @@ pub async fn request_account_delete( warn!("Failed to enqueue account deletion notification: {:?}", e); } info!("Account deletion requested for user {}", did); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } #[derive(Deserialize)] pub struct DeleteAccountInput { - pub did: String, - pub password: String, + pub did: crate::types::Did, + pub password: PlainPassword, pub token: String, } @@ -751,60 +692,33 @@ pub async fn delete_account( State(state): State, Json(input): Json, ) -> Response { - let did = input.did.trim(); + let did = &input.did; let password = &input.password; let token = input.token.trim(); - if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); - } if password.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "password is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("password is required".into()).into_response(); } const OLD_PASSWORD_MAX_LENGTH: usize = 512; if password.len() > OLD_PASSWORD_MAX_LENGTH { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Invalid password length."})), - ) - .into_response(); + return ApiError::InvalidRequest("Invalid password length".into()).into_response(); } if token.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidToken", "message": "token is required"})), - ) - .into_response(); + return ApiError::InvalidToken(Some("token is required".into())).into_response(); } let user = sqlx::query!( "SELECT id, password_hash, handle FROM users WHERE did = $1", - did + did.as_str() ) .fetch_optional(&state.db) .await; let (user_id, password_hash, handle) = match user { Ok(Some(row)) => (row.id, row.password_hash, row.handle), Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::InvalidRequest("account not found".into()).into_response(); } Err(e) => { error!("DB error in delete_account: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let password_valid = if password_hash @@ -826,11 +740,7 @@ pub async fn delete_account( .any(|row| verify(password, &row.password_hash).unwrap_or(false)) }; if !password_valid { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed", "message": "Invalid password"})), - ) - .into_response(); + return ApiError::AuthenticationFailed(Some("Invalid password".into())).into_response(); } let deletion_request = sqlx::query!( "SELECT did, expires_at FROM account_deletion_requests WHERE token = $1", @@ -841,27 +751,15 @@ pub async fn delete_account( let (token_did, expires_at) = match deletion_request { Ok(Some(row)) => (row.did, row.expires_at), Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})), - ) - .into_response(); + return ApiError::InvalidToken(Some("Invalid or expired token".into())).into_response(); } Err(e) => { error!("DB error fetching deletion token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; - if token_did != did { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidToken", "message": "Token does not match account"})), - ) - .into_response(); + if token_did != did.as_str() { + return ApiError::InvalidToken(Some("Token does not match account".into())).into_response(); } if Utc::now() > expires_at { let _ = sqlx::query!( @@ -870,21 +768,13 @@ pub async fn delete_account( ) .execute(&state.db) .await; - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), - ) - .into_response(); + return ApiError::ExpiredToken(None).into_response(); } let mut tx = match state.db.begin().await { Ok(tx) => tx, Err(e) => { error!("Failed to begin transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let deletion_result: Result<(), sqlx::Error> = async { @@ -919,11 +809,7 @@ pub async fn delete_account( Ok(()) => { if let Err(e) = tx.commit().await { error!("Failed to commit account deletion transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let account_seq = crate::api::repo::record::sequence_account_event( &state, @@ -957,15 +843,11 @@ pub async fn delete_account( } let _ = state.cache.delete(&format!("handle:{}", handle)).await; info!("Account {} deleted successfully", did); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } Err(e) => { error!("DB error deleting account, rolling back: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } diff --git a/src/api/server/app_password.rs b/src/api/server/app_password.rs index 07ee771..52751f2 100644 --- a/src/api/server/app_password.rs +++ b/src/api/server/app_password.rs @@ -1,4 +1,5 @@ -use crate::api::ApiError; +use crate::api::error::ApiError; +use crate::api::EmptyResponse; use crate::auth::BearerAuth; use crate::delegation::{self, DelegationActionType}; use crate::state::{AppState, RateLimitKind}; @@ -60,7 +61,7 @@ pub async fn list_app_passwords( } Err(e) => { error!("DB error listing app passwords: {:?}", e); - ApiError::InternalError.into_response() + ApiError::InternalError(None).into_response() } } } @@ -95,14 +96,7 @@ pub async fn create_app_password( .await { warn!(ip = %client_ip, "App password creation rate limit exceeded"); - return ( - axum::http::StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many requests. Please try again later." - })), - ) - .into_response(); + return ApiError::RateLimitExceeded(None).into_response(); } let user_id = match get_user_id_by_did(&state.db, &auth_user.did).await { Ok(id) => id, @@ -134,7 +128,7 @@ pub async fn create_app_password( let intersected = delegation::intersect_scopes(requested, &granted_scopes); if intersected.is_empty() && !granted_scopes.is_empty() { - return ApiError::InsufficientScope.into_response(); + return ApiError::InsufficientScope(None).into_response(); } let scope_result = if intersected.is_empty() { @@ -167,11 +161,11 @@ pub async fn create_app_password( Ok(Ok(h)) => h, Ok(Err(e)) => { error!("Failed to hash password: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } Err(e) => { error!("Failed to spawn blocking task: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; let privileged = input.privileged.unwrap_or(false); @@ -184,7 +178,7 @@ pub async fn create_app_password( created_at, privileged, final_scopes, - controller_did + controller_did.as_deref() ) .execute(&state.db) .await @@ -218,7 +212,7 @@ pub async fn create_app_password( } Err(e) => { error!("DB error creating app password: {:?}", e); - ApiError::InternalError.into_response() + ApiError::InternalError(None).into_response() } } } @@ -243,7 +237,7 @@ pub async fn revoke_app_password( } let sessions_to_invalidate = sqlx::query_scalar!( "SELECT access_jti FROM session_tokens WHERE did = $1 AND app_password_name = $2", - auth_user.did, + &auth_user.did, name ) .fetch_all(&state.db) @@ -251,17 +245,17 @@ pub async fn revoke_app_password( .unwrap_or_default(); if let Err(e) = sqlx::query!( "DELETE FROM session_tokens WHERE did = $1 AND app_password_name = $2", - auth_user.did, + &auth_user.did, name ) .execute(&state.db) .await { error!("DB error revoking sessions for app password: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } for jti in &sessions_to_invalidate { - let cache_key = format!("auth:session:{}:{}", auth_user.did, jti); + let cache_key = format!("auth:session:{}:{}", &auth_user.did, jti); let _ = state.cache.delete(&cache_key).await; } if let Err(e) = sqlx::query!( @@ -273,7 +267,7 @@ pub async fn revoke_app_password( .await { error!("DB error revoking app password: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } - Json(json!({})).into_response() + EmptyResponse::ok().into_response() } diff --git a/src/api/server/email.rs b/src/api/server/email.rs index 2356985..b68383c 100644 --- a/src/api/server/email.rs +++ b/src/api/server/email.rs @@ -1,10 +1,10 @@ -use crate::api::ApiError; +use crate::api::error::ApiError; +use crate::api::{EmptyResponse, TokenRequiredResponse, VerifiedResponse}; use crate::auth::BearerAuth; use crate::state::{AppState, RateLimitKind}; use axum::{ Json, extract::State, - http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; @@ -22,14 +22,7 @@ pub async fn request_email_update( .await { warn!(ip = %client_ip, "Email update rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many requests. Please try again later." - })), - ) - .into_response(); + return ApiError::RateLimitExceeded(None).into_response(); } if let Err(e) = crate::auth::scope_check::check_account_scope( @@ -41,7 +34,7 @@ pub async fn request_email_update( return e; } - let did = auth.0.did.clone(); + let did = auth.0.did.to_string(); let user = match sqlx::query!( "SELECT id, handle, email, email_verified FROM users WHERE did = $1", did @@ -51,31 +44,17 @@ pub async fn request_email_update( { Ok(Some(row)) => row, Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; - let current_email: String = match user.email { - Some(e) => e, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "account does not have an email address"})), - ) - .into_response(); - } + let Some(current_email) = user.email else { + return ApiError::InvalidRequest("account does not have an email address".into()) + .into_response(); }; let token_required = user.email_verified; @@ -98,11 +77,7 @@ pub async fn request_email_update( } info!("Email update requested for user {}", user.id); - ( - StatusCode::OK, - Json(json!({ "tokenRequired": token_required })), - ) - .into_response() + TokenRequiredResponse::new(token_required).into_response() } #[derive(Deserialize)] @@ -124,14 +99,7 @@ pub async fn confirm_email( .await { warn!(ip = %client_ip, "Confirm email rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many requests. Please try again later." - })), - ) - .into_response(); + return ApiError::RateLimitExceeded(None).into_response(); } if let Err(e) = crate::auth::scope_check::check_account_scope( @@ -143,7 +111,7 @@ pub async fn confirm_email( return e; } - let did = auth.0.did; + let did = auth.0.did.to_string(); let user = match sqlx::query!( "SELECT id, email, email_verified FROM users WHERE did = $1", did @@ -153,44 +121,26 @@ pub async fn confirm_email( { Ok(Some(row)) => row, Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "AccountNotFound", "message": "user not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; - let current_email = match &user.email { - Some(e) => e.to_lowercase(), - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidEmail", "message": "account does not have an email address"})), - ) - .into_response(); - } + let Some(ref email) = user.email else { + return ApiError::InvalidEmail.into_response(); }; + let current_email = email.to_lowercase(); let provided_email = input.email.trim().to_lowercase(); if provided_email != current_email { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidEmail", "message": "invalid email"})), - ) - .into_response(); + return ApiError::InvalidEmail.into_response(); } if user.email_verified { - return (StatusCode::OK, Json(json!({}))).into_response(); + return EmptyResponse::ok().into_response(); } let confirmation_code = @@ -205,28 +155,14 @@ pub async fn confirm_email( match verified { Ok(token_data) => { if token_data.did != did { - return ( - StatusCode::BAD_REQUEST, - Json( - json!({"error": "InvalidToken", "message": "Token does not match account"}), - ), - ) - .into_response(); + return ApiError::InvalidToken(None).into_response(); } } Err(crate::auth::verification_token::VerifyError::Expired) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), - ) - .into_response(); + return ApiError::ExpiredToken(None).into_response(); } Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidToken", "message": "Invalid token"})), - ) - .into_response(); + return ApiError::InvalidToken(None).into_response(); } } @@ -239,15 +175,11 @@ pub async fn confirm_email( if let Err(e) = update { error!("DB error confirming email: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } info!("Email confirmed for user {}", user.id); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } #[derive(Deserialize)] @@ -264,17 +196,10 @@ pub async fn update_email( headers: axum::http::HeaderMap, Json(input): Json, ) -> Response { - let bearer_token = match crate::auth::extract_bearer_token_from_header( + let Some(bearer_token) = crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + ) else { + return ApiError::AuthenticationRequired.into_response(); }; let auth_result = crate::auth::validate_bearer_token(&state.db, &bearer_token).await; @@ -292,7 +217,7 @@ pub async fn update_email( return e; } - let did = auth_user.did; + let did = auth_user.did.to_string(); let user = match sqlx::query!( "SELECT id, email, email_verified FROM users WHERE did = $1", did @@ -302,19 +227,11 @@ pub async fn update_email( { Ok(Some(row)) => row, Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -324,36 +241,23 @@ pub async fn update_email( let new_email = input.email.trim().to_lowercase(); if !crate::api::validation::is_valid_email(&new_email) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "This email address is not supported, please use a different email." - })), + return ApiError::InvalidRequest( + "This email address is not supported, please use a different email.".into(), ) - .into_response(); + .into_response(); } if let Some(ref current) = current_email && new_email == current.to_lowercase() { - return (StatusCode::OK, Json(json!({}))).into_response(); + return EmptyResponse::ok().into_response(); } if email_verified { - let confirmation_token = match &input.token { - Some(t) => crate::auth::verification_token::normalize_token_input(t.trim()), - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "TokenRequired", - "message": "confirmation token required" - })), - ) - .into_response(); - } + let Some(ref t) = input.token else { + return ApiError::TokenRequired.into_response(); }; + let confirmation_token = crate::auth::verification_token::normalize_token_input(t.trim()); let current_email_lower = current_email .as_ref() @@ -369,28 +273,14 @@ pub async fn update_email( match verified { Ok(token_data) => { if token_data.did != did { - return ( - StatusCode::BAD_REQUEST, - Json( - json!({"error": "InvalidToken", "message": "Token does not match account"}), - ), - ) - .into_response(); + return ApiError::InvalidToken(None).into_response(); } } Err(crate::auth::verification_token::VerifyError::Expired) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), - ) - .into_response(); + return ApiError::ExpiredToken(None).into_response(); } Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidToken", "message": "Invalid token"})), - ) - .into_response(); + return ApiError::InvalidToken(None).into_response(); } } } @@ -404,14 +294,7 @@ pub async fn update_email( .await; if let Ok(Some(_)) = exists { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "This email address is already in use, please use a different email." - })), - ) - .into_response(); + return ApiError::InvalidRequest("Email is already in use".into()).into_response(); } let update: Result = sqlx::query!( @@ -428,20 +311,9 @@ pub async fn update_email( .map(|db_err: &dyn sqlx::error::DatabaseError| db_err.is_unique_violation()) .unwrap_or(false) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "This email address is already in use, please use a different email." - })), - ) - .into_response(); + return ApiError::EmailTaken.into_response(); } - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let verification_token = @@ -474,7 +346,7 @@ pub async fn update_email( } info!("Email updated for user {}", user_id); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } #[derive(Deserialize)] @@ -492,14 +364,7 @@ pub async fn check_email_verified( .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) .await { - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many requests. Please try again later." - })), - ) - .into_response(); + return ApiError::RateLimitExceeded(None).into_response(); } let user = sqlx::query!( @@ -510,23 +375,11 @@ pub async fn check_email_verified( .await; match user { - Ok(Some(row)) => ( - StatusCode::OK, - Json(json!({ "verified": row.email_verified })), - ) - .into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "AccountNotFound", "message": "Account not found" })), - ) - .into_response(), + Ok(Some(row)) => VerifiedResponse::new(row.email_verified).into_response(), + Ok(None) => ApiError::AccountNotFound.into_response(), Err(e) => { error!("DB error checking email verified: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "InternalError" })), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } diff --git a/src/api/server/invite.rs b/src/api/server/invite.rs index 2ace680..0eba108 100644 --- a/src/api/server/invite.rs +++ b/src/api/server/invite.rs @@ -53,7 +53,7 @@ pub async fn create_invite_code( return ApiError::InvalidRequest("useCount must be at least 1".into()).into_response(); } - let for_account = input.for_account.unwrap_or_else(|| auth_user.did.clone()); + let for_account = input.for_account.unwrap_or_else(|| auth_user.did.to_string()); let code = gen_invite_code(); match sqlx::query!( @@ -69,13 +69,13 @@ pub async fn create_invite_code( Ok(result) => { if result.rows_affected() == 0 { error!("No admin user found to create invite code"); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } Json(CreateInviteCodeOutput { code }).into_response() } Err(e) => { error!("DB error creating invite code: {:?}", e); - ApiError::InternalError.into_response() + ApiError::InternalError(None).into_response() } } } @@ -112,7 +112,7 @@ pub async fn create_invite_codes( let for_accounts = input .for_accounts .filter(|v| !v.is_empty()) - .unwrap_or_else(|| vec![auth_user.did.clone()]); + .unwrap_or_else(|| vec![auth_user.did.to_string()]); let admin_user_id = match sqlx::query_scalar!("SELECT id FROM users WHERE is_admin = true LIMIT 1") @@ -122,11 +122,11 @@ pub async fn create_invite_codes( Ok(Some(id)) => id, Ok(None) => { error!("No admin user found to create invite codes"); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } Err(e) => { error!("DB error looking up admin user: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -147,7 +147,7 @@ pub async fn create_invite_codes( .await { error!("DB error creating invite code: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } codes.push(code); } @@ -213,7 +213,7 @@ pub async fn get_account_invite_codes( WHERE ic.for_account = $1 ORDER BY ic.created_at DESC "#, - auth_user.did + &auth_user.did ) .fetch_all(&state.db) .await @@ -221,7 +221,7 @@ pub async fn get_account_invite_codes( Ok(rows) => rows, Err(e) => { error!("DB error fetching invite codes: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; diff --git a/src/api/server/migration.rs b/src/api/server/migration.rs index 3dd9ada..c61369b 100644 --- a/src/api/server/migration.rs +++ b/src/api/server/migration.rs @@ -66,19 +66,15 @@ pub async fn update_did_document( }; if !auth_user.did.starts_with("did:web:") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "DID document updates are only available for did:web accounts" - })), + return ApiError::InvalidRequest( + "DID document updates are only available for did:web accounts".into(), ) - .into_response(); + .into_response(); } let user = match sqlx::query!( "SELECT id, handle, deactivated_at FROM users WHERE did = $1", - auth_user.did + &auth_user.did ) .fetch_optional(&state.db) .await @@ -87,7 +83,7 @@ pub async fn update_did_document( Ok(None) => return ApiError::AccountNotFound.into_response(), Err(e) => { tracing::error!("DB error getting user: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -171,7 +167,7 @@ pub async fn update_did_document( if let Err(e) = upsert_result { tracing::error!("DB error upserting did_web_overrides: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } if let Some(ref endpoint) = input.service_endpoint { @@ -180,20 +176,20 @@ pub async fn update_did_document( "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", endpoint_clean, now, - auth_user.did + &auth_user.did ) .execute(&state.db) .await; if let Err(e) = update_result { tracing::error!("DB error updating service endpoint: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } } let did_doc = build_did_document(&state.db, &auth_user.did).await; - tracing::info!("Updated DID document for {}", auth_user.did); + tracing::info!("Updated DID document for {}", &auth_user.did); ( StatusCode::OK, @@ -236,14 +232,10 @@ pub async fn get_did_document( }; if !auth_user.did.starts_with("did:web:") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "This endpoint is only available for did:web accounts" - })), + return ApiError::InvalidRequest( + "This endpoint is only available for did:web accounts".into(), ) - .into_response(); + .into_response(); } let did_doc = build_did_document(&state.db, &auth_user.did).await; diff --git a/src/api/server/passkey_account.rs b/src/api/server/passkey_account.rs index 5eec41a..93c5e10 100644 --- a/src/api/server/passkey_account.rs +++ b/src/api/server/passkey_account.rs @@ -1,7 +1,9 @@ +use crate::api::SuccessResponse; +use crate::api::error::ApiError; use axum::{ Json, extract::State, - http::{HeaderMap, StatusCode}, + http::HeaderMap, response::{IntoResponse, Response}, }; use bcrypt::{DEFAULT_COST, hash}; @@ -18,6 +20,7 @@ use uuid::Uuid; use crate::api::repo::record::utils::create_signed_commit; use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token}; use crate::state::{AppState, RateLimitKind}; +use crate::types::{Did, Handle, PlainPassword}; use crate::validation::validate_password; fn extract_client_ip(headers: &HeaderMap) -> String { @@ -80,8 +83,8 @@ pub struct CreatePasskeyAccountInput { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CreatePasskeyAccountResponse { - pub did: String, - pub handle: String, + pub did: Did, + pub handle: Handle, pub setup_token: String, pub setup_expires_at: chrono::DateTime, #[serde(skip_serializing_if = "Option::is_none")] @@ -99,14 +102,8 @@ pub async fn create_passkey_account( .await { warn!(ip = %client_ip, "Account creation rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many account creation attempts. Please try again later." - })), - ) - .into_response(); + return ApiError::RateLimitExceeded(Some("Too many account creation attempts. Please try again later.".into(),)) + .into_response(); } let byod_auth = if let Some(token) = @@ -127,14 +124,11 @@ pub async fn create_passkey_account( } Err(e) => { error!("Service token verification failed: {:?}", e); - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "AuthenticationFailed", - "message": format!("Service token verification failed: {}", e) - })), - ) - .into_response(); + return ApiError::AuthenticationFailed(Some(format!( + "Service token verification failed: {}", + e + ))) + .into_response(); } } } else { @@ -165,12 +159,8 @@ pub async fn create_passkey_account( }; match crate::api::validation::validate_short_handle(handle_to_validate) { Ok(h) => format!("{}.{}", h, hostname), - Err(e) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidHandle", "message": e.to_string()})), - ) - .into_response(); + Err(_) => { + return ApiError::InvalidHandle(None).into_response(); } } } else { @@ -185,11 +175,7 @@ pub async fn create_passkey_account( if let Some(ref email) = email && !crate::api::validation::is_valid_email(email) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})), - ) - .into_response(); + return ApiError::InvalidEmail.into_response(); } if let Some(ref code) = input.invite_code { @@ -204,22 +190,14 @@ pub async fn create_passkey_account( .unwrap_or(Some(false)); if valid != Some(true) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidInviteCode", "message": "Invalid or expired invite code"})), - ) - .into_response(); + return ApiError::InvalidInviteCode.into_response(); } } else { let invite_required = std::env::var("INVITE_CODE_REQUIRED") .map(|v| v == "true" || v == "1") .unwrap_or(false); if invite_required { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InviteCodeRequired", "message": "An invite code is required to create an account"})), - ) - .into_response(); + return ApiError::InviteCodeRequired.into_response(); } } @@ -227,36 +205,21 @@ pub async fn create_passkey_account( let verification_recipient = match verification_channel { "email" => match &email { Some(e) if !e.is_empty() => e.clone(), - _ => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})), - ).into_response(), + _ => return ApiError::MissingEmail.into_response(), }, "discord" => match &input.discord_id { Some(id) if !id.trim().is_empty() => id.trim().to_string(), - _ => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})), - ).into_response(), + _ => return ApiError::MissingDiscordId.into_response(), }, "telegram" => match &input.telegram_username { Some(username) if !username.trim().is_empty() => username.trim().to_string(), - _ => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})), - ).into_response(), + _ => return ApiError::MissingTelegramUsername.into_response(), }, "signal" => match &input.signal_number { Some(number) if !number.trim().is_empty() => number.trim().to_string(), - _ => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})), - ).into_response(), + _ => return ApiError::MissingSignalNumber.into_response(), }, - _ => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})), - ).into_response(), + _ => return ApiError::InvalidVerificationChannel.into_response(), }; use k256::ecdsa::SigningKey; @@ -283,22 +246,11 @@ pub async fn create_passkey_account( match reserved { Ok(Some(row)) => (row.private_key_bytes, Some(row.id)), Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidSigningKey", - "message": "Signing key not found, already used, or expired" - })), - ) - .into_response(); + return ApiError::InvalidSigningKey.into_response(); } Err(e) => { error!("Error looking up reserved signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } } else { @@ -310,11 +262,7 @@ pub async fn create_passkey_account( Ok(k) => k, Err(e) => { error!("Error creating signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -330,34 +278,25 @@ pub async fn create_passkey_account( let d = match &input.did { Some(d) if !d.trim().is_empty() => d.trim(), _ => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})), + return ApiError::InvalidRequest( + "External did:web requires the 'did' field to be provided".into(), ) - .into_response(); + .into_response(); } }; if !d.starts_with("did:web:") { - return ( - StatusCode::BAD_REQUEST, - Json( - json!({"error": "InvalidDid", "message": "External DID must be a did:web"}), - ), - ) + return ApiError::InvalidDid("External DID must be a did:web".into()) .into_response(); } if is_byod_did_web { if let Some(ref auth_did) = byod_auth && d != auth_did { - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "AuthorizationError", - "message": format!("Service token issuer {} does not match DID {}", auth_did, d) - })), - ) - .into_response(); + return ApiError::AuthorizationError(format!( + "Service token issuer {} does not match DID {}", + auth_did, d + )) + .into_response(); } info!(did = %d, "Creating external did:web passkey account (BYOD key)"); } else { @@ -369,11 +308,7 @@ pub async fn create_passkey_account( ) .await { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidDid", "message": e})), - ) - .into_response(); + return ApiError::InvalidDid(e).into_response(); } info!(did = %d, "Creating external did:web passkey account (reserved key)"); } @@ -384,36 +319,25 @@ pub async fn create_passkey_account( if let Some(ref provided_did) = input.did { if provided_did.starts_with("did:plc:") { if provided_did != auth_did { - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "AuthorizationError", - "message": format!("Service token issuer {} does not match DID {}", auth_did, provided_did) - })), - ) - .into_response(); + return ApiError::AuthorizationError(format!( + "Service token issuer {} does not match DID {}", + auth_did, provided_did + )) + .into_response(); } info!(did = %provided_did, "Creating BYOD did:plc passkey account (migration)"); provided_did.clone() } else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "BYOD migration requires a did:plc or did:web DID" - })), + return ApiError::InvalidRequest( + "BYOD migration requires a did:plc or did:web DID".into(), ) - .into_response(); + .into_response(); } } else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "BYOD migration requires the 'did' field" - })), + return ApiError::InvalidRequest( + "BYOD migration requires the 'did' field".into(), ) - .into_response(); + .into_response(); } } else { let rotation_key = std::env::var("PLC_ROTATION_KEY") @@ -428,10 +352,7 @@ pub async fn create_passkey_account( Ok(r) => r, Err(e) => { error!("Error creating PLC genesis operation: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})), - ) + return ApiError::InternalError(Some("Failed to create PLC operation".into())) .into_response(); } }; @@ -442,14 +363,11 @@ pub async fn create_passkey_account( .await { error!("Failed to submit PLC genesis operation: {:?}", e); - return ( - StatusCode::BAD_GATEWAY, - Json(json!({ - "error": "UpstreamError", - "message": format!("Failed to register DID with PLC directory: {}", e) - })), - ) - .into_response(); + return ApiError::UpstreamErrorMsg(format!( + "Failed to register DID with PLC directory: {}", + e + )) + .into_response(); } genesis_result.did } @@ -463,11 +381,7 @@ pub async fn create_passkey_account( Ok(h) => h, Err(e) => { error!("Error hashing setup token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let setup_expires_at = Utc::now() + Duration::hours(1); @@ -476,11 +390,7 @@ pub async fn create_passkey_account( Ok(tx) => tx, Err(e) => { error!("Error starting transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -545,27 +455,13 @@ pub async fn create_passkey_account( { let constraint = db_err.constraint().unwrap_or(""); if constraint.contains("handle") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "HandleNotAvailable", "message": "Handle already taken"})), - ) - .into_response(); + return ApiError::HandleNotAvailable(None).into_response(); } else if constraint.contains("email") { - return ( - StatusCode::BAD_REQUEST, - Json( - json!({"error": "InvalidEmail", "message": "Email already registered"}), - ), - ) - .into_response(); + return ApiError::EmailTaken.into_response(); } } error!("Error inserting user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -573,11 +469,7 @@ pub async fn create_passkey_account( Ok(bytes) => bytes, Err(e) => { error!("Error encrypting signing key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -591,11 +483,7 @@ pub async fn create_passkey_account( .await { error!("Error inserting user key: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Some(key_id) = reserved_key_id @@ -607,11 +495,7 @@ pub async fn create_passkey_account( .await { error!("Error marking reserved key as used: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let mst = Mst::new(Arc::new(state.block_store.clone())); @@ -619,11 +503,7 @@ pub async fn create_passkey_account( Ok(c) => c, Err(e) => { error!("Error persisting MST: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let rev = Tid::now(LimitedU32::MIN); @@ -632,22 +512,14 @@ pub async fn create_passkey_account( Ok(result) => result, Err(e) => { error!("Error creating genesis commit: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await { Ok(c) => c, Err(e) => { error!("Error saving genesis commit: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let commit_cid_str = commit_cid.to_string(); @@ -662,11 +534,7 @@ pub async fn create_passkey_account( .await { error!("Error inserting repo: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()]; if let Err(e) = sqlx::query!( @@ -682,11 +550,7 @@ pub async fn create_passkey_account( .await { error!("Error inserting user_blocks: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Some(ref code) = input.invite_code { @@ -727,11 +591,7 @@ pub async fn create_passkey_account( if let Err(e) = tx.commit().await { error!("Error committing transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if !is_byod_did_web { @@ -819,8 +679,8 @@ pub async fn create_passkey_account( }; Json(CreatePasskeyAccountResponse { - did, - handle, + did: did.into(), + handle: handle.into(), setup_token, setup_expires_at, access_jwt, @@ -831,7 +691,7 @@ pub async fn create_passkey_account( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct CompletePasskeySetupInput { - pub did: String, + pub did: Did, pub setup_token: String, pub passkey_credential: serde_json::Value, pub passkey_friendly_name: Option, @@ -840,8 +700,8 @@ pub struct CompletePasskeySetupInput { #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct CompletePasskeySetupResponse { - pub did: String, - pub handle: String, + pub did: Did, + pub handle: Handle, pub app_password: String, pub app_password_name: String, } @@ -853,7 +713,7 @@ pub async fn complete_passkey_setup( let user = sqlx::query!( r#"SELECT id, handle, recovery_token, recovery_token_expires_at, password_required FROM users WHERE did = $1"#, - input.did + input.did.as_str() ) .fetch_optional(&state.db) .await; @@ -861,57 +721,33 @@ pub async fn complete_passkey_setup( let user = match user { Ok(Some(u)) => u, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if user.password_required { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidAccount", "message": "This account is not a passkey-only account"})), - ) - .into_response(); + return ApiError::InvalidAccount.into_response(); } let token_hash = match &user.recovery_token { Some(h) => h, None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "SetupExpired", "message": "Setup has already been completed or expired"})), - ) - .into_response(); + return ApiError::SetupExpired.into_response(); } }; if let Some(expires_at) = user.recovery_token_expires_at && expires_at < Utc::now() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "SetupExpired", "message": "Setup token has expired"})), - ) - .into_response(); + return ApiError::SetupExpired.into_response(); } if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "InvalidToken", "message": "Invalid setup token"})), - ) - .into_response(); + return ApiError::InvalidToken(None).into_response(); } let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); @@ -919,11 +755,7 @@ pub async fn complete_passkey_setup( Ok(w) => w, Err(e) => { error!("Failed to create WebAuthn config: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -932,19 +764,11 @@ pub async fn complete_passkey_setup( { Ok(Some(s)) => s, Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "NoChallengeInProgress", "message": "Please start passkey registration first"})), - ) - .into_response(); + return ApiError::NoChallengeInProgress.into_response(); } Err(e) => { error!("Error loading registration state: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -953,13 +777,7 @@ pub async fn complete_passkey_setup( Ok(c) => c, Err(e) => { warn!("Failed to parse credential: {:?}", e); - return ( - StatusCode::BAD_REQUEST, - Json( - json!({"error": "InvalidCredential", "message": "Failed to parse credential"}), - ), - ) - .into_response(); + return ApiError::InvalidCredential.into_response(); } }; @@ -967,11 +785,7 @@ pub async fn complete_passkey_setup( Ok(sk) => sk, Err(e) => { warn!("Passkey registration failed: {:?}", e); - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "RegistrationFailed", "message": "Passkey registration failed"})), - ) - .into_response(); + return ApiError::RegistrationFailed.into_response(); } }; @@ -984,11 +798,7 @@ pub async fn complete_passkey_setup( .await { error!("Error saving passkey: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await; @@ -999,11 +809,7 @@ pub async fn complete_passkey_setup( Ok(h) => h, Err(e) => { error!("Error hashing app password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -1017,16 +823,12 @@ pub async fn complete_passkey_setup( .await { error!("Error creating app password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Err(e) = sqlx::query!( "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1", - input.did + input.did.as_str() ) .execute(&state.db) .await @@ -1037,8 +839,8 @@ pub async fn complete_passkey_setup( info!(did = %input.did, "Passkey-only account setup completed"); Json(CompletePasskeySetupResponse { - did: input.did, - handle: user.handle, + did: input.did.clone(), + handle: user.handle.into(), app_password, app_password_name, }) @@ -1052,7 +854,7 @@ pub async fn start_passkey_registration_for_setup( let user = sqlx::query!( r#"SELECT handle, recovery_token, recovery_token_expires_at, password_required FROM users WHERE did = $1"#, - input.did + input.did.as_str() ) .fetch_optional(&state.db) .await; @@ -1060,57 +862,33 @@ pub async fn start_passkey_registration_for_setup( let user = match user { Ok(Some(u)) => u, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if user.password_required { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidAccount"})), - ) - .into_response(); + return ApiError::InvalidAccount.into_response(); } let token_hash = match &user.recovery_token { Some(h) => h, None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "SetupExpired"})), - ) - .into_response(); + return ApiError::SetupExpired.into_response(); } }; if let Some(expires_at) = user.recovery_token_expires_at && expires_at < Utc::now() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "SetupExpired"})), - ) - .into_response(); + return ApiError::SetupExpired.into_response(); } if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "InvalidToken"})), - ) - .into_response(); + return ApiError::InvalidToken(None).into_response(); } let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); @@ -1118,11 +896,7 @@ pub async fn start_passkey_registration_for_setup( Ok(w) => w, Err(e) => { error!("Failed to create WebAuthn config: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -1146,11 +920,7 @@ pub async fn start_passkey_registration_for_setup( Ok(result) => result, Err(e) => { error!("Failed to start passkey registration: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -1158,11 +928,7 @@ pub async fn start_passkey_registration_for_setup( crate::auth::webauthn::save_registration_state(&state.db, &input.did, ®_state).await { error!("Failed to save registration state: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let options = serde_json::to_value(&ccr).unwrap_or(json!({})); @@ -1172,7 +938,7 @@ pub async fn start_passkey_registration_for_setup( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct StartPasskeyRegistrationInput { - pub did: String, + pub did: Did, pub setup_token: String, pub friendly_name: Option, } @@ -1194,11 +960,7 @@ pub async fn request_passkey_recovery( .check_rate_limit(RateLimitKind::PasswordReset, &client_ip) .await { - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({"error": "RateLimitExceeded"})), - ) - .into_response(); + return ApiError::RateLimitExceeded(None).into_response(); } let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); @@ -1221,7 +983,7 @@ pub async fn request_passkey_recovery( let user = match user { Ok(Some(u)) if !u.password_required => u, _ => { - return Json(json!({"success": true})).into_response(); + return SuccessResponse::ok().into_response(); } }; @@ -1229,11 +991,7 @@ pub async fn request_passkey_recovery( let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) { Ok(h) => h, Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let expires_at = Utc::now() + Duration::hours(1); @@ -1242,17 +1000,13 @@ pub async fn request_passkey_recovery( "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3", recovery_token_hash, expires_at, - user.did + &user.did ) .execute(&state.db) .await { error!("Error updating recovery token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); @@ -1267,15 +1021,15 @@ pub async fn request_passkey_recovery( crate::comms::enqueue_passkey_recovery(&state.db, user.id, &recovery_url, &hostname).await; info!(did = %user.did, "Passkey recovery requested"); - Json(json!({"success": true})).into_response() + SuccessResponse::ok().into_response() } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct RecoverPasskeyAccountInput { - pub did: String, + pub did: Did, pub recovery_token: String, - pub new_password: String, + pub new_password: PlainPassword, } pub async fn recover_passkey_account( @@ -1283,19 +1037,12 @@ pub async fn recover_passkey_account( Json(input): Json, ) -> Response { if let Err(e) = validate_password(&input.new_password) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidPassword", - "message": e.to_string() - })), - ) - .into_response(); + return ApiError::InvalidRequest(e.to_string()).into_response(); } let user = sqlx::query!( "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1", - input.did + input.did.as_str() ) .fetch_optional(&state.db) .await; @@ -1303,71 +1050,47 @@ pub async fn recover_passkey_account( let user = match user { Ok(Some(u)) => u, _ => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "InvalidRecoveryLink"})), - ) - .into_response(); + return ApiError::InvalidRecoveryLink.into_response(); } }; let token_hash = match &user.recovery_token { Some(h) => h, None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRecoveryLink"})), - ) - .into_response(); + return ApiError::InvalidRecoveryLink.into_response(); } }; if let Some(expires_at) = user.recovery_token_expires_at && expires_at < Utc::now() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "RecoveryLinkExpired"})), - ) - .into_response(); + return ApiError::RecoveryLinkExpired.into_response(); } if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "InvalidRecoveryLink"})), - ) - .into_response(); + return ApiError::InvalidRecoveryLink.into_response(); } let password_hash = match hash(&input.new_password, DEFAULT_COST) { Ok(h) => h, Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if let Err(e) = sqlx::query!( "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2", password_hash, - input.did + input.did.as_str() ) .execute(&state.db) .await { error!("Error updating password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } - let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did) + let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did.as_str()) .execute(&state.db) .await; match deleted { @@ -1382,5 +1105,5 @@ pub async fn recover_passkey_account( } info!(did = %input.did, "Passkey-only account recovered with temporary password"); - Json(json!({"success": true})).into_response() + SuccessResponse::ok().into_response() } diff --git a/src/api/server/passkeys.rs b/src/api/server/passkeys.rs index 8da624e..dd10d80 100644 --- a/src/api/server/passkeys.rs +++ b/src/api/server/passkeys.rs @@ -1,3 +1,5 @@ +use crate::api::EmptyResponse; +use crate::api::error::ApiError; use crate::auth::BearerAuth; use crate::auth::webauthn::{ self, WebAuthnConfig, delete_passkey as db_delete_passkey, delete_registration_state, @@ -8,22 +10,17 @@ use crate::state::AppState; use axum::{ Json, extract::State, - http::StatusCode, response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; -use serde_json::json; use tracing::{error, info, warn}; use webauthn_rs::prelude::*; -fn get_webauthn() -> Result)> { +fn get_webauthn() -> Result { let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); WebAuthnConfig::new(&hostname).map_err(|e| { error!("Failed to create WebAuthn config: {}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "WebAuthn configuration failed"})), - ) + ApiError::InternalError(Some("WebAuthn configuration failed".into())) }) } @@ -49,26 +46,18 @@ pub async fn start_passkey_registration( Err(e) => return e.into_response(), }; - let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", auth.0.did) + let user = sqlx::query!("SELECT handle FROM users WHERE did = $1", &*auth.0.did) .fetch_optional(&state.db) .await; let handle = match user { Ok(Some(row)) => row.handle, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error fetching user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -76,11 +65,7 @@ pub async fn start_passkey_registration( Ok(passkeys) => passkeys, Err(e) => { error!("DB error fetching existing passkeys: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -100,24 +85,17 @@ pub async fn start_passkey_registration( Ok(result) => result, Err(e) => { error!("Failed to start passkey registration: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to start registration"})), - ) + return ApiError::InternalError(Some("Failed to start registration".into())) .into_response(); } }; if let Err(e) = save_registration_state(&state.db, &auth.0.did, ®_state).await { error!("Failed to save registration state: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } - let options = serde_json::to_value(&ccr).unwrap_or(json!({})); + let options = serde_json::to_value(&ccr).unwrap_or(serde_json::json!({})); info!(did = %auth.0.did, "Passkey registration started"); @@ -151,22 +129,11 @@ pub async fn finish_passkey_registration( let reg_state = match load_registration_state(&state.db, &auth.0.did).await { Ok(Some(state)) => state, Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "NoRegistrationInProgress", - "message": "No registration in progress. Call startPasskeyRegistration first." - })), - ) - .into_response(); + return ApiError::NoRegistrationInProgress.into_response(); } Err(e) => { error!("DB error loading registration state: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -174,14 +141,7 @@ pub async fn finish_passkey_registration( Ok(c) => c, Err(e) => { warn!("Failed to parse credential: {:?}", e); - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidCredential", - "message": "Failed to parse credential response" - })), - ) - .into_response(); + return ApiError::InvalidCredential.into_response(); } }; @@ -189,14 +149,7 @@ pub async fn finish_passkey_registration( Ok(pk) => pk, Err(e) => { warn!("Failed to finish passkey registration: {}", e); - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "RegistrationFailed", - "message": "Failed to verify passkey registration" - })), - ) - .into_response(); + return ApiError::RegistrationFailed.into_response(); } }; @@ -211,11 +164,7 @@ pub async fn finish_passkey_registration( Ok(id) => id, Err(e) => { error!("Failed to save passkey: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -258,11 +207,7 @@ pub async fn list_passkeys(State(state): State, auth: BearerAuth) -> R Ok(pks) => pks, Err(e) => { error!("DB error fetching passkeys: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -306,31 +251,19 @@ pub async fn delete_passkey( let id: uuid::Uuid = match input.id.parse() { Ok(id) => id, Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})), - ) - .into_response(); + return ApiError::InvalidId.into_response(); } }; match db_delete_passkey(&state.db, id, &auth.0.did).await { Ok(true) => { info!(did = %auth.0.did, passkey_id = %id, "Passkey deleted"); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})), - ) - .into_response(), + Ok(false) => ApiError::PasskeyNotFound.into_response(), Err(e) => { error!("DB error deleting passkey: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -350,31 +283,19 @@ pub async fn update_passkey( let id: uuid::Uuid = match input.id.parse() { Ok(id) => id, Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidId", "message": "Invalid passkey ID"})), - ) - .into_response(); + return ApiError::InvalidId.into_response(); } }; match db_update_passkey_name(&state.db, id, &auth.0.did, &input.friendly_name).await { Ok(true) => { info!(did = %auth.0.did, passkey_id = %id, "Passkey renamed"); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } - Ok(false) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "PasskeyNotFound", "message": "Passkey not found"})), - ) - .into_response(), + Ok(false) => ApiError::PasskeyNotFound.into_response(), Err(e) => { error!("DB error updating passkey: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } diff --git a/src/api/server/password.rs b/src/api/server/password.rs index ce9ed70..033b44d 100644 --- a/src/api/server/password.rs +++ b/src/api/server/password.rs @@ -1,16 +1,18 @@ +use crate::api::error::ApiError; +use crate::api::{EmptyResponse, HasPasswordResponse, SuccessResponse}; use crate::auth::BearerAuth; use crate::state::{AppState, RateLimitKind}; +use crate::types::PlainPassword; use crate::validation::validate_password; use axum::{ Json, extract::State, - http::{HeaderMap, StatusCode}, + http::HeaderMap, response::{IntoResponse, Response}, }; use bcrypt::{DEFAULT_COST, hash, verify}; use chrono::{Duration, Utc}; use serde::Deserialize; -use serde_json::json; use tracing::{error, info, warn}; use uuid::Uuid; @@ -49,22 +51,11 @@ pub async fn request_password_reset( .await { warn!(ip = %client_ip, "Password reset rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many password reset requests. Please try again later." - })), - ) - .into_response(); + return ApiError::RateLimitExceeded(None).into_response(); } let identifier = input.email.trim(); if identifier.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "email or handle is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("email or handle is required".into()).into_response(); } let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let normalized = identifier.to_lowercase(); @@ -85,15 +76,11 @@ pub async fn request_password_reset( Ok(Some(row)) => row.id, Ok(None) => { info!("Password reset requested for unknown identifier"); - return (StatusCode::OK, Json(json!({}))).into_response(); + return EmptyResponse::ok().into_response(); } Err(e) => { error!("DB error in request_password_reset: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let code = generate_reset_code(); @@ -108,11 +95,7 @@ pub async fn request_password_reset( .await; if let Err(e) = update { error!("DB error setting reset code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); if let Err(e) = crate::comms::enqueue_password_reset(&state.db, user_id, &code, &hostname).await @@ -120,13 +103,13 @@ pub async fn request_password_reset( warn!("Failed to enqueue password reset notification: {:?}", e); } info!("Password reset requested for user {}", user_id); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } #[derive(Deserialize)] pub struct ResetPasswordInput { pub token: String, - pub password: String, + pub password: PlainPassword, } pub async fn reset_password( @@ -140,40 +123,18 @@ pub async fn reset_password( .await { warn!(ip = %client_ip, "Reset password rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many requests. Please try again later." - })), - ) - .into_response(); + return ApiError::RateLimitExceeded(None).into_response(); } let token = input.token.trim(); let password = &input.password; if token.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidToken", "message": "token is required"})), - ) - .into_response(); + return ApiError::InvalidToken(None).into_response(); } if password.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "password is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("password is required".into()).into_response(); } if let Err(e) = validate_password(password) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidPassword", - "message": e.to_string() - })), - ) - .into_response(); + return ApiError::InvalidRequest(e.to_string()).into_response(); } let user = sqlx::query!( "SELECT id, password_reset_code, password_reset_code_expires_at FROM users WHERE password_reset_code = $1", @@ -187,19 +148,11 @@ pub async fn reset_password( (row.id, expires) } Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})), - ) - .into_response(); + return ApiError::InvalidToken(None).into_response(); } Err(e) => { error!("DB error in reset_password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if let Some(exp) = expires_at { @@ -213,18 +166,10 @@ pub async fn reset_password( { error!("Failed to clear expired reset code: {:?}", e); } - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "ExpiredToken", "message": "Token has expired"})), - ) - .into_response(); + return ApiError::ExpiredToken(None).into_response(); } } else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidToken", "message": "Invalid or expired token"})), - ) - .into_response(); + return ApiError::InvalidToken(None).into_response(); } let password_clone = password.to_string(); let password_hash = @@ -232,30 +177,18 @@ pub async fn reset_password( Ok(Ok(h)) => h, Ok(Err(e)) => { error!("Failed to hash password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } Err(e) => { error!("Failed to spawn blocking task: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let mut tx = match state.db.begin().await { Ok(tx) => tx, Err(e) => { error!("Failed to begin transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if let Err(e) = sqlx::query!( @@ -267,11 +200,7 @@ pub async fn reset_password( .await { error!("DB error updating password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let user_did = match sqlx::query_scalar!("SELECT did FROM users WHERE id = $1", user_id) .fetch_one(&mut *tx) @@ -280,11 +209,7 @@ pub async fn reset_password( Ok(did) => did, Err(e) => { error!("Failed to get DID for user {}: {:?}", user_id, e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let session_jtis: Vec = match sqlx::query_scalar!( @@ -308,19 +233,11 @@ pub async fn reset_password( "Failed to invalidate sessions after password reset: {:?}", e ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Err(e) = tx.commit().await { error!("Failed to commit password reset transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } for jti in session_jtis { let cache_key = format!("auth:session:{}:{}", user_did, jti); @@ -332,14 +249,14 @@ pub async fn reset_password( } } info!("Password reset completed for user {}", user_id); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ChangePasswordInput { - pub current_password: String, - pub new_password: String, + pub current_password: PlainPassword, + pub new_password: PlainPassword, } pub async fn change_password( @@ -355,28 +272,13 @@ pub async fn change_password( let current_password = &input.current_password; let new_password = &input.new_password; if current_password.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "currentPassword is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("currentPassword is required".into()).into_response(); } if new_password.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "newPassword is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("newPassword is required".into()).into_response(); } if let Err(e) = validate_password(new_password) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidPassword", - "message": e.to_string() - })), - ) - .into_response(); + return ApiError::InvalidRequest(e.to_string()).into_response(); } let user = sqlx::query_as::<_, (Uuid, String)>("SELECT id, password_hash FROM users WHERE did = $1") @@ -386,38 +288,22 @@ pub async fn change_password( let (user_id, password_hash) = match user { Ok(Some(row)) => row, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error in change_password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let valid = match verify(current_password, &password_hash) { Ok(v) => v, Err(e) => { error!("Password verification error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if !valid { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "InvalidPassword", "message": "Current password is incorrect"})), - ) - .into_response(); + return ApiError::InvalidPassword("Current password is incorrect".into()).into_response(); } let new_password_clone = new_password.to_string(); let new_hash = @@ -425,19 +311,11 @@ pub async fn change_password( Ok(Ok(h)) => h, Ok(Err(e)) => { error!("Failed to hash password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } Err(e) => { error!("Failed to spawn blocking task: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if let Err(e) = sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2") @@ -447,40 +325,26 @@ pub async fn change_password( .await { error!("DB error updating password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } - info!(did = %auth.0.did, "Password changed successfully"); - (StatusCode::OK, Json(json!({}))).into_response() + info!(did = %&auth.0.did, "Password changed successfully"); + EmptyResponse::ok().into_response() } pub async fn get_password_status(State(state): State, auth: BearerAuth) -> Response { let user = sqlx::query!( "SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1", - auth.0.did + &auth.0.did ) .fetch_optional(&state.db) .await; match user { - Ok(Some(row)) => { - Json(json!({"hasPassword": row.has_password.unwrap_or(false)})).into_response() - } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(), + Ok(Some(row)) => HasPasswordResponse::new(row.has_password.unwrap_or(false)).into_response(), + Ok(None) => ApiError::AccountNotFound.into_response(), Err(e) => { error!("DB error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -504,19 +368,15 @@ pub async fn remove_password(State(state): State, auth: BearerAuth) -> let has_passkeys = crate::api::server::passkeys::has_passkeys_for_user_db(&state.db, &auth.0.did).await; if !has_passkeys { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "NoPasskeys", - "message": "You must have at least one passkey registered before removing your password" - })), + return ApiError::InvalidRequest( + "You must have at least one passkey registered before removing your password".into(), ) - .into_response(); + .into_response(); } let user = sqlx::query!( "SELECT id, password_hash FROM users WHERE did = $1", - auth.0.did + &auth.0.did ) .fetch_optional(&state.db) .await; @@ -524,31 +384,16 @@ pub async fn remove_password(State(state): State, auth: BearerAuth) -> let user = match user { Ok(Some(u)) => u, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if user.password_hash.is_none() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "NoPassword", - "message": "Account already has no password" - })), - ) - .into_response(); + return ApiError::InvalidRequest("Account already has no password".into()).into_response(); } if let Err(e) = sqlx::query!( @@ -559,13 +404,9 @@ pub async fn remove_password(State(state): State, auth: BearerAuth) -> .await { error!("DB error removing password: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } - info!(did = %auth.0.did, "Password removed - account is now passkey-only"); - (StatusCode::OK, Json(json!({"success": true}))).into_response() + info!(did = %&auth.0.did, "Password removed - account is now passkey-only"); + SuccessResponse::ok().into_response() } diff --git a/src/api/server/reauth.rs b/src/api/server/reauth.rs index cba46c1..f358864 100644 --- a/src/api/server/reauth.rs +++ b/src/api/server/reauth.rs @@ -1,3 +1,4 @@ +use crate::api::error::ApiError; use axum::{ Json, extract::State, @@ -6,12 +7,12 @@ use axum::{ }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use serde_json::json; use sqlx::PgPool; use tracing::{error, info, warn}; use crate::auth::BearerAuth; use crate::state::{AppState, RateLimitKind}; +use crate::types::PlainPassword; const REAUTH_WINDOW_SECONDS: i64 = 300; @@ -26,7 +27,7 @@ pub struct ReauthStatusResponse { pub async fn get_reauth_status(State(state): State, auth: BearerAuth) -> Response { let session = sqlx::query!( "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1", - auth.0.did + &auth.0.did ) .fetch_optional(&state.db) .await; @@ -36,11 +37,7 @@ pub async fn get_reauth_status(State(state): State, auth: BearerAuth) Ok(None) => None, Err(e) => { error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -58,7 +55,7 @@ pub async fn get_reauth_status(State(state): State, auth: BearerAuth) #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct PasswordReauthInput { - pub password: String, + pub password: PlainPassword, } #[derive(Serialize)] @@ -72,26 +69,18 @@ pub async fn reauth_password( auth: BearerAuth, Json(input): Json, ) -> Response { - let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) + let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did) .fetch_optional(&state.db) .await; let password_hash = match user { Ok(Some(row)) => row.password_hash, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(); + return ApiError::AccountNotFound.into_response(); } Err(e) => { error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -105,7 +94,7 @@ pub async fn reauth_password( "SELECT ap.password_hash FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1", - auth.0.did + &auth.0.did ) .fetch_all(&state.db) .await @@ -116,30 +105,19 @@ pub async fn reauth_password( .any(|ap| bcrypt::verify(&input.password, &ap.password_hash).unwrap_or(false)); if !app_password_valid { - warn!(did = %auth.0.did, "Re-auth failed: invalid password"); - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "InvalidPassword", - "message": "Password is incorrect" - })), - ) - .into_response(); + warn!(did = %&auth.0.did, "Re-auth failed: invalid password"); + return ApiError::InvalidPassword("Password is incorrect".into()).into_response(); } } match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await { Ok(reauthed_at) => { - info!(did = %auth.0.did, "Re-auth successful via password"); + info!(did = %&auth.0.did, "Re-auth successful via password"); Json(ReauthResponse { reauthed_at }).into_response() } Err(e) => { error!("DB error updating reauth: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -159,15 +137,9 @@ pub async fn reauth_totp( .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) .await { - warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many verification attempts. Please try again in a few minutes." - })), - ) - .into_response(); + warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded"); + return ApiError::RateLimitExceeded(Some("Too many verification attempts. Please try again in a few minutes.".into(),)) + .into_response(); } let valid = @@ -175,29 +147,18 @@ pub async fn reauth_totp( .await; if !valid { - warn!(did = %auth.0.did, "Re-auth failed: invalid TOTP code"); - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "InvalidCode", - "message": "Invalid TOTP or backup code" - })), - ) - .into_response(); + warn!(did = %&auth.0.did, "Re-auth failed: invalid TOTP code"); + return ApiError::InvalidCode(Some("Invalid TOTP or backup code".into())).into_response(); } match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await { Ok(reauthed_at) => { - info!(did = %auth.0.did, "Re-auth successful via TOTP"); + info!(did = %&auth.0.did, "Re-auth successful via TOTP"); Json(ReauthResponse { reauthed_at }).into_response() } Err(e) => { error!("DB error updating reauth: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -216,23 +177,12 @@ pub async fn reauth_passkey_start(State(state): State, auth: BearerAut Ok(pks) => pks, Err(e) => { error!("Failed to get passkeys: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if stored_passkeys.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "NoPasskeys", - "message": "No passkeys registered for this account" - })), - ) - .into_response(); + return ApiError::NoPasskeys.into_response(); } let passkeys: Vec = stored_passkeys @@ -241,22 +191,14 @@ pub async fn reauth_passkey_start(State(state): State, auth: BearerAut .collect(); if passkeys.is_empty() { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to load passkeys"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to load passkeys".into())).into_response(); } let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) { Ok(w) => w, Err(e) => { error!("Failed to create WebAuthn config: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -264,11 +206,7 @@ pub async fn reauth_passkey_start(State(state): State, auth: BearerAut Ok(result) => result, Err(e) => { error!("Failed to start passkey authentication: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -276,14 +214,10 @@ pub async fn reauth_passkey_start(State(state): State, auth: BearerAut crate::auth::webauthn::save_authentication_state(&state.db, &auth.0.did, &auth_state).await { error!("Failed to save authentication state: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } - let options = serde_json::to_value(&rcr).unwrap_or(json!({})); + let options = serde_json::to_value(&rcr).unwrap_or(serde_json::json!({})); Json(PasskeyReauthStartResponse { options }).into_response() } @@ -304,22 +238,11 @@ pub async fn reauth_passkey_finish( match crate::auth::webauthn::load_authentication_state(&state.db, &auth.0.did).await { Ok(Some(s)) => s, Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "NoChallengeInProgress", - "message": "No passkey authentication in progress or challenge expired" - })), - ) - .into_response(); + return ApiError::NoChallengeInProgress.into_response(); } Err(e) => { error!("Failed to load authentication state: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -328,14 +251,7 @@ pub async fn reauth_passkey_finish( Ok(c) => c, Err(e) => { warn!("Failed to parse credential: {:?}", e); - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidCredential", - "message": "Failed to parse credential response" - })), - ) - .into_response(); + return ApiError::InvalidCredential.into_response(); } }; @@ -343,25 +259,15 @@ pub async fn reauth_passkey_finish( Ok(w) => w, Err(e) => { error!("Failed to create WebAuthn config: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let auth_result = match webauthn.finish_authentication(&credential, &auth_state) { Ok(r) => r, Err(e) => { - warn!(did = %auth.0.did, "Passkey re-auth failed: {:?}", e); - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "AuthenticationFailed", - "message": "Passkey authentication failed" - })), - ) + warn!(did = %&auth.0.did, "Passkey re-auth failed: {:?}", e); + return ApiError::AuthenticationFailed(Some("Passkey authentication failed".into())) .into_response(); } }; @@ -375,17 +281,10 @@ pub async fn reauth_passkey_finish( .await { Ok(false) => { - warn!(did = %auth.0.did, "Passkey counter anomaly detected - possible cloned key"); + warn!(did = %&auth.0.did, "Passkey counter anomaly detected - possible cloned key"); let _ = crate::auth::webauthn::delete_authentication_state(&state.db, &auth.0.did).await; - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "PasskeyCounterAnomaly", - "message": "Authentication failed: security key counter anomaly detected. This may indicate a cloned key." - })), - ) - .into_response(); + return ApiError::PasskeyCounterAnomaly.into_response(); } Err(e) => { error!("Failed to update passkey counter: {:?}", e); @@ -397,16 +296,12 @@ pub async fn reauth_passkey_finish( match update_last_reauth_cached(&state.db, &state.cache, &auth.0.did).await { Ok(reauthed_at) => { - info!(did = %auth.0.did, "Re-auth successful via passkey"); + info!(did = %&auth.0.did, "Re-auth successful via passkey"); Json(ReauthResponse { reauthed_at }).into_response() } Err(e) => { error!("DB error updating reauth: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -582,11 +477,19 @@ pub async fn legacy_mfa_required_response(db: &PgPool, did: &str) -> Response { let methods = get_available_reauth_methods(db, did).await; ( StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "MfaVerificationRequired", - "message": "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.", - "reauthMethods": methods - })), + Json(MfaVerificationRequiredError { + error: "MfaVerificationRequired".to_string(), + message: "This sensitive operation requires MFA verification. Your session was created via a legacy app that doesn't support MFA during login.".to_string(), + reauth_methods: methods, + }), ) .into_response() } + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MfaVerificationRequiredError { + pub error: String, + pub message: String, + pub reauth_methods: Vec, +} diff --git a/src/api/server/service_auth.rs b/src/api/server/service_auth.rs index 28fdd9f..0418d27 100644 --- a/src/api/server/service_auth.rs +++ b/src/api/server/service_auth.rs @@ -1,4 +1,6 @@ -use crate::api::ApiError; +use crate::types::Did; +use crate::AccountStatus; +use crate::api::error::ApiError; use crate::state::AppState; use axum::{ Json, @@ -92,10 +94,10 @@ pub async fn get_service_auth( .await { Ok(result) => crate::auth::AuthenticatedUser { - did: result.did, + did: Did::new_unchecked(result.did), is_oauth: true, is_admin: false, - is_takendown: false, + status: AccountStatus::Active, scope: result.scope, key_bytes: None, controller_did: None, @@ -113,14 +115,7 @@ pub async fn get_service_auth( } Err(e) => { warn!(error = ?e, "getServiceAuth DPoP auth validation failed"); - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "AuthenticationFailed", - "message": format!("{:?}", e) - })), - ) - .into_response(); + return ApiError::AuthenticationFailed(Some(format!("{:?}", e))).into_response(); } } } else { @@ -133,7 +128,7 @@ pub async fn get_service_auth( } }; info!( - did = %auth_user.did, + did = %&auth_user.did, is_oauth = auth_user.is_oauth, has_key = auth_user.key_bytes.is_some(), "getServiceAuth auth validated" @@ -141,7 +136,7 @@ pub async fn get_service_auth( let key_bytes = match &auth_user.key_bytes { Some(kb) => kb.clone(), None => { - warn!(did = %auth_user.did, "getServiceAuth: OAuth token has no key_bytes, fetching from DB"); + warn!(did = %&auth_user.did, "getServiceAuth: OAuth token has no key_bytes, fetching from DB"); match sqlx::query_as::<_, (Vec, Option)>( "SELECT k.key_bytes, k.encryption_version FROM users u @@ -157,20 +152,20 @@ pub async fn get_service_auth( Ok(key) => key, Err(e) => { error!(error = ?e, "Failed to decrypt user key for service auth"); - return ApiError::AuthenticationFailedMsg( + return ApiError::AuthenticationFailed(Some( "Failed to get signing key".into(), - ) + )) .into_response(); } } } Ok(None) => { - return ApiError::AuthenticationFailedMsg("User has no signing key".into()) + return ApiError::AuthenticationFailed(Some("User has no signing key".into())) .into_response(); } Err(e) => { error!(error = ?e, "DB error fetching user key"); - return ApiError::AuthenticationFailedMsg("Failed to get signing key".into()) + return ApiError::AuthenticationFailed(Some("Failed to get signing key".into())) .into_response(); } } @@ -192,20 +187,16 @@ pub async fn get_service_auth( } else if auth_user.is_oauth { let permissions = auth_user.permissions(); if !permissions.has_full_access() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": "OAuth tokens with granular scopes must specify an lxm parameter" - })), + return ApiError::InvalidRequest( + "OAuth tokens with granular scopes must specify an lxm parameter".into(), ) - .into_response(); + .into_response(); } } let user_status = sqlx::query!( "SELECT takedown_ref FROM users WHERE did = $1", - auth_user.did + &auth_user.did ) .fetch_optional(&state.db) .await; @@ -216,27 +207,17 @@ pub async fn get_service_auth( }; if is_takendown && lxm != Some("com.atproto.server.createAccount") { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidToken", - "message": "Bad token scope" - })), - ) - .into_response(); + return ApiError::InvalidToken(Some("Bad token scope".into())).into_response(); } if let Some(method) = lxm && PROTECTED_METHODS.contains(&method) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("cannot request a service auth token for the following protected method: {}", method) - })), - ) - .into_response(); + return ApiError::InvalidRequest(format!( + "cannot request a service auth token for the following protected method: {}", + method + )) + .into_response(); } if let Some(exp) = params.exp { @@ -244,36 +225,21 @@ pub async fn get_service_auth( let diff = exp - now; if diff < 0 { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "BadExpiration", - "message": "expiration is in past" - })), - ) - .into_response(); + return ApiError::InvalidRequest("expiration is in past".into()).into_response(); } if diff > HOUR_SECS { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "BadExpiration", - "message": "cannot request a token with an expiration more than an hour in the future" - })), + return ApiError::InvalidRequest( + "cannot request a token with an expiration more than an hour in the future".into(), ) - .into_response(); + .into_response(); } if lxm.is_none() && diff > MINUTE_SECS { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "BadExpiration", - "message": "cannot request a method-less token with an expiration more than a minute in the future" - })), + return ApiError::InvalidRequest( + "cannot request a method-less token with an expiration more than a minute in the future".into(), ) - .into_response(); + .into_response(); } } @@ -286,11 +252,7 @@ pub async fn get_service_auth( Ok(t) => t, Err(e) => { error!("Failed to create service token: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; ( diff --git a/src/api/server/session.rs b/src/api/server/session.rs index bf37858..003afa6 100644 --- a/src/api/server/session.rs +++ b/src/api/server/session.rs @@ -1,6 +1,8 @@ -use crate::api::ApiError; +use crate::api::error::ApiError; +use crate::api::{EmptyResponse, SuccessResponse}; use crate::auth::{BearerAuth, BearerAuthAllowDeactivated}; use crate::state::{AppState, RateLimitKind}; +use crate::types::{AccountState, Did, Handle, PlainPassword}; use axum::{ Json, extract::State, @@ -46,7 +48,7 @@ fn full_handle(stored_handle: &str, _pds_hostname: &str) -> String { #[serde(rename_all = "camelCase")] pub struct CreateSessionInput { pub identifier: String, - pub password: String, + pub password: PlainPassword, #[serde(default)] pub allow_takendown: bool, } @@ -56,8 +58,8 @@ pub struct CreateSessionInput { pub struct CreateSessionOutput { pub access_jwt: String, pub refresh_jwt: String, - pub handle: String, - pub did: String, + pub handle: Handle, + pub did: Did, #[serde(skip_serializing_if = "Option::is_none")] pub did_doc: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -85,14 +87,7 @@ pub async fn create_session( .await { warn!(ip = %client_ip, "Login rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many login attempts. Please try again later." - })), - ) - .into_response(); + return ApiError::RateLimitExceeded(None).into_response(); } let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let normalized_identifier = normalize_handle(&input.identifier, &pds_hostname); @@ -123,19 +118,19 @@ pub async fn create_session( "$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.VTtYw1ZzQKZqmK", ); warn!("User not found for login attempt"); - return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()) + return ApiError::AuthenticationFailed(Some("Invalid identifier or password".into())) .into_response(); } Err(e) => { error!("Database error fetching user: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; let key_bytes = match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) { Ok(k) => k, Err(e) => { error!("Failed to decrypt user key: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; let (password_valid, app_password_name, app_password_scopes, app_password_controller) = if row @@ -168,20 +163,18 @@ pub async fn create_session( }; if !password_valid { warn!("Password verification failed for login attempt"); - return ApiError::AuthenticationFailedMsg("Invalid identifier or password".into()) + return ApiError::AuthenticationFailed(Some("Invalid identifier or password".into())) .into_response(); } - let is_takendown = row.takedown_ref.is_some(); - if is_takendown && !input.allow_takendown { + let account_state = AccountState::from_db_fields( + row.deactivated_at, + row.takedown_ref.clone(), + row.migrated_to_pds.clone(), + None, + ); + if account_state.is_takendown() && !input.allow_takendown { warn!("Login attempt for takendown account: {}", row.did); - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "AccountTakedown", - "message": "Account has been taken down" - })), - ) - .into_response(); + return ApiError::AccountTakedown.into_response(); } let is_verified = row.email_verified || row.discord_verified || row.telegram_verified || row.signal_verified; @@ -223,14 +216,14 @@ pub async fn create_session( Ok(m) => m, Err(e) => { error!("Failed to create access token: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) { Ok(m) => m, Err(e) => { error!("Failed to create refresh token: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; let did_for_doc = row.did.clone(); @@ -254,7 +247,7 @@ pub async fn create_session( ); if let Err(e) = insert_result { error!("Failed to insert session: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } if is_legacy_login { warn!( @@ -276,22 +269,13 @@ pub async fn create_session( } } let handle = full_handle(&row.handle, &pds_hostname); - let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some(); - let is_active = row.deactivated_at.is_none() && !is_takendown; - let status = if is_takendown { - Some("takendown".to_string()) - } else if is_migrated { - Some("migrated".to_string()) - } else if row.deactivated_at.is_some() { - Some("deactivated".to_string()) - } else { - None - }; + let is_active = account_state.is_active(); + let status = account_state.status_for_session().map(String::from); Json(CreateSessionOutput { access_jwt: access_meta.token, refresh_jwt: refresh_meta.token, - handle, - did: row.did, + handle: handle.into(), + did: row.did.into(), did_doc, email: row.email, email_confirmed: Some(row.email_verified), @@ -317,7 +301,7 @@ pub async fn get_session( preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel", discord_verified, telegram_verified, signal_verified, migrated_to_pds, migrated_at FROM users WHERE did = $1"#, - auth_user.did + &auth_user.did ) .fetch_optional(&state.db), did_resolver.resolve_did_document(&did_for_doc) @@ -333,9 +317,12 @@ pub async fn get_session( let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let handle = full_handle(&row.handle, &pds_hostname); - let is_takendown = row.takedown_ref.is_some(); - let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some(); - let is_active = row.deactivated_at.is_none() && !is_takendown; + let account_state = AccountState::from_db_fields( + row.deactivated_at, + row.takedown_ref.clone(), + row.migrated_to_pds.clone(), + row.migrated_at, + ); let email_value = if can_read_email { row.email.clone() } else { @@ -344,8 +331,8 @@ pub async fn get_session( let email_confirmed_value = can_read_email && row.email_verified; let mut response = json!({ "handle": handle, - "did": auth_user.did, - "active": is_active, + "did": &auth_user.did, + "active": account_state.is_active(), "preferredChannel": preferred_channel, "preferredChannelVerified": preferred_channel_verified, "preferredLocale": row.preferred_locale, @@ -355,24 +342,22 @@ pub async fn get_session( response["email"] = json!(email_value); response["emailConfirmed"] = json!(email_confirmed_value); } - if is_takendown { - response["status"] = json!("takendown"); - } else if is_migrated { - response["status"] = json!("migrated"); - response["migratedToPds"] = json!(row.migrated_to_pds); - response["migratedAt"] = json!(row.migrated_at); - } else if row.deactivated_at.is_some() { - response["status"] = json!("deactivated"); + if let Some(status) = account_state.status_for_session() { + response["status"] = json!(status); + } + if let AccountState::Migrated { to_pds, at } = &account_state { + response["migratedToPds"] = json!(to_pds); + response["migratedAt"] = json!(at); } if let Some(doc) = did_doc { response["didDoc"] = doc; } Json(response).into_response() } - Ok(None) => ApiError::AuthenticationFailed.into_response(), + Ok(None) => ApiError::AuthenticationFailed(None).into_response(), Err(e) => { error!("Database error in get_session: {:?}", e); - ApiError::InternalError.into_response() + ApiError::InternalError(None).into_response() } } } @@ -389,7 +374,7 @@ pub async fn delete_session( }; let jti = match crate::auth::get_jti_from_token(&token) { Ok(jti) => jti, - Err(_) => return ApiError::AuthenticationFailed.into_response(), + Err(_) => return ApiError::AuthenticationFailed(None).into_response(), }; let did = crate::auth::get_did_from_token(&token).ok(); match sqlx::query!("DELETE FROM session_tokens WHERE access_jti = $1", jti) @@ -401,12 +386,12 @@ pub async fn delete_session( let session_cache_key = format!("auth:session:{}:{}", did, jti); let _ = state.cache.delete(&session_cache_key).await; } - Json(json!({})).into_response() + EmptyResponse::ok().into_response() } - Ok(_) => ApiError::AuthenticationFailed.into_response(), + Ok(_) => ApiError::AuthenticationFailed(None).into_response(), Err(e) => { error!("Database error in delete_session: {:?}", e); - ApiError::AuthenticationFailed.into_response() + ApiError::AuthenticationFailed(None).into_response() } } } @@ -421,14 +406,7 @@ pub async fn refresh_session( .await { tracing::warn!(ip = %client_ip, "Refresh session rate limit exceeded"); - return ( - axum::http::StatusCode::TOO_MANY_REQUESTS, - axum::Json(serde_json::json!({ - "error": "RateLimitExceeded", - "message": "Too many requests. Please try again later." - })), - ) - .into_response(); + return ApiError::RateLimitExceeded(None).into_response(); } let refresh_token = match crate::auth::extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), @@ -439,7 +417,7 @@ pub async fn refresh_session( let refresh_jti = match crate::auth::get_jti_from_token(&refresh_token) { Ok(jti) => jti, Err(_) => { - return ApiError::AuthenticationFailedMsg("Invalid token format".into()) + return ApiError::AuthenticationFailed(Some("Invalid token format".into())) .into_response(); } }; @@ -447,7 +425,7 @@ pub async fn refresh_session( Ok(tx) => tx, Err(e) => { error!("Failed to begin transaction: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; if let Ok(Some(session_id)) = sqlx::query_scalar!( @@ -465,9 +443,9 @@ pub async fn refresh_session( .execute(&mut *tx) .await; let _ = tx.commit().await; - return ApiError::ExpiredTokenMsg( + return ApiError::AuthenticationFailed(Some( "Refresh token has been revoked due to suspected compromise".into(), - ) + )) .into_response(); } let session_row = match sqlx::query!( @@ -484,12 +462,12 @@ pub async fn refresh_session( { Ok(Some(row)) => row, Ok(None) => { - return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()) + return ApiError::AuthenticationFailed(Some("Invalid refresh token".into())) .into_response(); } Err(e) => { error!("Database error fetching session: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; let key_bytes = @@ -497,11 +475,11 @@ pub async fn refresh_session( Ok(k) => k, Err(e) => { error!("Failed to decrypt user key: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; if crate::auth::verify_refresh_token(&refresh_token, &key_bytes).is_err() { - return ApiError::AuthenticationFailedMsg("Invalid refresh token".into()).into_response(); + return ApiError::AuthenticationFailed(Some("Invalid refresh token".into())).into_response(); } let new_access_meta = match crate::auth::create_access_token_with_delegation( &session_row.did, @@ -512,7 +490,7 @@ pub async fn refresh_session( Ok(m) => m, Err(e) => { error!("Failed to create access token: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; let new_refresh_meta = @@ -520,7 +498,7 @@ pub async fn refresh_session( Ok(m) => m, Err(e) => { error!("Failed to create refresh token: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; match sqlx::query!( @@ -537,11 +515,11 @@ pub async fn refresh_session( .execute(&mut *tx) .await; let _ = tx.commit().await; - return ApiError::ExpiredTokenMsg("Refresh token has been revoked due to suspected compromise".into()).into_response(); + return ApiError::AuthenticationFailed(Some("Refresh token has been revoked due to suspected compromise".into())).into_response(); } Err(e) => { error!("Failed to record used refresh token: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } Ok(_) => {} } @@ -557,11 +535,11 @@ pub async fn refresh_session( .await { error!("Database error updating session: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } if let Err(e) = tx.commit().await { error!("Failed to commit transaction: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } let did_for_doc = session_row.did.clone(); let did_resolver = state.did_resolver.clone(); @@ -588,8 +566,12 @@ pub async fn refresh_session( let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); let handle = full_handle(&u.handle, &pds_hostname); - let is_takendown = u.takedown_ref.is_some(); - let is_active = u.deactivated_at.is_none() && !is_takendown; + let account_state = AccountState::from_db_fields( + u.deactivated_at, + u.takedown_ref.clone(), + None, + None, + ); let mut response = json!({ "accessJwt": new_access_meta.token, "refreshJwt": new_refresh_meta.token, @@ -601,25 +583,23 @@ pub async fn refresh_session( "preferredChannelVerified": preferred_channel_verified, "preferredLocale": u.preferred_locale, "isAdmin": u.is_admin, - "active": is_active + "active": account_state.is_active() }); if let Some(doc) = did_doc { response["didDoc"] = doc; } - if is_takendown { - response["status"] = json!("takendown"); - } else if u.deactivated_at.is_some() { - response["status"] = json!("deactivated"); + if let Some(status) = account_state.status_for_session() { + response["status"] = json!(status); } Json(response).into_response() } Ok(None) => { error!("User not found for existing session: {}", session_row.did); - ApiError::InternalError.into_response() + ApiError::InternalError(None).into_response() } Err(e) => { error!("Database error fetching user: {:?}", e); - ApiError::InternalError.into_response() + ApiError::InternalError(None).into_response() } } } @@ -627,7 +607,7 @@ pub async fn refresh_session( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ConfirmSignupInput { - pub did: String, + pub did: Did, pub verification_code: String, } @@ -636,8 +616,8 @@ pub struct ConfirmSignupInput { pub struct ConfirmSignupOutput { pub access_jwt: String, pub refresh_jwt: String, - pub handle: String, - pub did: String, + pub handle: Handle, + pub did: Did, pub email: Option, pub email_verified: bool, pub preferred_channel: String, @@ -658,7 +638,7 @@ pub async fn confirm_signup( FROM users u JOIN user_keys k ON u.id = k.user_id WHERE u.did = $1"#, - input.did + input.did.as_str() ) .fetch_optional(&state.db) .await @@ -671,7 +651,7 @@ pub async fn confirm_signup( } Err(e) => { error!("Database error in confirm_signup: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -697,7 +677,7 @@ pub async fn confirm_signup( &identifier, ) { Ok(token_data) => { - if token_data.did != input.did { + if token_data.did != input.did.as_str() { warn!( "Token DID mismatch for confirm_signup: expected {}, got {}", input.did, token_data.did @@ -708,7 +688,7 @@ pub async fn confirm_signup( } Err(crate::auth::verification_token::VerifyError::Expired) => { warn!("Verification code expired for user: {}", input.did); - return ApiError::ExpiredTokenMsg("Verification code has expired".into()) + return ApiError::ExpiredToken(Some("Verification code has expired".into())) .into_response(); } Err(e) => { @@ -721,7 +701,7 @@ pub async fn confirm_signup( Ok(k) => k, Err(e) => { error!("Failed to decrypt user key: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; let verified_column = match row.channel { @@ -732,26 +712,26 @@ pub async fn confirm_signup( }; let update_query = format!("UPDATE users SET {} = TRUE WHERE did = $1", verified_column); if let Err(e) = sqlx::query(&update_query) - .bind(&input.did) + .bind(input.did.as_str()) .execute(&state.db) .await { error!("Failed to update verification status: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { Ok(m) => m, Err(e) => { error!("Failed to create access token: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; let refresh_meta = match crate::auth::create_refresh_token_with_metadata(&row.did, &key_bytes) { Ok(m) => m, Err(e) => { error!("Failed to create refresh token: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; let no_scope: Option = None; @@ -770,7 +750,7 @@ pub async fn confirm_signup( .await { error!("Failed to insert session: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()); if let Err(e) = crate::comms::enqueue_welcome(&state.db, row.id, &hostname).await { @@ -786,8 +766,8 @@ pub async fn confirm_signup( Json(ConfirmSignupOutput { access_jwt: access_meta.token, refresh_jwt: refresh_meta.token, - handle: row.handle, - did: row.did, + handle: row.handle.into(), + did: row.did.into(), email: row.email, email_verified, preferred_channel: preferred_channel.to_string(), @@ -799,7 +779,7 @@ pub async fn confirm_signup( #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResendVerificationInput { - pub did: String, + pub did: Did, } pub async fn resend_verification( @@ -815,7 +795,7 @@ pub async fn resend_verification( email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1"#, - input.did + input.did.as_str() ) .fetch_optional(&state.db) .await @@ -826,7 +806,7 @@ pub async fn resend_verification( } Err(e) => { error!("Database error in resend_verification: {:?}", e); - return ApiError::InternalError.into_response(); + return ApiError::InternalError(None).into_response(); } }; let is_verified = @@ -866,7 +846,7 @@ pub async fn resend_verification( { warn!("Failed to enqueue verification notification: {:?}", e); } - Json(json!({"success": true})).into_response() + SuccessResponse::ok().into_response() } #[derive(Serialize)] @@ -934,11 +914,7 @@ pub async fn list_sessions( } Err(e) => { error!("DB error fetching JWT sessions: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } @@ -980,11 +956,7 @@ pub async fn list_sessions( } Err(e) => { error!("DB error fetching OAuth sessions: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } @@ -1015,15 +987,8 @@ pub async fn revoke_session( Json(input): Json, ) -> Response { if let Some(jwt_id) = input.session_id.strip_prefix("jwt:") { - let session_id: i32 = match jwt_id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), - ) - .into_response(); - } + let Ok(session_id) = jwt_id.parse::() else { + return ApiError::InvalidRequest("Invalid session ID".into()).into_response(); }; let session = sqlx::query_as::<_, (String,)>( "SELECT access_jti FROM session_tokens WHERE id = $1 AND did = $2", @@ -1035,19 +1000,11 @@ pub async fn revoke_session( let access_jti = match session { Ok(Some((jti,))) => jti, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "SessionNotFound", "message": "Session not found"})), - ) - .into_response(); + return ApiError::SessionNotFound.into_response(); } Err(e) => { error!("DB error in revoke_session: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE id = $1") @@ -1056,27 +1013,16 @@ pub async fn revoke_session( .await { error!("DB error deleting session: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } - let cache_key = format!("auth:session:{}:{}", auth.0.did, access_jti); + let cache_key = format!("auth:session:{}:{}", &auth.0.did, access_jti); if let Err(e) = state.cache.delete(&cache_key).await { warn!("Failed to invalidate session cache: {:?}", e); } - info!(did = %auth.0.did, session_id = %session_id, "JWT session revoked"); + info!(did = %&auth.0.did, session_id = %session_id, "JWT session revoked"); } else if let Some(oauth_id) = input.session_id.strip_prefix("oauth:") { - let session_id: i32 = match oauth_id.parse() { - Ok(id) => id, - Err(_) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Invalid session ID"})), - ) - .into_response(); - } + let Ok(session_id) = oauth_id.parse::() else { + return ApiError::InvalidRequest("Invalid session ID".into()).into_response(); }; let result = sqlx::query("DELETE FROM oauth_token WHERE id = $1 AND did = $2") .bind(session_id) @@ -1085,31 +1031,19 @@ pub async fn revoke_session( .await; match result { Ok(r) if r.rows_affected() == 0 => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "SessionNotFound", "message": "Session not found"})), - ) - .into_response(); + return ApiError::SessionNotFound.into_response(); } Err(e) => { error!("DB error deleting OAuth session: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } _ => {} } - info!(did = %auth.0.did, session_id = %session_id, "OAuth session revoked"); + info!(did = %&auth.0.did, session_id = %session_id, "OAuth session revoked"); } else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Invalid session ID format"})), - ) - .into_response(); + return ApiError::InvalidRequest("Invalid session ID format".into()).into_response(); } - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } pub async fn revoke_all_sessions( @@ -1123,71 +1057,51 @@ pub async fn revoke_all_sessions( .and_then(|v| v.strip_prefix("Bearer ")) .and_then(|token| crate::auth::get_jti_from_token(token).ok()); - if let Some(ref jti) = current_jti { - if auth.0.is_oauth { - if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1") - .bind(&auth.0.did) - .execute(&state.db) - .await - { - error!("DB error revoking JWT sessions: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2") + let Some(ref jti) = current_jti else { + return ApiError::InvalidToken(None).into_response(); + }; + + if auth.0.is_oauth { + if let Err(e) = sqlx::query("DELETE FROM session_tokens WHERE did = $1") + .bind(&auth.0.did) + .execute(&state.db) + .await + { + error!("DB error revoking JWT sessions: {:?}", e); + return ApiError::InternalError(None).into_response(); + } + if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1 AND token_id != $2") + .bind(&auth.0.did) + .bind(jti) + .execute(&state.db) + .await + { + error!("DB error revoking OAuth sessions: {:?}", e); + return ApiError::InternalError(None).into_response(); + } + } else { + if let Err(e) = + sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2") .bind(&auth.0.did) .bind(jti) .execute(&state.db) .await - { - error!("DB error revoking OAuth sessions: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - } else { - if let Err(e) = - sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2") - .bind(&auth.0.did) - .bind(jti) - .execute(&state.db) - .await - { - error!("DB error revoking JWT sessions: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } - if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1") - .bind(&auth.0.did) - .execute(&state.db) - .await - { - error!("DB error revoking OAuth sessions: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); - } + { + error!("DB error revoking JWT sessions: {:?}", e); + return ApiError::InternalError(None).into_response(); + } + if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1") + .bind(&auth.0.did) + .execute(&state.db) + .await + { + error!("DB error revoking OAuth sessions: {:?}", e); + return ApiError::InternalError(None).into_response(); } - } else { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidToken", "message": "Could not identify current session"})), - ) - .into_response(); } - info!(did = %auth.0.did, "All other sessions revoked"); - (StatusCode::OK, Json(json!({"success": true}))).into_response() + info!(did = %&auth.0.did, "All other sessions revoked"); + SuccessResponse::ok().into_response() } #[derive(Serialize)] @@ -1207,7 +1121,7 @@ pub async fn get_legacy_login_preference( (EXISTS(SELECT 1 FROM user_totp t WHERE t.did = u.did AND t.verified = TRUE) OR EXISTS(SELECT 1 FROM passkeys p WHERE p.did = u.did)) as "has_mfa!" FROM users u WHERE u.did = $1"#, - auth.0.did + &auth.0.did ) .fetch_optional(&state.db) .await; @@ -1218,18 +1132,10 @@ pub async fn get_legacy_login_preference( has_mfa: row.has_mfa, }) .into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(), + Ok(None) => ApiError::AccountNotFound.into_response(), Err(e) => { error!("DB error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -1257,7 +1163,7 @@ pub async fn update_legacy_login_preference( let result = sqlx::query!( "UPDATE users SET allow_legacy_login = $1 WHERE did = $2 RETURNING did", input.allow_legacy_login, - auth.0.did + &auth.0.did ) .fetch_optional(&state.db) .await; @@ -1265,7 +1171,7 @@ pub async fn update_legacy_login_preference( match result { Ok(Some(_)) => { info!( - did = %auth.0.did, + did = %&auth.0.did, allow_legacy_login = input.allow_legacy_login, "Legacy login preference updated" ); @@ -1274,18 +1180,10 @@ pub async fn update_legacy_login_preference( })) .into_response() } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(), + Ok(None) => ApiError::AccountNotFound.into_response(), Err(e) => { error!("DB error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -1304,20 +1202,17 @@ pub async fn update_locale( Json(input): Json, ) -> Response { if !VALID_LOCALES.contains(&input.preferred_locale.as_str()) { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Invalid locale. Valid options: {}", VALID_LOCALES.join(", ")) - })), - ) - .into_response(); + return ApiError::InvalidRequest(format!( + "Invalid locale. Valid options: {}", + VALID_LOCALES.join(", ") + )) + .into_response(); } let result = sqlx::query!( "UPDATE users SET preferred_locale = $1 WHERE did = $2 RETURNING did", input.preferred_locale, - auth.0.did + &auth.0.did ) .fetch_optional(&state.db) .await; @@ -1325,7 +1220,7 @@ pub async fn update_locale( match result { Ok(Some(_)) => { info!( - did = %auth.0.did, + did = %&auth.0.did, locale = %input.preferred_locale, "User locale preference updated" ); @@ -1334,18 +1229,10 @@ pub async fn update_locale( })) .into_response() } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound"})), - ) - .into_response(), + Ok(None) => ApiError::AccountNotFound.into_response(), Err(e) => { error!("DB error updating locale: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } diff --git a/src/api/server/signing_key.rs b/src/api/server/signing_key.rs index c40acbf..fc2eac2 100644 --- a/src/api/server/signing_key.rs +++ b/src/api/server/signing_key.rs @@ -1,3 +1,4 @@ +use crate::api::error::ApiError; use crate::state::AppState; use axum::{ Json, @@ -8,7 +9,6 @@ use axum::{ use chrono::{Duration, Utc}; use k256::ecdsa::SigningKey; use serde::{Deserialize, Serialize}; -use serde_json::json; use tracing::{error, info}; const SECP256K1_MULTICODEC_PREFIX: [u8; 2] = [0xe7, 0x01]; @@ -69,11 +69,7 @@ pub async fn reserve_signing_key( } Err(e) => { error!("DB error in reserve_signing_key: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } diff --git a/src/api/server/totp.rs b/src/api/server/totp.rs index b127a57..cbc9b90 100644 --- a/src/api/server/totp.rs +++ b/src/api/server/totp.rs @@ -1,3 +1,5 @@ +use crate::api::EmptyResponse; +use crate::api::error::ApiError; use crate::auth::BearerAuth; use crate::auth::totp::{ decrypt_totp_secret, encrypt_totp_secret, generate_backup_codes, generate_qr_png_base64, @@ -5,15 +7,14 @@ use crate::auth::totp::{ verify_backup_code, verify_totp_code, }; use crate::state::{AppState, RateLimitKind}; +use crate::types::PlainPassword; use axum::{ Json, extract::State, - http::StatusCode, response::{IntoResponse, Response}, }; use chrono::Utc; use serde::{Deserialize, Serialize}; -use serde_json::json; use tracing::{error, info, warn}; const ENCRYPTION_VERSION: i32 = 1; @@ -27,43 +28,26 @@ pub struct CreateTotpSecretResponse { } pub async fn create_totp_secret(State(state): State, auth: BearerAuth) -> Response { - let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did) + let existing = sqlx::query_scalar!("SELECT verified FROM user_totp WHERE did = $1", &*&auth.0.did) .fetch_optional(&state.db) .await; if let Ok(Some(true)) = existing { - return ( - StatusCode::CONFLICT, - Json(json!({ - "error": "TotpAlreadyEnabled", - "message": "TOTP is already enabled for this account" - })), - ) - .into_response(); + return ApiError::TotpAlreadyEnabled.into_response(); } let secret = generate_totp_secret(); - let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", auth.0.did) + let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", &*&auth.0.did) .fetch_optional(&state.db) .await; let handle = match handle { Ok(Some(h)) => h, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); - } + Ok(None) => return ApiError::AccountNotFound.into_response(), Err(e) => { error!("DB error fetching handle: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -74,11 +58,7 @@ pub async fn create_totp_secret(State(state): State, auth: BearerAuth) Ok(qr) => qr, Err(e) => { error!("Failed to generate QR code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to generate QR code"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to generate QR code".into())).into_response(); } }; @@ -86,11 +66,7 @@ pub async fn create_totp_secret(State(state): State, auth: BearerAuth) Ok(enc) => enc, Err(e) => { error!("Failed to encrypt TOTP secret: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -105,7 +81,7 @@ pub async fn create_totp_secret(State(state): State, auth: BearerAuth) created_at = NOW(), last_used = NULL "#, - auth.0.did, + &auth.0.did, encrypted_secret, ENCRYPTION_VERSION ) @@ -114,16 +90,12 @@ pub async fn create_totp_secret(State(state): State, auth: BearerAuth) if let Err(e) = result { error!("Failed to store TOTP secret: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } let secret_base32 = base32::encode(base32::Alphabet::Rfc4648 { padding: false }, &secret); - info!(did = %auth.0.did, "TOTP secret created (pending verification)"); + info!(did = %&auth.0.did, "TOTP secret created (pending verification)"); Json(CreateTotpSecretResponse { secret: secret_base32, @@ -153,55 +125,28 @@ pub async fn enable_totp( .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) .await { - warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many verification attempts. Please try again in a few minutes." - })), - ) - .into_response(); + warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded"); + return ApiError::RateLimitExceeded(None).into_response(); } let totp_row = sqlx::query!( "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", - auth.0.did + &auth.0.did ) .fetch_optional(&state.db) .await; let totp_row = match totp_row { Ok(Some(row)) => row, - Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "TotpNotSetup", - "message": "Please call createTotpSecret first" - })), - ) - .into_response(); - } + Ok(None) => return ApiError::TotpNotEnabled.into_response(), Err(e) => { error!("DB error fetching TOTP: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if totp_row.verified { - return ( - StatusCode::CONFLICT, - Json(json!({ - "error": "TotpAlreadyEnabled", - "message": "TOTP is already enabled" - })), - ) - .into_response(); + return ApiError::TotpAlreadyEnabled.into_response(); } let secret = match decrypt_totp_secret(&totp_row.secret_encrypted, totp_row.encryption_version) @@ -209,24 +154,13 @@ pub async fn enable_totp( Ok(s) => s, Err(e) => { error!("Failed to decrypt TOTP secret: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let code = input.code.trim(); if !verify_totp_code(&secret, code) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "InvalidCode", - "message": "Invalid verification code" - })), - ) - .into_response(); + return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response(); } let backup_codes = generate_backup_codes(); @@ -234,39 +168,27 @@ pub async fn enable_totp( Ok(tx) => tx, Err(e) => { error!("Failed to begin transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if let Err(e) = sqlx::query!( "UPDATE user_totp SET verified = true, last_used = NOW() WHERE did = $1", - auth.0.did + &auth.0.did ) .execute(&mut *tx) .await { error!("Failed to enable TOTP: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } - if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did) + if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", &*&auth.0.did) .execute(&mut *tx) .await { error!("Failed to clear old backup codes: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } for code in &backup_codes { @@ -274,48 +196,36 @@ pub async fn enable_totp( Ok(h) => h, Err(e) => { error!("Failed to hash backup code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if let Err(e) = sqlx::query!( "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())", - auth.0.did, + &auth.0.did, hash ) .execute(&mut *tx) .await { error!("Failed to store backup code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } if let Err(e) = tx.commit().await { error!("Failed to commit transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } - info!(did = %auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len()); + info!(did = %&auth.0.did, "TOTP enabled with {} backup codes", backup_codes.len()); Json(EnableTotpResponse { backup_codes }).into_response() } #[derive(Deserialize)] pub struct DisableTotpInput { - pub password: String, + pub password: PlainPassword, pub code: String, } @@ -333,37 +243,20 @@ pub async fn disable_totp( .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) .await { - warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many verification attempts. Please try again in a few minutes." - })), - ) - .into_response(); + warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded"); + return ApiError::RateLimitExceeded(None).into_response(); } - let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) + let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did) .fetch_optional(&state.db) .await; let password_hash = match user { Ok(Some(row)) => row.password_hash, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); - } + Ok(None) => return ApiError::AccountNotFound.into_response(), Err(e) => { error!("DB error fetching user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -372,42 +265,22 @@ pub async fn disable_totp( .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false)) .unwrap_or(false); if !password_valid { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "InvalidPassword", - "message": "Password is incorrect" - })), - ) - .into_response(); + return ApiError::InvalidPassword("Password is incorrect".into()).into_response(); } let totp_row = sqlx::query!( "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", - auth.0.did + &auth.0.did ) .fetch_optional(&state.db) .await; let totp_row = match totp_row { Ok(Some(row)) if row.verified => row, - Ok(Some(_)) | Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "TotpNotEnabled", - "message": "TOTP is not enabled for this account" - })), - ) - .into_response(); - } + Ok(Some(_)) | Ok(None) => return ApiError::TotpNotEnabled.into_response(), Err(e) => { error!("DB error fetching TOTP: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -420,75 +293,48 @@ pub async fn disable_totp( Ok(s) => s, Err(e) => { error!("Failed to decrypt TOTP secret: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; verify_totp_code(&secret, code) }; if !code_valid { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "InvalidCode", - "message": "Invalid verification code" - })), - ) - .into_response(); + return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response(); } let mut tx = match state.db.begin().await { Ok(tx) => tx, Err(e) => { error!("Failed to begin transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; - if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", auth.0.did) + if let Err(e) = sqlx::query!("DELETE FROM user_totp WHERE did = $1", &*&auth.0.did) .execute(&mut *tx) .await { error!("Failed to delete TOTP: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } - if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did) + if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", &*&auth.0.did) .execute(&mut *tx) .await { error!("Failed to delete backup codes: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } if let Err(e) = tx.commit().await { error!("Failed to commit transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } - info!(did = %auth.0.did, "TOTP disabled"); + info!(did = %&auth.0.did, "TOTP disabled"); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } #[derive(Serialize)] @@ -500,7 +346,7 @@ pub struct GetTotpStatusResponse { } pub async fn get_totp_status(State(state): State, auth: BearerAuth) -> Response { - let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", auth.0.did) + let totp_row = sqlx::query!("SELECT verified FROM user_totp WHERE did = $1", &*&auth.0.did) .fetch_optional(&state.db) .await; @@ -509,17 +355,13 @@ pub async fn get_totp_status(State(state): State, auth: BearerAuth) -> Ok(None) => false, Err(e) => { error!("DB error fetching TOTP status: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let backup_count_row = sqlx::query!( "SELECT COUNT(*) as count FROM backup_codes WHERE did = $1 AND used_at IS NULL", - auth.0.did + &auth.0.did ) .fetch_one(&state.db) .await; @@ -536,7 +378,7 @@ pub async fn get_totp_status(State(state): State, auth: BearerAuth) -> #[derive(Deserialize)] pub struct RegenerateBackupCodesInput { - pub password: String, + pub password: PlainPassword, pub code: String, } @@ -555,37 +397,20 @@ pub async fn regenerate_backup_codes( .check_rate_limit(RateLimitKind::TotpVerify, &auth.0.did) .await { - warn!(did = %auth.0.did, "TOTP verification rate limit exceeded"); - return ( - StatusCode::TOO_MANY_REQUESTS, - Json(json!({ - "error": "RateLimitExceeded", - "message": "Too many verification attempts. Please try again in a few minutes." - })), - ) - .into_response(); + warn!(did = %&auth.0.did, "TOTP verification rate limit exceeded"); + return ApiError::RateLimitExceeded(None).into_response(); } - let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", auth.0.did) + let user = sqlx::query!("SELECT password_hash FROM users WHERE did = $1", &*&auth.0.did) .fetch_optional(&state.db) .await; let password_hash = match user { Ok(Some(row)) => row.password_hash, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "AccountNotFound", "message": "Account not found"})), - ) - .into_response(); - } + Ok(None) => return ApiError::AccountNotFound.into_response(), Err(e) => { error!("DB error fetching user: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -594,42 +419,22 @@ pub async fn regenerate_backup_codes( .map(|h| bcrypt::verify(&input.password, h).unwrap_or(false)) .unwrap_or(false); if !password_valid { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "InvalidPassword", - "message": "Password is incorrect" - })), - ) - .into_response(); + return ApiError::InvalidPassword("Password is incorrect".into()).into_response(); } let totp_row = sqlx::query!( "SELECT secret_encrypted, encryption_version, verified FROM user_totp WHERE did = $1", - auth.0.did + &auth.0.did ) .fetch_optional(&state.db) .await; let totp_row = match totp_row { Ok(Some(row)) if row.verified => row, - Ok(Some(_)) | Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "TotpNotEnabled", - "message": "TOTP must be enabled to regenerate backup codes" - })), - ) - .into_response(); - } + Ok(Some(_)) | Ok(None) => return ApiError::TotpNotEnabled.into_response(), Err(e) => { error!("DB error fetching TOTP: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; @@ -638,24 +443,13 @@ pub async fn regenerate_backup_codes( Ok(s) => s, Err(e) => { error!("Failed to decrypt TOTP secret: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let code = input.code.trim(); if !verify_totp_code(&secret, code) { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({ - "error": "InvalidCode", - "message": "Invalid verification code" - })), - ) - .into_response(); + return ApiError::InvalidCode(Some("Invalid verification code".into())).into_response(); } let backup_codes = generate_backup_codes(); @@ -663,24 +457,16 @@ pub async fn regenerate_backup_codes( Ok(tx) => tx, Err(e) => { error!("Failed to begin transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; - if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", auth.0.did) + if let Err(e) = sqlx::query!("DELETE FROM backup_codes WHERE did = $1", &*&auth.0.did) .execute(&mut *tx) .await { error!("Failed to clear old backup codes: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } for code in &backup_codes { @@ -688,41 +474,29 @@ pub async fn regenerate_backup_codes( Ok(h) => h, Err(e) => { error!("Failed to hash backup code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; if let Err(e) = sqlx::query!( "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())", - auth.0.did, + &auth.0.did, hash ) .execute(&mut *tx) .await { error!("Failed to store backup code: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } if let Err(e) = tx.commit().await { error!("Failed to commit transaction: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } - info!(did = %auth.0.did, "Backup codes regenerated"); + info!(did = %&auth.0.did, "Backup codes regenerated"); Json(RegenerateBackupCodesResponse { backup_codes }).into_response() } diff --git a/src/api/server/trusted_devices.rs b/src/api/server/trusted_devices.rs index 48b2f57..e2935c2 100644 --- a/src/api/server/trusted_devices.rs +++ b/src/api/server/trusted_devices.rs @@ -1,12 +1,12 @@ +use crate::api::error::ApiError; +use crate::api::SuccessResponse; use axum::{ Json, extract::State, - http::StatusCode, response::{IntoResponse, Response}, }; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; -use serde_json::json; use sqlx::PgPool; use tracing::{error, info}; @@ -15,6 +15,43 @@ use crate::state::AppState; const TRUST_DURATION_DAYS: i64 = 30; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DeviceTrustState { + Untrusted, + Trusted, + Expired, +} + +impl DeviceTrustState { + pub fn from_timestamps( + trusted_at: Option>, + trusted_until: Option>, + ) -> Self { + match (trusted_at, trusted_until) { + (Some(_), Some(until)) if until > Utc::now() => Self::Trusted, + (Some(_), Some(_)) => Self::Expired, + _ => Self::Untrusted, + } + } + + pub fn is_trusted(&self) -> bool { + matches!(self, Self::Trusted) + } + + pub fn is_expired(&self) -> bool { + matches!(self, Self::Expired) + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Untrusted => "untrusted", + Self::Trusted => "trusted", + Self::Expired => "expired", + } + } +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct TrustedDevice { @@ -24,6 +61,7 @@ pub struct TrustedDevice { pub trusted_at: Option>, pub trusted_until: Option>, pub last_seen_at: DateTime, + pub trust_state: DeviceTrustState, } #[derive(Serialize)] @@ -39,7 +77,7 @@ pub async fn list_trusted_devices(State(state): State, auth: BearerAut JOIN oauth_account_device oad ON od.id = oad.device_id WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW() ORDER BY od.last_seen_at DESC"#, - auth.0.did + &auth.0.did ) .fetch_all(&state.db) .await; @@ -48,24 +86,24 @@ pub async fn list_trusted_devices(State(state): State, auth: BearerAut Ok(rows) => { let devices = rows .into_iter() - .map(|row| TrustedDevice { - id: row.id, - user_agent: row.user_agent, - friendly_name: row.friendly_name, - trusted_at: row.trusted_at, - trusted_until: row.trusted_until, - last_seen_at: row.last_seen_at, + .map(|row| { + let trust_state = DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until); + TrustedDevice { + id: row.id, + user_agent: row.user_agent, + friendly_name: row.friendly_name, + trusted_at: row.trusted_at, + trusted_until: row.trusted_until, + last_seen_at: row.last_seen_at, + trust_state, + } }) .collect(); Json(ListTrustedDevicesResponse { devices }).into_response() } Err(e) => { error!("DB error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -85,7 +123,7 @@ pub async fn revoke_trusted_device( r#"SELECT 1 as one FROM oauth_device od JOIN oauth_account_device oad ON od.id = oad.device_id WHERE oad.did = $1 AND od.id = $2"#, - auth.0.did, + &auth.0.did, input.device_id ) .fetch_optional(&state.db) @@ -94,19 +132,11 @@ pub async fn revoke_trusted_device( match device_exists { Ok(Some(_)) => {} Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})), - ) - .into_response(); + return ApiError::DeviceNotFound.into_response(); } Err(e) => { error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } @@ -119,16 +149,12 @@ pub async fn revoke_trusted_device( match result { Ok(_) => { - info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device revoked"); - Json(json!({"success": true})).into_response() + info!(did = %&auth.0.did, device_id = %input.device_id, "Trusted device revoked"); + SuccessResponse::ok().into_response() } Err(e) => { error!("DB error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } @@ -149,7 +175,7 @@ pub async fn update_trusted_device( r#"SELECT 1 as one FROM oauth_device od JOIN oauth_account_device oad ON od.id = oad.device_id WHERE oad.did = $1 AND od.id = $2"#, - auth.0.did, + &auth.0.did, input.device_id ) .fetch_optional(&state.db) @@ -158,19 +184,11 @@ pub async fn update_trusted_device( match device_exists { Ok(Some(_)) => {} Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "DeviceNotFound", "message": "Device not found or not owned by this account"})), - ) - .into_response(); + return ApiError::DeviceNotFound.into_response(); } Err(e) => { error!("DB error: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } } @@ -185,22 +203,18 @@ pub async fn update_trusted_device( match result { Ok(_) => { info!(did = %auth.0.did, device_id = %input.device_id, "Trusted device updated"); - Json(json!({"success": true})).into_response() + SuccessResponse::ok().into_response() } Err(e) => { error!("DB error: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(None).into_response() } } } -pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool { - let result = sqlx::query_scalar!( - r#"SELECT trusted_until FROM oauth_device od +pub async fn get_device_trust_state(db: &PgPool, device_id: &str, did: &str) -> DeviceTrustState { + let result = sqlx::query!( + r#"SELECT trusted_at, trusted_until FROM oauth_device od JOIN oauth_account_device oad ON od.id = oad.device_id WHERE od.id = $1 AND oad.did = $2"#, device_id, @@ -210,11 +224,15 @@ pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool .await; match result { - Ok(Some(Some(trusted_until))) => trusted_until > Utc::now(), - _ => false, + Ok(Some(row)) => DeviceTrustState::from_timestamps(row.trusted_at, row.trusted_until), + _ => DeviceTrustState::Untrusted, } } +pub async fn is_device_trusted(db: &PgPool, device_id: &str, did: &str) -> bool { + get_device_trust_state(db, device_id, did).await.is_trusted() +} + pub async fn trust_device(db: &PgPool, device_id: &str) -> Result<(), sqlx::Error> { let now = Utc::now(); let trusted_until = now + Duration::days(TRUST_DURATION_DAYS); diff --git a/src/api/server/verify_email.rs b/src/api/server/verify_email.rs index 5ec32c6..b5323ad 100644 --- a/src/api/server/verify_email.rs +++ b/src/api/server/verify_email.rs @@ -1,6 +1,7 @@ -use axum::{Json, extract::State, http::StatusCode}; +use crate::api::error::ApiError; +use crate::types::Did; +use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; -use serde_json::json; use tracing::{info, warn}; use crate::state::AppState; @@ -16,13 +17,13 @@ pub struct VerifyMigrationEmailInput { #[serde(rename_all = "camelCase")] pub struct VerifyMigrationEmailOutput { pub success: bool, - pub did: String, + pub did: Did, } pub async fn verify_migration_email( State(state): State, Json(input): Json, -) -> Result, (StatusCode, Json)> { +) -> Result, ApiError> { let token_input = super::verify_token::VerifyTokenInput { token: input.token, identifier: input.email, @@ -32,7 +33,7 @@ pub async fn verify_migration_email( Ok(Json(VerifyMigrationEmailOutput { success: result.success, - did: result.did.clone(), + did: result.did.clone().into(), })) } @@ -51,7 +52,7 @@ pub struct ResendMigrationVerificationOutput { pub async fn resend_migration_verification( State(state): State, Json(input): Json, -) -> Result, (StatusCode, Json)> { +) -> Result, ApiError> { let email = input.email.trim().to_lowercase(); let user = sqlx::query!( @@ -62,10 +63,7 @@ pub async fn resend_migration_verification( .await .map_err(|e| { warn!(error = %e, "Database error during resend verification"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "InternalError", "message": "Database error" })), - ) + ApiError::InternalError(None) })?; let user = match user { diff --git a/src/api/server/verify_token.rs b/src/api/server/verify_token.rs index 64ed235..60de1e5 100644 --- a/src/api/server/verify_token.rs +++ b/src/api/server/verify_token.rs @@ -1,10 +1,11 @@ -use axum::{Json, extract::State, http::StatusCode}; +use crate::api::error::ApiError; +use crate::types::Did; +use axum::{Json, extract::State}; use serde::{Deserialize, Serialize}; -use serde_json::json; use tracing::{error, info, warn}; use crate::auth::verification_token::{ - VerificationPurpose, VerifyError, normalize_token_input, verify_token_signature, + VerificationPurpose, normalize_token_input, verify_token_signature, }; use crate::state::AppState; @@ -19,7 +20,7 @@ pub struct VerifyTokenInput { #[serde(rename_all = "camelCase")] pub struct VerifyTokenOutput { pub success: bool, - pub did: String, + pub did: Did, pub purpose: String, pub channel: String, } @@ -27,60 +28,25 @@ pub struct VerifyTokenOutput { pub async fn verify_token( State(state): State, Json(input): Json, -) -> Result, (StatusCode, Json)> { +) -> Result, ApiError> { verify_token_internal(&state, input).await } pub async fn verify_token_internal( state: &AppState, input: VerifyTokenInput, -) -> Result, (StatusCode, Json)> { +) -> Result, ApiError> { let normalized_token = normalize_token_input(&input.token); let identifier = input.identifier.trim().to_lowercase(); - let token_data = match verify_token_signature(&normalized_token) { - Ok(data) => data, - Err(e) => { - let (status, error, message) = match e { - VerifyError::InvalidFormat => ( - StatusCode::BAD_REQUEST, - "InvalidToken", - "The verification token is invalid or malformed", - ), - VerifyError::UnsupportedVersion => ( - StatusCode::BAD_REQUEST, - "InvalidToken", - "This verification token version is not supported", - ), - VerifyError::Expired => ( - StatusCode::BAD_REQUEST, - "ExpiredToken", - "The verification token has expired. Please request a new one.", - ), - VerifyError::InvalidSignature => ( - StatusCode::BAD_REQUEST, - "InvalidToken", - "The verification token signature is invalid", - ), - _ => ( - StatusCode::BAD_REQUEST, - "InvalidToken", - "The verification token is not valid", - ), - }; - warn!(error = ?e, "Token verification failed"); - return Err((status, Json(json!({ "error": error, "message": message })))); - } - }; + let token_data = verify_token_signature(&normalized_token).map_err(|e| { + warn!(error = ?e, "Token verification failed"); + ApiError::from(e) + })?; let expected_hash = crate::auth::verification_token::hash_identifier(&identifier); if token_data.identifier_hash != expected_hash { - return Err(( - StatusCode::BAD_REQUEST, - Json( - json!({ "error": "IdentifierMismatch", "message": "The identifier does not match the verification token" }), - ), - )); + return Err(ApiError::IdentifierMismatch); } match token_data.purpose { @@ -103,14 +69,9 @@ async fn handle_migration_verification( did: &str, channel: &str, identifier: &str, -) -> Result, (StatusCode, Json)> { +) -> Result, ApiError> { if channel != "email" { - return Err(( - StatusCode::BAD_REQUEST, - Json( - json!({ "error": "InvalidChannel", "message": "Migration verification is only supported for email" }), - ), - )); + return Err(ApiError::InvalidChannel); } let user = sqlx::query!( @@ -121,26 +82,13 @@ async fn handle_migration_verification( .await .map_err(|e| { warn!(error = %e, "Database error during migration verification"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "InternalError", "message": "Database error" })), - ) + ApiError::InternalError(None) })?; - let user = user.ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })), - ) - })?; + let user = user.ok_or(ApiError::AccountNotFound)?; if user.email.as_ref().map(|e| e.to_lowercase()) != Some(identifier.to_string()) { - return Err(( - StatusCode::BAD_REQUEST, - Json( - json!({ "error": "IdentifierMismatch", "message": "The email address does not match the account" }), - ), - )); + return Err(ApiError::IdentifierMismatch); } if !user.email_verified { @@ -152,10 +100,7 @@ async fn handle_migration_verification( .await .map_err(|e| { warn!(error = %e, "Failed to update email_verified status"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "InternalError", "message": "Failed to verify email" })), - ) + ApiError::InternalError(None) })?; } @@ -163,7 +108,7 @@ async fn handle_migration_verification( Ok(Json(VerifyTokenOutput { success: true, - did: did.to_string(), + did: did.to_string().into(), purpose: "migration".to_string(), channel: channel.to_string(), })) @@ -174,16 +119,11 @@ async fn handle_channel_update( did: &str, channel: &str, identifier: &str, -) -> Result, (StatusCode, Json)> { +) -> Result, ApiError> { let user_id = sqlx::query_scalar!("SELECT id FROM users WHERE did = $1", did) .fetch_one(&state.db) .await - .map_err(|_| { - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "InternalError", "message": "User not found" })), - ) - })?; + .map_err(|_| ApiError::InternalError(None))?; let update_result = match channel { "email" => sqlx::query!( @@ -207,10 +147,7 @@ async fn handle_channel_update( user_id ).execute(&state.db).await, _ => { - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })), - )); + return Err(ApiError::InvalidChannel); } }; @@ -221,22 +158,16 @@ async fn handle_channel_update( .map(|db| db.is_unique_violation()) .unwrap_or(false) { - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "EmailTaken", "message": "Email already in use" })), - )); + return Err(ApiError::EmailTaken); } - return Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "InternalError", "message": "Failed to update channel" })), - )); + return Err(ApiError::InternalError(None)); } info!(did = %did, channel = %channel, "Channel verified successfully"); Ok(Json(VerifyTokenOutput { success: true, - did: did.to_string(), + did: did.to_string().into(), purpose: "channel_update".to_string(), channel: channel.to_string(), })) @@ -247,7 +178,7 @@ async fn handle_signup_verification( did: &str, channel: &str, _identifier: &str, -) -> Result, (StatusCode, Json)> { +) -> Result, ApiError> { let user = sqlx::query!( "SELECT id, handle, email, email_verified, discord_verified, telegram_verified, signal_verified FROM users WHERE did = $1", did @@ -256,18 +187,10 @@ async fn handle_signup_verification( .await .map_err(|e| { warn!(error = %e, "Database error during signup verification"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "InternalError", "message": "Database error" })), - ) + ApiError::InternalError(None) })?; - let user = user.ok_or_else(|| { - ( - StatusCode::NOT_FOUND, - Json(json!({ "error": "AccountNotFound", "message": "No account found for this verification token" })), - ) - })?; + let user = user.ok_or(ApiError::AccountNotFound)?; let is_verified = user.email_verified || user.discord_verified @@ -277,7 +200,7 @@ async fn handle_signup_verification( info!(did = %did, "Account already verified"); return Ok(Json(VerifyTokenOutput { success: true, - did: did.to_string(), + did: did.to_string().into(), purpose: "signup".to_string(), channel: channel.to_string(), })); @@ -317,26 +240,20 @@ async fn handle_signup_verification( .await } _ => { - return Err(( - StatusCode::BAD_REQUEST, - Json(json!({ "error": "InvalidChannel", "message": "Invalid channel" })), - )); + return Err(ApiError::InvalidChannel); } }; update_result.map_err(|e| { warn!(error = %e, "Failed to update channel verified status"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ "error": "InternalError", "message": "Failed to verify channel" })), - ) + ApiError::InternalError(None) })?; info!(did = %did, channel = %channel, "Signup verified successfully"); Ok(Json(VerifyTokenOutput { success: true, - did: did.to_string(), + did: did.to_string().into(), purpose: "signup".to_string(), channel: channel.to_string(), })) diff --git a/src/api/temp.rs b/src/api/temp.rs index 279e182..cae0096 100644 --- a/src/api/temp.rs +++ b/src/api/temp.rs @@ -1,15 +1,15 @@ +use crate::api::error::ApiError; use crate::auth::{extract_bearer_token_from_header, validate_bearer_token}; use crate::state::AppState; use axum::{ Json, extract::State, - http::{HeaderMap, StatusCode}, + http::HeaderMap, response::{IntoResponse, Response}, }; use cid::Cid; use jacquard_repo::storage::BlockStore; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::str::FromStr; #[derive(Serialize)] @@ -28,14 +28,7 @@ pub async fn check_signup_queue(State(state): State, headers: HeaderMa && let Ok(user) = validate_bearer_token(&state.db, &token).await && user.is_oauth { - return ( - StatusCode::FORBIDDEN, - Json(json!({ - "error": "Forbidden", - "message": "OAuth credentials are not supported for this endpoint" - })), - ) - .into_response(); + return ApiError::Forbidden.into_response(); } Json(CheckSignupQueueOutput { activated: true, @@ -62,25 +55,14 @@ pub async fn dereference_scope( headers: HeaderMap, Json(input): Json, ) -> Response { - let token = match extract_bearer_token_from_header( + let Some(token) = extract_bearer_token_from_header( headers.get("Authorization").and_then(|h| h.to_str().ok()), - ) { - Some(t) => t, - None => { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationRequired"})), - ) - .into_response(); - } + ) else { + return ApiError::AuthenticationRequired.into_response(); }; if validate_bearer_token(&state.db, &token).await.is_err() { - return ( - StatusCode::UNAUTHORIZED, - Json(json!({"error": "AuthenticationFailed"})), - ) - .into_response(); + return ApiError::AuthenticationFailed(None).into_response(); } let scope_parts: Vec<&str> = input.scope.split_whitespace().collect(); diff --git a/src/api/validation.rs b/src/api/validation.rs index f0956cb..0d06e71 100644 --- a/src/api/validation.rs +++ b/src/api/validation.rs @@ -1,3 +1,7 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; +use std::ops::Deref; + pub const MAX_EMAIL_LENGTH: usize = 254; pub const MAX_LOCAL_PART_LENGTH: usize = 64; pub const MAX_DOMAIN_LENGTH: usize = 253; @@ -8,6 +12,195 @@ pub const MIN_HANDLE_LENGTH: usize = 3; pub const MAX_HANDLE_LENGTH: usize = 253; pub const MAX_SERVICE_HANDLE_LOCAL_PART: usize = 18; +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ValidatedLocalHandle(String); + +impl ValidatedLocalHandle { + pub fn new(handle: impl AsRef) -> Result { + let validated = validate_short_handle(handle.as_ref())?; + Ok(Self(validated)) + } + + pub fn new_allow_reserved(handle: impl AsRef) -> Result { + let validated = validate_service_handle(handle.as_ref(), true)?; + Ok(Self(validated)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl Deref for ValidatedLocalHandle { + type Target = str; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for ValidatedLocalHandle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for ValidatedLocalHandle { + type Error = HandleValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(handle: ValidatedLocalHandle) -> Self { + handle.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EmailValidationError { + Empty, + TooLong, + MissingAtSign, + EmptyLocalPart, + LocalPartTooLong, + InvalidLocalPart, + EmptyDomain, + DomainTooLong, + MissingDomainDot, + InvalidDomainLabel, +} + +impl fmt::Display for EmailValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "Email cannot be empty"), + Self::TooLong => write!(f, "Email exceeds maximum length of {} characters", MAX_EMAIL_LENGTH), + Self::MissingAtSign => write!(f, "Email must contain @"), + Self::EmptyLocalPart => write!(f, "Email local part cannot be empty"), + Self::LocalPartTooLong => write!(f, "Email local part exceeds maximum length"), + Self::InvalidLocalPart => write!(f, "Email local part contains invalid characters"), + Self::EmptyDomain => write!(f, "Email domain cannot be empty"), + Self::DomainTooLong => write!(f, "Email domain exceeds maximum length"), + Self::MissingDomainDot => write!(f, "Email domain must contain a dot"), + Self::InvalidDomainLabel => write!(f, "Email domain contains invalid label"), + } + } +} + +impl std::error::Error for EmailValidationError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ValidatedEmail(String); + +impl ValidatedEmail { + pub fn new(email: impl AsRef) -> Result { + let email = email.as_ref().trim(); + validate_email_detailed(email)?; + Ok(Self(email.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn local_part(&self) -> &str { + self.0.rsplitn(2, '@').nth(1).unwrap_or("") + } + + pub fn domain(&self) -> &str { + self.0.rsplitn(2, '@').next().unwrap_or("") + } +} + +impl Deref for ValidatedEmail { + type Target = str; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for ValidatedEmail { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for ValidatedEmail { + type Error = EmailValidationError; + fn try_from(value: String) -> Result { + Self::new(value) + } +} + +impl From for String { + fn from(email: ValidatedEmail) -> Self { + email.0 + } +} + +fn validate_email_detailed(email: &str) -> Result<(), EmailValidationError> { + if email.is_empty() { + return Err(EmailValidationError::Empty); + } + if email.len() > MAX_EMAIL_LENGTH { + return Err(EmailValidationError::TooLong); + } + let parts: Vec<&str> = email.rsplitn(2, '@').collect(); + if parts.len() != 2 { + return Err(EmailValidationError::MissingAtSign); + } + let domain = parts[0]; + let local = parts[1]; + if local.is_empty() { + return Err(EmailValidationError::EmptyLocalPart); + } + if local.len() > MAX_LOCAL_PART_LENGTH { + return Err(EmailValidationError::LocalPartTooLong); + } + if local.starts_with('.') || local.ends_with('.') || local.contains("..") { + return Err(EmailValidationError::InvalidLocalPart); + } + for c in local.chars() { + if !c.is_ascii_alphanumeric() && !EMAIL_LOCAL_SPECIAL_CHARS.contains(c) { + return Err(EmailValidationError::InvalidLocalPart); + } + } + if domain.is_empty() { + return Err(EmailValidationError::EmptyDomain); + } + if domain.len() > MAX_DOMAIN_LENGTH { + return Err(EmailValidationError::DomainTooLong); + } + if !domain.contains('.') { + return Err(EmailValidationError::MissingDomainDot); + } + for label in domain.split('.') { + if label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH { + return Err(EmailValidationError::InvalidDomainLabel); + } + if label.starts_with('-') || label.ends_with('-') { + return Err(EmailValidationError::InvalidDomainLabel); + } + for c in label.chars() { + if !c.is_ascii_alphanumeric() && c != '-' { + return Err(EmailValidationError::InvalidDomainLabel); + } + } + } + Ok(()) +} + #[derive(Debug, PartialEq)] pub enum HandleValidationError { Empty, @@ -50,6 +243,8 @@ impl std::fmt::Display for HandleValidationError { } } +impl std::error::Error for HandleValidationError {} + pub fn validate_short_handle(handle: &str) -> Result { validate_service_handle(handle, false) } diff --git a/src/api/verification.rs b/src/api/verification.rs index 036ff6b..4b79107 100644 --- a/src/api/verification.rs +++ b/src/api/verification.rs @@ -1,3 +1,4 @@ +use crate::api::SuccessResponse; use crate::state::AppState; use axum::{ Json, @@ -5,7 +6,6 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::Deserialize; -use serde_json::json; #[derive(Deserialize)] #[serde(rename_all = "camelCase")] @@ -25,7 +25,7 @@ pub async fn confirm_channel_verification( }; match crate::api::server::verify_token_internal(&state, token_input).await { - Ok(output) => Json(json!({"success": output.success})).into_response(), - Err((status, err_json)) => (status, err_json).into_response(), + Ok(_output) => SuccessResponse::ok().into_response(), + Err(e) => e.into_response(), } } diff --git a/src/appview/mod.rs b/src/appview/mod.rs index d58b840..918c9c5 100644 --- a/src/appview/mod.rs +++ b/src/appview/mod.rs @@ -41,26 +41,15 @@ pub struct ResolvedService { pub did: String, } +#[derive(Clone)] pub struct DidResolver { - did_cache: RwLock>, - did_doc_cache: RwLock>, + did_cache: Arc>>, + did_doc_cache: Arc>>, client: Client, cache_ttl: Duration, plc_directory_url: String, } -impl Clone for DidResolver { - fn clone(&self) -> Self { - Self { - did_cache: RwLock::new(HashMap::new()), - did_doc_cache: RwLock::new(HashMap::new()), - client: self.client.clone(), - cache_ttl: self.cache_ttl, - plc_directory_url: self.plc_directory_url.clone(), - } - } -} - impl DidResolver { pub fn new() -> Self { let cache_ttl_secs: u64 = std::env::var("DID_CACHE_TTL_SECS") @@ -81,8 +70,8 @@ impl DidResolver { info!("DID resolver initialized"); Self { - did_cache: RwLock::new(HashMap::new()), - did_doc_cache: RwLock::new(HashMap::new()), + did_cache: Arc::new(RwLock::new(HashMap::new())), + did_doc_cache: Arc::new(RwLock::new(HashMap::new())), client, cache_ttl: Duration::from_secs(cache_ttl_secs), plc_directory_url, diff --git a/src/auth/extractor.rs b/src/auth/extractor.rs index 9fefc39..e53f364 100644 --- a/src/auth/extractor.rs +++ b/src/auth/extractor.rs @@ -1,15 +1,14 @@ use axum::{ - Json, extract::FromRequestParts, - http::{StatusCode, header::AUTHORIZATION, request::Parts}, + http::{header::AUTHORIZATION, request::Parts}, response::{IntoResponse, Response}, }; -use serde_json::json; use super::{ AuthenticatedUser, TokenValidationError, validate_bearer_token_cached, validate_bearer_token_cached_allow_deactivated, validate_token_with_dpop, }; +use crate::api::error::ApiError; use crate::state::AppState; use crate::util::build_full_url; @@ -28,45 +27,7 @@ pub enum AuthError { impl IntoResponse for AuthError { fn into_response(self) -> Response { - let (status, error, message) = match self { - AuthError::MissingToken => ( - StatusCode::UNAUTHORIZED, - "AuthenticationRequired", - "Authorization header is required", - ), - AuthError::InvalidFormat => ( - StatusCode::UNAUTHORIZED, - "InvalidToken", - "Invalid authorization header format", - ), - AuthError::AuthenticationFailed => ( - StatusCode::UNAUTHORIZED, - "InvalidToken", - "Token could not be verified", - ), - AuthError::TokenExpired => ( - StatusCode::UNAUTHORIZED, - "ExpiredToken", - "Token has expired", - ), - AuthError::AccountDeactivated => ( - StatusCode::UNAUTHORIZED, - "AccountDeactivated", - "Account is deactivated", - ), - AuthError::AccountTakedown => ( - StatusCode::UNAUTHORIZED, - "AccountTakedown", - "Account has been taken down", - ), - AuthError::AdminRequired => ( - StatusCode::FORBIDDEN, - "AdminRequired", - "This action requires admin privileges", - ), - }; - - (status, Json(json!({ "error": error, "message": message }))).into_response() + ApiError::from(self).into_response() } } @@ -185,7 +146,7 @@ impl FromRequestParts for BearerAuth { Err(_) => Err(AuthError::AuthenticationFailed), } } else { - match validate_bearer_token_cached(&state.db, &state.cache, &extracted.token).await { + match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token).await { Ok(user) => Ok(BearerAuth(user)), Err(TokenValidationError::AccountDeactivated) => Err(AuthError::AccountDeactivated), Err(TokenValidationError::AccountTakedown) => Err(AuthError::AccountTakedown), @@ -239,7 +200,7 @@ impl FromRequestParts for BearerAuthAllowDeactivated { } else { match validate_bearer_token_cached_allow_deactivated( &state.db, - &state.cache, + state.cache.as_ref(), &extracted.token, ) .await @@ -301,7 +262,7 @@ impl FromRequestParts for BearerAuthAdmin { Err(_) => return Err(AuthError::AuthenticationFailed), } } else { - match validate_bearer_token_cached(&state.db, &state.cache, &extracted.token).await { + match validate_bearer_token_cached(&state.db, state.cache.as_ref(), &extracted.token).await { Ok(user) => user, Err(TokenValidationError::AccountDeactivated) => { return Err(AuthError::AccountDeactivated); diff --git a/src/auth/mod.rs b/src/auth/mod.rs index a41ce62..945e5dc 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -1,9 +1,10 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::fmt; -use std::sync::Arc; use std::time::Duration; +use crate::types::Did; +use crate::AccountStatus; use crate::cache::Cache; use crate::oauth::scopes::ScopePermissions; @@ -66,13 +67,13 @@ impl fmt::Display for TokenValidationError { } pub struct AuthenticatedUser { - pub did: String, + pub did: Did, pub key_bytes: Option>, pub is_oauth: bool, pub is_admin: bool, - pub is_takendown: bool, + pub status: AccountStatus, pub scope: Option, - pub controller_did: Option, + pub controller_did: Option, } impl AuthenticatedUser { @@ -87,6 +88,10 @@ impl AuthenticatedUser { } ScopePermissions::from_scope_string(self.scope.as_deref()) } + + pub fn is_takendown(&self) -> bool { + self.status.is_takendown() + } } pub async fn validate_bearer_token( @@ -105,7 +110,7 @@ pub async fn validate_bearer_token_allow_deactivated( pub async fn validate_bearer_token_cached( db: &PgPool, - cache: &Arc, + cache: &dyn Cache, token: &str, ) -> Result { validate_bearer_token_with_options_internal(db, Some(cache), token, false, false).await @@ -113,7 +118,7 @@ pub async fn validate_bearer_token_cached( pub async fn validate_bearer_token_cached_allow_deactivated( db: &PgPool, - cache: &Arc, + cache: &dyn Cache, token: &str, ) -> Result { validate_bearer_token_with_options_internal(db, Some(cache), token, true, false).await @@ -135,7 +140,7 @@ pub async fn validate_bearer_token_allow_takendown( async fn validate_bearer_token_with_options_internal( db: &PgPool, - cache: Option<&Arc>, + cache: Option<&dyn Cache>, token: &str, allow_deactivated: bool, allow_takendown: bool, @@ -324,13 +329,21 @@ async fn validate_bearer_token_with_options_internal( } if session_valid { - let controller_did = token_data.claims.act.as_ref().map(|a| a.sub.clone()); + let controller_did = token_data + .claims + .act + .as_ref() + .map(|a| Did::new_unchecked(a.sub.clone())); + let status = AccountStatus::from_db_fields( + takedown_ref.as_deref(), + deactivated_at, + ); return Ok(AuthenticatedUser { - did: did.clone(), + did: Did::new_unchecked(did.clone()), key_bytes: Some(decrypted_key), is_oauth: false, is_admin, - is_takendown: takedown_ref.is_some(), + status, scope: token_data.claims.scope.clone(), controller_did, }); @@ -359,12 +372,16 @@ async fn validate_bearer_token_with_options_internal( .ok() .flatten() { - if !allow_deactivated && oauth_token.deactivated_at.is_some() { + let status = AccountStatus::from_db_fields( + oauth_token.takedown_ref.as_deref(), + oauth_token.deactivated_at, + ); + + if !allow_deactivated && status.is_deactivated() { return Err(TokenValidationError::AccountDeactivated); } - let is_takendown = oauth_token.takedown_ref.is_some(); - if !allow_takendown && is_takendown { + if !allow_takendown && status.is_takendown() { return Err(TokenValidationError::AccountTakedown); } @@ -378,13 +395,13 @@ async fn validate_bearer_token_with_options_internal( None }; return Ok(AuthenticatedUser { - did: oauth_token.did, + did: Did::new_unchecked(oauth_token.did), key_bytes, is_oauth: true, is_admin: oauth_token.is_admin, - is_takendown, + status, scope: oauth_info.scope, - controller_did: oauth_info.controller_did, + controller_did: oauth_info.controller_did.map(Did::new_unchecked), }); } else { return Err(TokenValidationError::TokenExpired); @@ -394,7 +411,7 @@ async fn validate_bearer_token_with_options_internal( Err(TokenValidationError::AuthenticationFailed) } -pub async fn invalidate_auth_cache(cache: &Arc, did: &str) { +pub async fn invalidate_auth_cache(cache: &dyn Cache, did: &str) { let key_cache_key = format!("auth:key:{}", did); let status_cache_key = format!("auth:status:{}", did); let _ = cache.delete(&key_cache_key).await; @@ -442,11 +459,14 @@ pub async fn validate_token_with_dpop( let Some(user_info) = user_info else { return Err(TokenValidationError::AuthenticationFailed); }; - if !allow_deactivated && user_info.deactivated_at.is_some() { + let status = AccountStatus::from_db_fields( + user_info.takedown_ref.as_deref(), + user_info.deactivated_at, + ); + if !allow_deactivated && status.is_deactivated() { return Err(TokenValidationError::AccountDeactivated); } - let is_takendown = user_info.takedown_ref.is_some(); - if is_takendown { + if status.is_takendown() { return Err(TokenValidationError::AccountTakedown); } let key_bytes = if let (Some(kb), Some(ev)) = @@ -457,11 +477,11 @@ pub async fn validate_token_with_dpop( None }; Ok(AuthenticatedUser { - did: result.did, + did: Did::new_unchecked(result.did), key_bytes, is_oauth: true, is_admin: user_info.is_admin, - is_takendown, + status, scope: result.scope, controller_did: None, }) diff --git a/src/auth/scope_check.rs b/src/auth/scope_check.rs index e85706e..c560a84 100644 --- a/src/auth/scope_check.rs +++ b/src/auth/scope_check.rs @@ -1,9 +1,8 @@ #![allow(clippy::result_large_err)] -use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use serde_json::json; +use crate::api::error::ApiError; use crate::oauth::scopes::{ AccountAction, AccountAttr, IdentityAttr, RepoAction, ScopePermissions, }; @@ -28,16 +27,9 @@ pub fn check_repo_scope( } let permissions = ScopePermissions::from_scope_string(scope); - permissions.assert_repo(action, collection).map_err(|e| { - ( - StatusCode::FORBIDDEN, - axum::Json(json!({ - "error": "InsufficientScope", - "message": e.to_string() - })), - ) - .into_response() - }) + permissions + .assert_repo(action, collection) + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) } pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Result<(), Response> { @@ -46,16 +38,9 @@ pub fn check_blob_scope(is_oauth: bool, scope: Option<&str>, mime: &str) -> Resu } let permissions = ScopePermissions::from_scope_string(scope); - permissions.assert_blob(mime).map_err(|e| { - ( - StatusCode::FORBIDDEN, - axum::Json(json!({ - "error": "InsufficientScope", - "message": e.to_string() - })), - ) - .into_response() - }) + permissions + .assert_blob(mime) + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) } pub fn check_rpc_scope( @@ -69,16 +54,9 @@ pub fn check_rpc_scope( } let permissions = ScopePermissions::from_scope_string(scope); - permissions.assert_rpc(aud, lxm).map_err(|e| { - ( - StatusCode::FORBIDDEN, - axum::Json(json!({ - "error": "InsufficientScope", - "message": e.to_string() - })), - ) - .into_response() - }) + permissions + .assert_rpc(aud, lxm) + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) } pub fn check_account_scope( @@ -92,16 +70,9 @@ pub fn check_account_scope( } let permissions = ScopePermissions::from_scope_string(scope); - permissions.assert_account(attr, action).map_err(|e| { - ( - StatusCode::FORBIDDEN, - axum::Json(json!({ - "error": "InsufficientScope", - "message": e.to_string() - })), - ) - .into_response() - }) + permissions + .assert_account(attr, action) + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) } pub fn check_identity_scope( @@ -114,14 +85,7 @@ pub fn check_identity_scope( } let permissions = ScopePermissions::from_scope_string(scope); - permissions.assert_identity(attr).map_err(|e| { - ( - StatusCode::FORBIDDEN, - axum::Json(json!({ - "error": "InsufficientScope", - "message": e.to_string() - })), - ) - .into_response() - }) + permissions + .assert_identity(attr) + .map_err(|e| ApiError::InsufficientScope(Some(e.to_string())).into_response()) } diff --git a/src/comms/service.rs b/src/comms/service.rs index e21253a..e75987f 100644 --- a/src/comms/service.rs +++ b/src/comms/service.rs @@ -257,7 +257,7 @@ pub async fn enqueue_comms(db: &PgPool, item: NewComms) -> Result, - pub handle: String, + pub handle: crate::types::Handle, pub locale: String, } @@ -282,7 +282,7 @@ pub async fn get_user_comms_prefs( Ok(UserCommsPrefs { channel: row.channel, email: row.email, - handle: row.handle, + handle: row.handle.into(), locale: row.preferred_locale.unwrap_or_else(|| "en".to_string()), }) } @@ -305,7 +305,7 @@ pub async fn enqueue_welcome( user_id, prefs.channel, super::types::CommsType::Welcome, - prefs.email.clone().unwrap_or_default(), + prefs.email.unwrap_or_default(), Some(subject), body, ), @@ -332,7 +332,7 @@ pub async fn enqueue_password_reset( user_id, prefs.channel, super::types::CommsType::PasswordReset, - prefs.email.clone().unwrap_or_default(), + prefs.email.unwrap_or_default(), Some(subject), body, ), @@ -388,7 +388,7 @@ pub async fn enqueue_email_update_token( ) -> Result { let prefs = get_user_comms_prefs(db, user_id).await?; let strings = get_strings(&prefs.locale); - let current_email = prefs.email.clone().unwrap_or_default(); + let current_email = prefs.email.unwrap_or_default(); let verify_page = format!("https://{}/app/verify?type=email-update", hostname); let verify_link = format!( "https://{}/app/verify?type=email-update&token={}", @@ -437,7 +437,7 @@ pub async fn enqueue_account_deletion( user_id, prefs.channel, super::types::CommsType::AccountDeletion, - prefs.email.clone().unwrap_or_default(), + prefs.email.unwrap_or_default(), Some(subject), body, ), @@ -464,7 +464,7 @@ pub async fn enqueue_plc_operation( user_id, prefs.channel, super::types::CommsType::PlcOperation, - prefs.email.clone().unwrap_or_default(), + prefs.email.unwrap_or_default(), Some(subject), body, ), @@ -491,7 +491,7 @@ pub async fn enqueue_2fa_code( user_id, prefs.channel, super::types::CommsType::TwoFactorCode, - prefs.email.clone().unwrap_or_default(), + prefs.email.unwrap_or_default(), Some(subject), body, ), @@ -518,7 +518,7 @@ pub async fn enqueue_passkey_recovery( user_id, prefs.channel, super::types::CommsType::PasskeyRecovery, - prefs.email.clone().unwrap_or_default(), + prefs.email.unwrap_or_default(), Some(subject), body, ), @@ -665,7 +665,7 @@ pub async fn queue_legacy_login_notification( user_id, channel, super::types::CommsType::LegacyLoginAlert, - prefs.email.clone().unwrap_or_default(), + prefs.email.unwrap_or_default(), Some(subject), body, ), diff --git a/src/delegation/db.rs b/src/delegation/db.rs index c47f55e..b519a58 100644 --- a/src/delegation/db.rs +++ b/src/delegation/db.rs @@ -1,3 +1,4 @@ +use crate::types::Handle; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -18,7 +19,7 @@ pub struct DelegationGrant { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DelegatedAccountInfo { pub did: String, - pub handle: String, + pub handle: Handle, pub granted_scopes: String, pub granted_at: DateTime, } @@ -26,7 +27,7 @@ pub struct DelegatedAccountInfo { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ControllerInfo { pub did: String, - pub handle: String, + pub handle: Handle, pub granted_scopes: String, pub granted_at: DateTime, pub is_active: bool, diff --git a/src/lib.rs b/src/lib.rs index eae2bb0..076513f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,10 +19,13 @@ pub mod scheduled; pub mod state; pub mod storage; pub mod sync; +pub mod types; pub mod util; pub mod validation; use api::proxy::XrpcProxyLayer; +pub use sync::util::AccountStatus; +pub use types::{AccountState, AtIdentifier, AtUri, Did, Handle, Nsid, Rkey}; use axum::{ Json, Router, extract::DefaultBodyLimit, @@ -33,7 +36,7 @@ use axum::{ use http::StatusCode; use serde_json::json; use state::AppState; -use tower::{Layer, ServiceBuilder}; +use tower::ServiceBuilder; use tower_http::cors::{Any, CorsLayer}; use tower_http::services::{ServeDir, ServeFile}; @@ -571,6 +574,7 @@ pub fn app(state: AppState) -> Router { let router = Router::new() .nest_service("/xrpc", xrpc_service) .nest("/oauth", oauth_router) + .nest("/.well-known", well_known_router) .route("/metrics", get(metrics::metrics_handler)) .route("/health", get(api::server::health)) .route("/robots.txt", get(api::server::robots_txt)) @@ -606,7 +610,7 @@ pub fn app(state: AppState) -> Router { let serve_dir = ServeDir::new(&frontend_dir).not_found_service(ServeFile::new(&index_path)); - router + return router .route_service("/", ServeFile::new(&homepage_file)) .nest("/app", spa_router) .fallback_service(serve_dir); diff --git a/src/main.rs b/src/main.rs index 094b96a..066de79 100644 --- a/src/main.rs +++ b/src/main.rs @@ -35,10 +35,12 @@ async fn run() -> Result<(), Box> { let backfill_db = state.db.clone(); let backfill_block_store = state.block_store.clone(); tokio::spawn(async move { - backfill_genesis_commit_blocks(&backfill_db, backfill_block_store.clone()).await; - backfill_repo_rev(&backfill_db, backfill_block_store.clone()).await; - backfill_user_blocks(&backfill_db, backfill_block_store.clone()).await; - backfill_record_blobs(&backfill_db, backfill_block_store).await; + tokio::join!( + backfill_genesis_commit_blocks(&backfill_db, backfill_block_store.clone()), + backfill_repo_rev(&backfill_db, backfill_block_store.clone()), + backfill_user_blocks(&backfill_db, backfill_block_store.clone()), + backfill_record_blobs(&backfill_db, backfill_block_store), + ); }); let mut comms_service = CommsService::new(state.db.clone()); diff --git a/src/oauth/db/device.rs b/src/oauth/db/device.rs index ff44d76..0022644 100644 --- a/src/oauth/db/device.rs +++ b/src/oauth/db/device.rs @@ -1,10 +1,11 @@ use super::super::{DeviceData, OAuthError}; +use crate::types::Handle; use chrono::{DateTime, Utc}; use sqlx::PgPool; pub struct DeviceAccountRow { pub did: String, - pub handle: String, + pub handle: Handle, pub email: Option, pub last_used_at: DateTime, } @@ -116,7 +117,7 @@ pub async fn get_device_accounts( .into_iter() .map(|r| DeviceAccountRow { did: r.did, - handle: r.handle, + handle: r.handle.into(), email: r.email, last_used_at: r.last_used_at, }) diff --git a/src/oauth/db/mod.rs b/src/oauth/db/mod.rs index 85cfcf2..ab6247d 100644 --- a/src/oauth/db/mod.rs +++ b/src/oauth/db/mod.rs @@ -16,18 +16,19 @@ pub use dpop::{check_and_record_dpop_jti, cleanup_expired_dpop_jtis}; pub use request::{ consume_authorization_request_by_code, create_authorization_request, delete_authorization_request, delete_expired_authorization_requests, get_authorization_request, - mark_request_authenticated, set_authorization_did, set_controller_did, set_request_did, - update_authorization_request, update_request_scope, + get_authorization_request_with_state, mark_request_authenticated, set_authorization_did, + set_controller_did, set_request_did, update_authorization_request, update_request_scope, }; pub use scope_preference::{ ScopePreference, delete_scope_preferences, get_scope_preferences, should_show_consent, upsert_scope_preferences, }; pub use token::{ - check_refresh_token_used, count_tokens_for_user, create_token, delete_oldest_tokens_for_user, - delete_token, delete_token_family, enforce_token_limit_for_user, get_token_by_id, - get_token_by_previous_refresh_token, get_token_by_refresh_token, list_tokens_for_user, - revoke_tokens_for_client, revoke_tokens_for_controller, rotate_token, + RefreshTokenLookup, check_refresh_token_used, count_tokens_for_user, create_token, + delete_oldest_tokens_for_user, delete_token, delete_token_family, enforce_token_limit_for_user, + get_token_by_id, get_token_by_previous_refresh_token, get_token_by_refresh_token, + list_tokens_for_user, lookup_refresh_token, revoke_tokens_for_client, + revoke_tokens_for_controller, rotate_token, }; pub use two_factor::{ TwoFactorChallenge, check_user_2fa_enabled, cleanup_expired_2fa_challenges, diff --git a/src/oauth/db/request.rs b/src/oauth/db/request.rs index 9f1d9df..7497593 100644 --- a/src/oauth/db/request.rs +++ b/src/oauth/db/request.rs @@ -1,7 +1,20 @@ -use super::super::{AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData}; +use super::super::{AuthFlowState, AuthorizationRequestParameters, ClientAuth, OAuthError, RequestData}; use super::helpers::{from_json, to_json}; use sqlx::PgPool; +pub async fn get_authorization_request_with_state( + pool: &PgPool, + request_id: &str, +) -> Result, OAuthError> { + match get_authorization_request(pool, request_id).await? { + Some(data) => { + let state = AuthFlowState::from_request_data(&data); + Ok(Some((data, state))) + } + None => Ok(None), + } +} + pub async fn create_authorization_request( pool: &PgPool, request_id: &str, diff --git a/src/oauth/db/token.rs b/src/oauth/db/token.rs index cce68d4..e2f565b 100644 --- a/src/oauth/db/token.rs +++ b/src/oauth/db/token.rs @@ -1,8 +1,54 @@ -use super::super::{OAuthError, TokenData}; +use super::super::{OAuthError, RefreshTokenState, TokenData}; use super::helpers::{from_json, to_json}; use chrono::{DateTime, Utc}; use sqlx::PgPool; +pub enum RefreshTokenLookup { + Valid { db_id: i32, token_data: TokenData }, + InGracePeriod { db_id: i32, token_data: TokenData, rotated_at: DateTime }, + Used { original_token_id: i32 }, + Expired { db_id: i32 }, + NotFound, +} + +impl RefreshTokenLookup { + pub fn state(&self) -> RefreshTokenState { + match self { + RefreshTokenLookup::Valid { .. } => RefreshTokenState::Valid, + RefreshTokenLookup::InGracePeriod { rotated_at, .. } => { + RefreshTokenState::InGracePeriod { rotated_at: *rotated_at } + } + RefreshTokenLookup::Used { .. } => RefreshTokenState::Used { at: Utc::now() }, + RefreshTokenLookup::Expired { .. } => RefreshTokenState::Expired, + RefreshTokenLookup::NotFound => RefreshTokenState::Revoked, + } + } +} + +pub async fn lookup_refresh_token( + pool: &PgPool, + refresh_token: &str, +) -> Result { + if let Some(token_id) = check_refresh_token_used(pool, refresh_token).await? { + if let Some((db_id, token_data)) = get_token_by_previous_refresh_token(pool, refresh_token).await? { + let rotated_at = token_data.updated_at; + return Ok(RefreshTokenLookup::InGracePeriod { db_id, token_data, rotated_at }); + } + return Ok(RefreshTokenLookup::Used { original_token_id: token_id }); + } + + match get_token_by_refresh_token(pool, refresh_token).await? { + Some((db_id, token_data)) => { + if token_data.expires_at < Utc::now() { + Ok(RefreshTokenLookup::Expired { db_id }) + } else { + Ok(RefreshTokenLookup::Valid { db_id, token_data }) + } + } + None => Ok(RefreshTokenLookup::NotFound), + } +} + pub async fn create_token(pool: &PgPool, data: &TokenData) -> Result { let client_auth_json = to_json(&data.client_auth)?; let parameters_json = to_json(&data.parameters)?; diff --git a/src/oauth/dpop.rs b/src/oauth/dpop.rs index e2d1be9..8b99d0a 100644 --- a/src/oauth/dpop.rs +++ b/src/oauth/dpop.rs @@ -5,14 +5,15 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use super::OAuthError; +use crate::types::{DPoPProofId, JwkThumbprint}; const DPOP_NONCE_VALIDITY_SECS: i64 = 300; const DPOP_MAX_AGE_SECS: i64 = 300; #[derive(Debug, Clone)] pub struct DPoPVerifyResult { - pub jkt: String, - pub jti: String, + pub jkt: JwkThumbprint, + pub jti: DPoPProofId, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -179,8 +180,8 @@ impl DPoPVerifier { )?; let jkt = compute_jwk_thumbprint(&header.jwk)?; Ok(DPoPVerifyResult { - jkt, - jti: payload.jti.clone(), + jkt: jkt.into(), + jti: payload.jti.clone().into(), }) } } diff --git a/src/oauth/endpoints/authorize.rs b/src/oauth/endpoints/authorize.rs index 6819a44..b8f5535 100644 --- a/src/oauth/endpoints/authorize.rs +++ b/src/oauth/endpoints/authorize.rs @@ -1,8 +1,10 @@ use crate::comms::{CommsChannel, channel_display_name, enqueue_2fa_code}; use crate::oauth::{ - Code, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, db, + AuthFlowState, Code, DeviceData, DeviceId, OAuthError, SessionId, client::ClientMetadataCache, + db, }; use crate::state::{AppState, RateLimitKind}; +use crate::types::{Handle, PlainPassword}; use axum::{ Json, extract::{Query, State}, @@ -31,6 +33,38 @@ fn redirect_to_frontend_error(error: &str, description: &str) -> Response { )) } +fn json_error(status: StatusCode, error: &str, description: &str) -> Response { + ( + status, + Json(serde_json::json!({ + "error": error, + "error_description": description + })), + ) + .into_response() +} + +fn validate_auth_flow_state( + flow_state: &AuthFlowState, + require_authenticated: bool, +) -> Option { + if flow_state.is_expired() { + return Some(json_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "Authorization request has expired", + )); + } + if require_authenticated && flow_state.is_pending() { + return Some(json_error( + StatusCode::FORBIDDEN, + "access_denied", + "Not authenticated", + )); + } + None +} + fn extract_device_cookie(headers: &HeaderMap) -> Option { headers .get("cookie") @@ -97,7 +131,7 @@ pub struct AuthorizeResponse { pub struct AuthorizeSubmit { pub request_uri: String, pub username: String, - pub password: String, + pub password: PlainPassword, #[serde(default)] pub remember_device: bool, } @@ -298,7 +332,7 @@ pub async fn authorize_get_json( #[derive(Debug, Serialize)] pub struct AccountInfo { pub did: String, - pub handle: String, + pub handle: Handle, #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, } @@ -1155,53 +1189,33 @@ pub async fn consent_get( State(state): State, Query(query): Query, ) -> Response { - let request_data = match db::get_authorization_request(&state.db, &query.request_uri).await { - Ok(Some(data)) => data, - Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_request", - "error_description": "Invalid or expired request_uri" - })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "server_error", - "error_description": format!("Database error: {:?}", e) - })), - ) - .into_response(); + let (request_data, flow_state) = + match db::get_authorization_request_with_state(&state.db, &query.request_uri).await { + Ok(Some(result)) => result, + Ok(None) => { + return json_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "Invalid or expired request_uri", + ); + } + Err(e) => { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + &format!("Database error: {:?}", e), + ); + } + }; + + if let Some(err_response) = validate_auth_flow_state(&flow_state, true) { + if flow_state.is_expired() { + let _ = db::delete_authorization_request(&state.db, &query.request_uri).await; } - }; - if request_data.expires_at < Utc::now() { - let _ = db::delete_authorization_request(&state.db, &query.request_uri).await; - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_request", - "error_description": "Authorization request has expired" - })), - ) - .into_response(); + return err_response; } - let did = match &request_data.did { - Some(d) => d.clone(), - None => { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "access_denied", - "error_description": "Not authenticated" - })), - ) - .into_response(); - } - }; + + let did = flow_state.did().unwrap().to_string(); let client_cache = ClientMetadataCache::new(3600); let client_metadata = client_cache .get(&request_data.parameters.client_id) @@ -1334,53 +1348,33 @@ pub async fn consent_post( form.approved_scopes, form.remember ); - let request_data = match db::get_authorization_request(&state.db, &form.request_uri).await { - Ok(Some(data)) => data, - Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_request", - "error_description": "Invalid or expired request_uri" - })), - ) - .into_response(); - } - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({ - "error": "server_error", - "error_description": format!("Database error: {:?}", e) - })), - ) - .into_response(); + let (request_data, flow_state) = + match db::get_authorization_request_with_state(&state.db, &form.request_uri).await { + Ok(Some(result)) => result, + Ok(None) => { + return json_error( + StatusCode::BAD_REQUEST, + "invalid_request", + "Invalid or expired request_uri", + ); + } + Err(e) => { + return json_error( + StatusCode::INTERNAL_SERVER_ERROR, + "server_error", + &format!("Database error: {:?}", e), + ); + } + }; + + if let Some(err_response) = validate_auth_flow_state(&flow_state, true) { + if flow_state.is_expired() { + let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; } - }; - if request_data.expires_at < Utc::now() { - let _ = db::delete_authorization_request(&state.db, &form.request_uri).await; - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_request", - "error_description": "Authorization request has expired" - })), - ) - .into_response(); + return err_response; } - let did = match &request_data.did { - Some(d) => d.clone(), - None => { - return ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "access_denied", - "error_description": "Not authenticated" - })), - ) - .into_response(); - } - }; + + let did = flow_state.did().unwrap().to_string(); let original_scope_str = request_data .parameters .scope diff --git a/src/oauth/endpoints/delegation.rs b/src/oauth/endpoints/delegation.rs index b73bbcb..b3c7949 100644 --- a/src/oauth/endpoints/delegation.rs +++ b/src/oauth/endpoints/delegation.rs @@ -1,6 +1,7 @@ use crate::delegation; use crate::oauth::db; use crate::state::{AppState, RateLimitKind}; +use crate::types::PlainPassword; use crate::util::extract_client_ip; use axum::{ Json, @@ -15,7 +16,7 @@ pub struct DelegationAuthSubmit { pub request_uri: String, pub delegated_did: Option, pub controller_did: String, - pub password: String, + pub password: PlainPassword, #[serde(default)] pub remember_device: bool, } diff --git a/src/oauth/endpoints/token/grants.rs b/src/oauth/endpoints/token/grants.rs index a338454..59e45fb 100644 --- a/src/oauth/endpoints/token/grants.rs +++ b/src/oauth/endpoints/token/grants.rs @@ -1,11 +1,11 @@ use super::helpers::{create_access_token_with_delegation, verify_pkce}; -use super::types::{TokenRequest, TokenResponse}; +use super::types::{TokenGrant, TokenResponse, ValidatedTokenRequest}; use crate::config::AuthConfig; use crate::delegation; use crate::oauth::{ - ClientAuth, OAuthError, RefreshToken, TokenData, TokenId, + AuthFlowState, ClientAuth, OAuthError, RefreshToken, TokenData, TokenId, client::{ClientMetadataCache, verify_client_auth}, - db, + db::{self, RefreshTokenLookup}, dpop::DPoPVerifier, }; use crate::state::AppState; @@ -20,35 +20,41 @@ const REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC: i64 = 14; pub async fn handle_authorization_code_grant( state: AppState, _headers: HeaderMap, - request: TokenRequest, + request: ValidatedTokenRequest, dpop_proof: Option, ) -> Result<(HeaderMap, Json), OAuthError> { - let code = request - .code - .ok_or_else(|| OAuthError::InvalidRequest("code is required".to_string()))?; - let code_verifier = request - .code_verifier - .ok_or_else(|| OAuthError::InvalidRequest("code_verifier is required".to_string()))?; + let (code, code_verifier, redirect_uri) = match request.grant { + TokenGrant::AuthorizationCode { code, code_verifier, redirect_uri } => { + (code, code_verifier, redirect_uri) + } + _ => return Err(OAuthError::InvalidRequest("Expected authorization_code grant".to_string())), + }; let auth_request = db::consume_authorization_request_by_code(&state.db, &code) .await? .ok_or_else(|| OAuthError::InvalidGrant("Invalid or expired code".to_string()))?; - if auth_request.expires_at < Utc::now() { + + let flow_state = AuthFlowState::from_request_data(&auth_request); + if flow_state.is_expired() { return Err(OAuthError::InvalidGrant( "Authorization code has expired".to_string(), )); } - if let Some(request_client_id) = &request.client_id + if !flow_state.can_exchange() { + return Err(OAuthError::InvalidGrant( + "Authorization not completed".to_string(), + )); + } + + if let Some(request_client_id) = &request.client_auth.client_id && request_client_id != &auth_request.client_id { return Err(OAuthError::InvalidGrant("client_id mismatch".to_string())); } - let did = auth_request - .did - .ok_or_else(|| OAuthError::InvalidGrant("Authorization not completed".to_string()))?; + let did = flow_state.did().unwrap().to_string(); let client_metadata_cache = ClientMetadataCache::new(3600); let client_metadata = client_metadata_cache.get(&auth_request.client_id).await?; let client_auth = if let (Some(assertion), Some(assertion_type)) = - (&request.client_assertion, &request.client_assertion_type) + (&request.client_auth.client_assertion, &request.client_auth.client_assertion_type) { if assertion_type != "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" { return Err(OAuthError::InvalidClient( @@ -58,7 +64,7 @@ pub async fn handle_authorization_code_grant( ClientAuth::PrivateKeyJwt { client_assertion: assertion.clone(), } - } else if let Some(secret) = &request.client_secret { + } else if let Some(secret) = &request.client_auth.client_secret { ClientAuth::SecretPost { client_secret: secret.clone(), } @@ -67,8 +73,8 @@ pub async fn handle_authorization_code_grant( }; verify_client_auth(&client_metadata_cache, &client_metadata, &client_auth).await?; verify_pkce(&auth_request.parameters.code_challenge, &code_verifier)?; - if let Some(redirect_uri) = &request.redirect_uri - && redirect_uri != &auth_request.parameters.redirect_uri + if let Some(req_redirect_uri) = &redirect_uri + && req_redirect_uri != &auth_request.parameters.redirect_uri { return Err(OAuthError::InvalidGrant( "redirect_uri mismatch".to_string(), @@ -87,13 +93,13 @@ pub async fn handle_authorization_code_grant( )); } if let Some(expected_jkt) = &auth_request.parameters.dpop_jkt - && &result.jkt != expected_jkt + && result.jkt.as_str() != expected_jkt { return Err(OAuthError::InvalidDpopProof( "DPoP key binding mismatch".to_string(), )); } - Some(result.jkt) + Some(result.jkt.as_str().to_string()) } else if auth_request.parameters.dpop_jkt.is_some() || client_metadata.requires_dpop() { return Err(OAuthError::UseDpopNonce( crate::oauth::dpop::DPoPVerifier::new(AuthConfig::get().dpop_secret().as_bytes()) @@ -187,23 +193,30 @@ pub async fn handle_authorization_code_grant( pub async fn handle_refresh_token_grant( state: AppState, _headers: HeaderMap, - request: TokenRequest, + request: ValidatedTokenRequest, dpop_proof: Option, ) -> Result<(HeaderMap, Json), OAuthError> { - let refresh_token_str = request - .refresh_token - .ok_or_else(|| OAuthError::InvalidRequest("refresh_token is required".to_string()))?; + let refresh_token_str = match request.grant { + TokenGrant::RefreshToken { refresh_token } => refresh_token, + _ => return Err(OAuthError::InvalidRequest("Expected refresh_token grant".to_string())), + }; + let token_prefix = &refresh_token_str[..std::cmp::min(16, refresh_token_str.len())]; tracing::info!( - refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())], + refresh_token_prefix = %token_prefix, has_dpop = dpop_proof.is_some(), "Refresh token grant requested" ); - if let Some(token_id) = db::check_refresh_token_used(&state.db, &refresh_token_str).await? { - if let Some((_db_id, token_data)) = - db::get_token_by_previous_refresh_token(&state.db, &refresh_token_str).await? - { + + let lookup = db::lookup_refresh_token(&state.db, &refresh_token_str).await?; + let token_state = lookup.state(); + tracing::debug!(state = %token_state, "Refresh token state"); + + let (db_id, token_data) = match lookup { + RefreshTokenLookup::Valid { db_id, token_data } => (db_id, token_data), + RefreshTokenLookup::InGracePeriod { db_id: _, token_data, rotated_at } => { tracing::info!( - refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())], + refresh_token_prefix = %token_prefix, + rotated_at = %rotated_at, "Refresh token reuse within grace period, returning existing tokens" ); let dpop_jkt = token_data.parameters.dpop_jkt.as_deref(); @@ -230,35 +243,28 @@ pub async fn handle_refresh_token_grant( }), )); } - tracing::warn!( - refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())], - "Refresh token reuse detected, revoking token family" - ); - db::delete_token_family(&state.db, token_id).await?; - return Err(OAuthError::InvalidGrant( - "Refresh token reuse detected, token family revoked".to_string(), - )); - } - let (db_id, token_data) = db::get_token_by_refresh_token(&state.db, &refresh_token_str) - .await? - .ok_or_else(|| { + RefreshTokenLookup::Used { original_token_id } => { tracing::warn!( - refresh_token_prefix = %&refresh_token_str[..std::cmp::min(16, refresh_token_str.len())], - "Refresh token not found in database" + refresh_token_prefix = %token_prefix, + "Refresh token reuse detected, revoking token family" ); - OAuthError::InvalidGrant("Invalid refresh token".to_string()) - })?; - if token_data.expires_at < Utc::now() { - tracing::warn!( - did = %token_data.did, - expired_at = %token_data.expires_at, - "Refresh token has expired" - ); - db::delete_token_family(&state.db, db_id).await?; - return Err(OAuthError::InvalidGrant( - "Refresh token has expired".to_string(), - )); - } + db::delete_token_family(&state.db, original_token_id).await?; + return Err(OAuthError::InvalidGrant( + "Refresh token reuse detected, token family revoked".to_string(), + )); + } + RefreshTokenLookup::Expired { db_id } => { + tracing::warn!(refresh_token_prefix = %token_prefix, "Refresh token has expired"); + db::delete_token_family(&state.db, db_id).await?; + return Err(OAuthError::InvalidGrant( + "Refresh token has expired".to_string(), + )); + } + RefreshTokenLookup::NotFound => { + tracing::warn!(refresh_token_prefix = %token_prefix, "Refresh token not found"); + return Err(OAuthError::InvalidGrant("Invalid refresh token".to_string())); + } + }; let dpop_jkt = if let Some(proof) = &dpop_proof { let config = AuthConfig::get(); let verifier = DPoPVerifier::new(config.dpop_secret().as_bytes()); @@ -272,13 +278,13 @@ pub async fn handle_refresh_token_grant( )); } if let Some(expected_jkt) = &token_data.parameters.dpop_jkt - && &result.jkt != expected_jkt + && result.jkt.as_str() != expected_jkt { return Err(OAuthError::InvalidDpopProof( "DPoP key binding mismatch".to_string(), )); } - Some(result.jkt) + Some(result.jkt.as_str().to_string()) } else if token_data.parameters.dpop_jkt.is_some() { return Err(OAuthError::InvalidRequest( "DPoP proof required".to_string(), diff --git a/src/oauth/endpoints/token/mod.rs b/src/oauth/endpoints/token/mod.rs index 51182f1..fc621c3 100644 --- a/src/oauth/endpoints/token/mod.rs +++ b/src/oauth/endpoints/token/mod.rs @@ -13,7 +13,7 @@ pub use helpers::{TokenClaims, create_access_token, extract_token_claims, verify pub use introspect::{ IntrospectRequest, IntrospectResponse, RevokeRequest, introspect_token, revoke_token, }; -pub use types::{TokenRequest, TokenResponse}; +pub use types::{ClientAuthParams, GrantType, TokenGrant, TokenRequest, TokenResponse, ValidatedTokenRequest}; fn extract_client_ip(headers: &HeaderMap) -> String { if let Some(forwarded) = headers.get("x-forwarded-for") @@ -65,14 +65,13 @@ pub async fn token_endpoint( .get("DPoP") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); - match request.grant_type.as_str() { - "authorization_code" => { - handle_authorization_code_grant(state, headers, request, dpop_proof).await + let validated = request.validate()?; + match validated.grant { + TokenGrant::AuthorizationCode { .. } => { + handle_authorization_code_grant(state, headers, validated, dpop_proof).await + } + TokenGrant::RefreshToken { .. } => { + handle_refresh_token_grant(state, headers, validated, dpop_proof).await } - "refresh_token" => handle_refresh_token_grant(state, headers, request, dpop_proof).await, - _ => Err(OAuthError::UnsupportedGrantType(format!( - "Unsupported grant_type: {}", - request.grant_type - ))), } } diff --git a/src/oauth/endpoints/token/types.rs b/src/oauth/endpoints/token/types.rs index f595a2b..5e50129 100644 --- a/src/oauth/endpoints/token/types.rs +++ b/src/oauth/endpoints/token/types.rs @@ -1,8 +1,57 @@ +use crate::oauth::OAuthError; use serde::{Deserialize, Serialize}; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GrantType { + AuthorizationCode, + RefreshToken, + Unsupported(String), +} + +impl GrantType { + pub fn as_str(&self) -> &str { + match self { + Self::AuthorizationCode => "authorization_code", + Self::RefreshToken => "refresh_token", + Self::Unsupported(s) => s, + } + } +} + +impl std::str::FromStr for GrantType { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + Ok(match s { + "authorization_code" => Self::AuthorizationCode, + "refresh_token" => Self::RefreshToken, + other => Self::Unsupported(other.to_string()), + }) + } +} + +impl<'de> Deserialize<'de> for GrantType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(s.parse().unwrap()) + } +} + +impl Serialize for GrantType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + #[derive(Debug, Deserialize)] pub struct TokenRequest { - pub grant_type: String, + pub grant_type: GrantType, #[serde(default)] pub code: Option, #[serde(default)] @@ -21,6 +70,70 @@ pub struct TokenRequest { pub client_assertion_type: Option, } +#[derive(Debug, Clone)] +pub enum TokenGrant { + AuthorizationCode { + code: String, + code_verifier: String, + redirect_uri: Option, + }, + RefreshToken { + refresh_token: String, + }, +} + +#[derive(Debug, Clone, Default)] +pub struct ClientAuthParams { + pub client_id: Option, + pub client_secret: Option, + pub client_assertion: Option, + pub client_assertion_type: Option, +} + +#[derive(Debug, Clone)] +pub struct ValidatedTokenRequest { + pub grant: TokenGrant, + pub client_auth: ClientAuthParams, +} + +impl TokenRequest { + pub fn validate(self) -> Result { + let grant = match self.grant_type { + GrantType::AuthorizationCode => { + let code = self.code.ok_or_else(|| { + OAuthError::InvalidRequest("code is required for authorization_code grant".to_string()) + })?; + let code_verifier = self.code_verifier.ok_or_else(|| { + OAuthError::InvalidRequest("code_verifier is required for authorization_code grant".to_string()) + })?; + TokenGrant::AuthorizationCode { + code, + code_verifier, + redirect_uri: self.redirect_uri, + } + } + GrantType::RefreshToken => { + let refresh_token = self.refresh_token.ok_or_else(|| { + OAuthError::InvalidRequest("refresh_token is required for refresh_token grant".to_string()) + })?; + TokenGrant::RefreshToken { refresh_token } + } + GrantType::Unsupported(grant_type) => { + return Err(OAuthError::UnsupportedGrantType(grant_type)); + } + }; + + let client_auth = ClientAuthParams { + client_id: self.client_id, + client_secret: self.client_secret, + client_assertion: self.client_assertion, + client_assertion_type: self.client_assertion_type, + }; + + Ok(ValidatedTokenRequest { grant, client_auth }) + } +} + #[derive(Debug, Serialize)] pub struct TokenResponse { pub access_token: String, diff --git a/src/oauth/scopes/parser.rs b/src/oauth/scopes/parser.rs index ba71e8f..b3fbc31 100644 --- a/src/oauth/scopes/parser.rs +++ b/src/oauth/scopes/parser.rs @@ -140,16 +140,13 @@ impl AccountAction { } fn parse_query_params(query: &str) -> HashMap> { - let mut params: HashMap> = HashMap::new(); - for part in query.split('&') { - if let Some((key, value)) = part.split_once('=') { - params - .entry(key.to_string()) - .or_default() - .push(value.to_string()); - } - } - params + query + .split('&') + .filter_map(|part| part.split_once('=')) + .fold(HashMap::new(), |mut acc, (key, value)| { + acc.entry(key.to_string()).or_default().push(value.to_string()); + acc + }) } pub fn parse_scope(scope: &str) -> ParsedScope { diff --git a/src/oauth/types.rs b/src/oauth/types.rs index 4b4a35a..dc444a1 100644 --- a/src/oauth/types.rs +++ b/src/oauth/types.rs @@ -245,3 +245,282 @@ pub struct JwkPublicKey { pub struct Jwks { pub keys: Vec, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthFlowState { + Pending, + Authenticated { did: String, device_id: Option }, + Authorized { did: String, device_id: Option, code: String }, + Expired, +} + +impl AuthFlowState { + pub fn from_request_data(data: &RequestData) -> Self { + if data.expires_at < chrono::Utc::now() { + return AuthFlowState::Expired; + } + match (&data.did, &data.code) { + (Some(did), Some(code)) => AuthFlowState::Authorized { + did: did.clone(), + device_id: data.device_id.clone(), + code: code.clone(), + }, + (Some(did), None) => AuthFlowState::Authenticated { + did: did.clone(), + device_id: data.device_id.clone(), + }, + (None, _) => AuthFlowState::Pending, + } + } + + pub fn is_pending(&self) -> bool { + matches!(self, AuthFlowState::Pending) + } + + pub fn is_authenticated(&self) -> bool { + matches!(self, AuthFlowState::Authenticated { .. }) + } + + pub fn is_authorized(&self) -> bool { + matches!(self, AuthFlowState::Authorized { .. }) + } + + pub fn is_expired(&self) -> bool { + matches!(self, AuthFlowState::Expired) + } + + pub fn can_authenticate(&self) -> bool { + matches!(self, AuthFlowState::Pending) + } + + pub fn can_authorize(&self) -> bool { + matches!(self, AuthFlowState::Authenticated { .. }) + } + + pub fn can_exchange(&self) -> bool { + matches!(self, AuthFlowState::Authorized { .. }) + } + + pub fn did(&self) -> Option<&str> { + match self { + AuthFlowState::Authenticated { did, .. } | AuthFlowState::Authorized { did, .. } => { + Some(did) + } + _ => None, + } + } + + pub fn code(&self) -> Option<&str> { + match self { + AuthFlowState::Authorized { code, .. } => Some(code), + _ => None, + } + } +} + +impl std::fmt::Display for AuthFlowState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuthFlowState::Pending => write!(f, "pending"), + AuthFlowState::Authenticated { did, .. } => write!(f, "authenticated ({})", did), + AuthFlowState::Authorized { did, code, .. } => { + write!(f, "authorized ({}, code={}...)", did, &code[..8.min(code.len())]) + } + AuthFlowState::Expired => write!(f, "expired"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RefreshTokenState { + Valid, + Used { at: chrono::DateTime }, + InGracePeriod { rotated_at: chrono::DateTime }, + Expired, + Revoked, +} + +impl RefreshTokenState { + pub fn is_valid(&self) -> bool { + matches!(self, RefreshTokenState::Valid) + } + + pub fn is_usable(&self) -> bool { + matches!( + self, + RefreshTokenState::Valid | RefreshTokenState::InGracePeriod { .. } + ) + } + + pub fn is_used(&self) -> bool { + matches!(self, RefreshTokenState::Used { .. }) + } + + pub fn is_in_grace_period(&self) -> bool { + matches!(self, RefreshTokenState::InGracePeriod { .. }) + } + + pub fn is_expired(&self) -> bool { + matches!(self, RefreshTokenState::Expired) + } + + pub fn is_revoked(&self) -> bool { + matches!(self, RefreshTokenState::Revoked) + } +} + +impl std::fmt::Display for RefreshTokenState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RefreshTokenState::Valid => write!(f, "valid"), + RefreshTokenState::Used { at } => write!(f, "used ({})", at), + RefreshTokenState::InGracePeriod { rotated_at } => { + write!(f, "grace period (rotated {})", rotated_at) + } + RefreshTokenState::Expired => write!(f, "expired"), + RefreshTokenState::Revoked => write!(f, "revoked"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::{Duration, Utc}; + + fn make_request_data( + did: Option, + code: Option, + expires_in: Duration, + ) -> RequestData { + RequestData { + client_id: "test-client".into(), + client_auth: None, + parameters: AuthorizationRequestParameters { + response_type: "code".into(), + client_id: "test-client".into(), + redirect_uri: "https://example.com/callback".into(), + scope: Some("atproto".into()), + state: None, + code_challenge: "test".into(), + code_challenge_method: "S256".into(), + response_mode: None, + login_hint: None, + dpop_jkt: None, + extra: None, + }, + expires_at: Utc::now() + expires_in, + did, + device_id: None, + code, + controller_did: None, + } + } + + #[test] + fn test_auth_flow_state_pending() { + let data = make_request_data(None, None, Duration::minutes(5)); + let state = AuthFlowState::from_request_data(&data); + assert!(state.is_pending()); + assert!(!state.is_authenticated()); + assert!(!state.is_authorized()); + assert!(!state.is_expired()); + assert!(state.can_authenticate()); + assert!(!state.can_authorize()); + assert!(!state.can_exchange()); + assert!(state.did().is_none()); + assert!(state.code().is_none()); + } + + #[test] + fn test_auth_flow_state_authenticated() { + let data = make_request_data(Some("did:plc:test".into()), None, Duration::minutes(5)); + let state = AuthFlowState::from_request_data(&data); + assert!(!state.is_pending()); + assert!(state.is_authenticated()); + assert!(!state.is_authorized()); + assert!(!state.is_expired()); + assert!(!state.can_authenticate()); + assert!(state.can_authorize()); + assert!(!state.can_exchange()); + assert_eq!(state.did(), Some("did:plc:test")); + assert!(state.code().is_none()); + } + + #[test] + fn test_auth_flow_state_authorized() { + let data = make_request_data( + Some("did:plc:test".into()), + Some("auth-code-123".into()), + Duration::minutes(5), + ); + let state = AuthFlowState::from_request_data(&data); + assert!(!state.is_pending()); + assert!(!state.is_authenticated()); + assert!(state.is_authorized()); + assert!(!state.is_expired()); + assert!(!state.can_authenticate()); + assert!(!state.can_authorize()); + assert!(state.can_exchange()); + assert_eq!(state.did(), Some("did:plc:test")); + assert_eq!(state.code(), Some("auth-code-123")); + } + + #[test] + fn test_auth_flow_state_expired() { + let data = make_request_data( + Some("did:plc:test".into()), + Some("code".into()), + Duration::minutes(-1), + ); + let state = AuthFlowState::from_request_data(&data); + assert!(state.is_expired()); + assert!(!state.can_authenticate()); + assert!(!state.can_authorize()); + assert!(!state.can_exchange()); + } + + #[test] + fn test_refresh_token_state_valid() { + let state = RefreshTokenState::Valid; + assert!(state.is_valid()); + assert!(state.is_usable()); + assert!(!state.is_used()); + assert!(!state.is_in_grace_period()); + assert!(!state.is_expired()); + assert!(!state.is_revoked()); + } + + #[test] + fn test_refresh_token_state_grace_period() { + let state = RefreshTokenState::InGracePeriod { + rotated_at: Utc::now(), + }; + assert!(!state.is_valid()); + assert!(state.is_usable()); + assert!(!state.is_used()); + assert!(state.is_in_grace_period()); + } + + #[test] + fn test_refresh_token_state_used() { + let state = RefreshTokenState::Used { at: Utc::now() }; + assert!(!state.is_valid()); + assert!(!state.is_usable()); + assert!(state.is_used()); + } + + #[test] + fn test_refresh_token_state_expired() { + let state = RefreshTokenState::Expired; + assert!(!state.is_usable()); + assert!(state.is_expired()); + } + + #[test] + fn test_refresh_token_state_revoked() { + let state = RefreshTokenState::Revoked; + assert!(!state.is_usable()); + assert!(state.is_revoked()); + } +} diff --git a/src/oauth/verify.rs b/src/oauth/verify.rs index 21fd7ed..518be34 100644 --- a/src/oauth/verify.rs +++ b/src/oauth/verify.rs @@ -79,7 +79,7 @@ pub async fn verify_oauth_access_token( "DPoP proof has already been used".to_string(), )); } - if &result.jkt != expected_jkt { + if result.jkt.as_str() != expected_jkt { return Err(OAuthError::InvalidDpopProof( "DPoP key binding mismatch".to_string(), )); @@ -374,7 +374,7 @@ struct LegacyAuthResult { async fn try_legacy_auth(pool: &PgPool, token: &str) -> Result { match crate::auth::validate_bearer_token(pool, token).await { - Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did }), + Ok(user) if !user.is_oauth => Ok(LegacyAuthResult { did: user.did.to_string() }), _ => Err(()), } } diff --git a/src/scheduled.rs b/src/scheduled.rs index 8627738..b0d8151 100644 --- a/src/scheduled.rs +++ b/src/scheduled.rs @@ -431,7 +431,7 @@ pub async fn start_scheduled_tasks( } } _ = ticker.tick() => { - if let Err(e) = process_scheduled_deletions(&db, &blob_store).await { + if let Err(e) = process_scheduled_deletions(&db, blob_store.as_ref()).await { error!("Error processing scheduled deletions: {}", e); } } @@ -441,7 +441,7 @@ pub async fn start_scheduled_tasks( async fn process_scheduled_deletions( db: &PgPool, - blob_store: &Arc, + blob_store: &dyn BlobStorage, ) -> Result<(), String> { let accounts_to_delete = sqlx::query!( r#" @@ -489,7 +489,7 @@ async fn process_scheduled_deletions( async fn delete_account_data( db: &PgPool, - blob_store: &Arc, + blob_store: &dyn BlobStorage, did: &str, _handle: &str, ) -> Result<(), String> { diff --git a/src/sync/blob.rs b/src/sync/blob.rs index 0043d71..2bb6e0d 100644 --- a/src/sync/blob.rs +++ b/src/sync/blob.rs @@ -1,3 +1,4 @@ +use crate::api::error::ApiError; use crate::state::AppState; use crate::sync::util::assert_repo_availability; use axum::{ @@ -9,7 +10,6 @@ use axum::{ response::{IntoResponse, Response}, }; use serde::{Deserialize, Serialize}; -use serde_json::json; use tracing::error; #[derive(Deserialize)] @@ -25,18 +25,10 @@ pub async fn get_blob( let did = params.did.trim(); let cid = params.cid.trim(); if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("did is required".into()).into_response(); } if cid.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "cid is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("cid is required".into()).into_response(); } let _account = match assert_repo_availability(&state.db, did, false).await { @@ -66,26 +58,14 @@ pub async fn get_blob( .unwrap(), Err(e) => { error!("Failed to fetch blob from storage: {:?}", e); - ( - StatusCode::NOT_FOUND, - Json(json!({"error": "BlobNotFound", "message": "Blob not found in storage"})), - ) - .into_response() + ApiError::BlobNotFound(Some("Blob not found in storage".into())).into_response() } } } - Ok(None) => ( - StatusCode::NOT_FOUND, - Json(json!({"error": "BlobNotFound", "message": "Blob not found"})), - ) - .into_response(), + Ok(None) => ApiError::BlobNotFound(Some("Blob not found".into())).into_response(), Err(e) => { error!("DB error in get_blob: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(Some("Database error".into())).into_response() } } } @@ -111,11 +91,7 @@ pub async fn list_blobs( ) -> Response { let did = params.did.trim(); if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("did is required".into()).into_response(); } let account = match assert_repo_availability(&state.db, did, false).await { @@ -178,11 +154,7 @@ pub async fn list_blobs( } Err(e) => { error!("DB error in list_blobs: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(Some("Database error".into())).into_response() } } } diff --git a/src/sync/commit.rs b/src/sync/commit.rs index 68dab6e..0c41613 100644 --- a/src/sync/commit.rs +++ b/src/sync/commit.rs @@ -1,3 +1,4 @@ +use crate::api::error::ApiError; use crate::state::AppState; use crate::sync::util::{AccountStatus, assert_repo_availability, get_account_with_status}; use axum::{ @@ -10,7 +11,6 @@ use cid::Cid; use jacquard_repo::commit::Commit; use jacquard_repo::storage::BlockStore; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::str::FromStr; use tracing::error; @@ -38,11 +38,7 @@ pub async fn get_latest_commit( ) -> Response { let did = params.did.trim(); if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("did is required".into()).into_response(); } let account = match assert_repo_availability(&state.db, did, false).await { @@ -50,30 +46,16 @@ pub async fn get_latest_commit( Err(e) => return e.into_response(), }; - let repo_root_cid = match account.repo_root_cid { - Some(cid) => cid, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "RepoNotFound", "message": "Repo not initialized"})), - ) - .into_response(); - } + let Some(repo_root_cid) = account.repo_root_cid else { + return ApiError::RepoNotFound(Some("Repo not initialized".into())).into_response(); }; - let rev = match get_rev_from_commit(&state, &repo_root_cid).await { - Some(r) => r, - None => { - error!( - "Failed to parse commit for DID {}: CID {}", - did, repo_root_cid - ); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to read repo commit"})), - ) - .into_response(); - } + let Some(rev) = get_rev_from_commit(&state, &repo_root_cid).await else { + error!( + "Failed to parse commit for DID {}: CID {}", + did, repo_root_cid + ); + return ApiError::InternalError(Some("Failed to read repo commit".into())).into_response(); }; ( @@ -181,11 +163,7 @@ pub async fn list_repos( } Err(e) => { error!("DB error in list_repos: {:?}", e); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response() + ApiError::InternalError(Some("Database error".into())).into_response() } } } @@ -211,29 +189,18 @@ pub async fn get_repo_status( ) -> Response { let did = params.did.trim(); if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("did is required".into()).into_response(); } let account = match get_account_with_status(&state.db, did).await { Ok(Some(a)) => a, Ok(None) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "RepoNotFound", "message": format!("Could not find repo for DID: {}", did)})), - ) + return ApiError::RepoNotFound(Some(format!("Could not find repo for DID: {}", did))) .into_response() } Err(e) => { error!("DB error in get_repo_status: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(Some("Database error".into())).into_response(); } }; diff --git a/src/sync/crawl.rs b/src/sync/crawl.rs index 8d74867..3bc239d 100644 --- a/src/sync/crawl.rs +++ b/src/sync/crawl.rs @@ -1,12 +1,11 @@ +use crate::api::EmptyResponse; use crate::state::AppState; use axum::{ Json, extract::{Query, State}, - http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; -use serde_json::json; use tracing::info; #[derive(Deserialize)] @@ -19,7 +18,7 @@ pub async fn notify_of_update( Query(params): Query, ) -> Response { info!("Received notifyOfUpdate from hostname: {}", params.hostname); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } #[derive(Deserialize)] @@ -32,5 +31,5 @@ pub async fn request_crawl( Json(input): Json, ) -> Response { info!("Received requestCrawl for hostname: {}", input.hostname); - (StatusCode::OK, Json(json!({}))).into_response() + EmptyResponse::ok().into_response() } diff --git a/src/sync/deprecated.rs b/src/sync/deprecated.rs index 2aad9a8..fcb82d6 100644 --- a/src/sync/deprecated.rs +++ b/src/sync/deprecated.rs @@ -1,3 +1,4 @@ +use crate::api::error::ApiError; use crate::auth::{extract_bearer_token_from_header, validate_bearer_token_allow_takendown}; use crate::state::AppState; use crate::sync::car::encode_car_header; @@ -12,7 +13,6 @@ use cid::Cid; use ipld_core::ipld::Ipld; use jacquard_repo::storage::BlockStore; use serde::{Deserialize, Serialize}; -use serde_json::json; use std::io::Write; use std::str::FromStr; @@ -48,11 +48,7 @@ pub async fn get_head( ) -> Response { let did = params.did.trim(); if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("did is required".into()).into_response(); } let is_admin_or_self = check_admin_or_self(&state, &headers, did).await; let account = match assert_repo_availability(&state.db, did, is_admin_or_self).await { @@ -61,11 +57,10 @@ pub async fn get_head( }; match account.repo_root_cid { Some(root) => (StatusCode::OK, Json(GetHeadOutput { root })).into_response(), - None => ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "HeadNotFound", "message": format!("Could not find root for DID: {}", did)})), - ) - .into_response(), + None => { + ApiError::RepoNotFound(Some(format!("Could not find root for DID: {}", did))) + .into_response() + } } } @@ -81,46 +76,21 @@ pub async fn get_checkout( ) -> Response { let did = params.did.trim(); if did.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "did is required"})), - ) - .into_response(); + return ApiError::InvalidRequest("did is required".into()).into_response(); } let is_admin_or_self = check_admin_or_self(&state, &headers, did).await; let account = match assert_repo_availability(&state.db, did, is_admin_or_self).await { Ok(a) => a, Err(e) => return e.into_response(), }; - let head_str = match account.repo_root_cid { - Some(r) => r, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "RepoNotFound", "message": "Repo not initialized"})), - ) - .into_response(); - } + let Some(head_str) = account.repo_root_cid else { + return ApiError::RepoNotFound(Some("Repo not initialized".into())).into_response(); }; - let head_cid = match Cid::from_str(&head_str) { - Ok(c) => c, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Invalid head CID"})), - ) - .into_response(); - } + let Ok(head_cid) = Cid::from_str(&head_str) else { + return ApiError::InternalError(Some("Invalid head CID".into())).into_response(); }; - let mut car_bytes = match encode_car_header(&head_cid) { - Ok(h) => h, - Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Failed to encode CAR header: {}", e)})), - ) - .into_response(); - } + let Ok(mut car_bytes) = encode_car_header(&head_cid) else { + return ApiError::InternalError(Some("Failed to encode CAR header".into())).into_response(); }; let mut stack = vec![head_cid]; let mut visited = std::collections::HashSet::new(); diff --git a/src/sync/frame.rs b/src/sync/frame.rs index 18cf15f..712c436 100644 --- a/src/sync/frame.rs +++ b/src/sync/frame.rs @@ -93,20 +93,68 @@ pub struct ErrorFrameBody { pub message: Option, } +#[derive(Debug, Clone)] +pub enum CommitFrameError { + InvalidCommitCid(String), + InvalidBlobCid(String), +} + +impl std::fmt::Display for CommitFrameError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidCommitCid(s) => write!(f, "Invalid commit CID: {}", s), + Self::InvalidBlobCid(s) => write!(f, "Invalid blob CID: {}", s), + } + } +} + +impl std::error::Error for CommitFrameError {} + pub struct CommitFrameBuilder { - pub seq: i64, - pub did: String, - pub commit_cid_str: String, - pub prev_cid_str: Option, - pub ops_json: serde_json::Value, - pub blobs: Vec, - pub time: chrono::DateTime, - pub rev: Option, + seq: i64, + did: String, + commit_cid: Cid, + prev_cid: Option, + ops_json: serde_json::Value, + blob_cids: Vec, + time: chrono::DateTime, + rev: Option, } impl CommitFrameBuilder { - pub fn build(self) -> Result { - let commit_cid = Cid::from_str(&self.commit_cid_str).map_err(|_| "Invalid commit CID")?; + pub fn new( + seq: i64, + did: String, + commit_cid_str: &str, + prev_cid_str: Option<&str>, + ops_json: serde_json::Value, + blob_strs: Vec, + time: chrono::DateTime, + rev: Option, + ) -> Result { + let commit_cid = Cid::from_str(commit_cid_str) + .map_err(|_| CommitFrameError::InvalidCommitCid(commit_cid_str.to_string()))?; + let prev_cid = prev_cid_str + .map(|s| Cid::from_str(s)) + .transpose() + .map_err(|_| CommitFrameError::InvalidCommitCid(prev_cid_str.unwrap_or("").to_string()))?; + let blob_cids: Vec = blob_strs + .iter() + .filter_map(|s| Cid::from_str(s).ok()) + .collect(); + Ok(Self { + seq, + did, + commit_cid, + prev_cid, + ops_json, + blob_cids, + time, + rev, + }) + } + + pub fn build(self) -> CommitFrame { let json_ops: Vec = serde_json::from_value(self.ops_json).unwrap_or_else(|_| vec![]); let ops: Vec = json_ops @@ -118,27 +166,22 @@ impl CommitFrameBuilder { prev: op.prev.and_then(|s| Cid::from_str(&s).ok()), }) .collect(); - let blobs: Vec = self - .blobs - .iter() - .filter_map(|s| Cid::from_str(s).ok()) - .collect(); let rev = self.rev.unwrap_or_else(placeholder_rev); - let since = self.prev_cid_str.as_ref().map(|_| rev.clone()); - Ok(CommitFrame { + let since = self.prev_cid.as_ref().map(|_| rev.clone()); + CommitFrame { seq: self.seq, rebase: false, too_big: false, repo: self.did, - commit: commit_cid, + commit: self.commit_cid, rev, since, blocks: Vec::new(), ops, - blobs, + blobs: self.blob_cids, time: format_atproto_time(self.time), prev_data: None, - }) + } } } @@ -152,19 +195,22 @@ fn format_atproto_time(dt: chrono::DateTime) -> String { } impl TryFrom for CommitFrame { - type Error = &'static str; + type Error = CommitFrameError; fn try_from(event: SequencedEvent) -> Result { - let builder = CommitFrameBuilder { - seq: event.seq, - did: event.did, - commit_cid_str: event.commit_cid.ok_or("Missing commit_cid in event")?, - prev_cid_str: event.prev_cid, - ops_json: event.ops.unwrap_or_default(), - blobs: event.blobs.unwrap_or_default(), - time: event.created_at, - rev: event.rev, - }; - builder.build() + let commit_cid_str = event.commit_cid.ok_or_else(|| { + CommitFrameError::InvalidCommitCid("Missing commit_cid in event".to_string()) + })?; + let builder = CommitFrameBuilder::new( + event.seq, + event.did, + &commit_cid_str, + event.prev_cid.as_deref(), + event.ops.unwrap_or_default(), + event.blobs.unwrap_or_default(), + event.created_at, + event.rev, + )?; + Ok(builder.build()) } } diff --git a/src/sync/repo.rs b/src/sync/repo.rs index a26dffb..e4f4faf 100644 --- a/src/sync/repo.rs +++ b/src/sync/repo.rs @@ -1,8 +1,8 @@ +use crate::api::error::ApiError; use crate::state::AppState; use crate::sync::car::encode_car_header; use crate::sync::util::assert_repo_availability; use axum::{ - Json, extract::{Query, RawQuery, State}, http::StatusCode, response::{IntoResponse, Response}, @@ -11,7 +11,6 @@ use cid::Cid; use ipld_core::ipld::Ipld; use jacquard_repo::storage::BlockStore; use serde::Deserialize; -use serde_json::json; use std::io::Write; use std::str::FromStr; use tracing::error; @@ -28,26 +27,13 @@ fn parse_get_blocks_query(query_string: &str) -> Result<(String, Vec), S } pub async fn get_blocks(State(state): State, RawQuery(query): RawQuery) -> Response { - let query_string = match query { - Some(q) => q, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "Missing query parameters"})), - ) - .into_response(); - } + let Some(query_string) = query else { + return ApiError::InvalidRequest("Missing query parameters".into()).into_response(); }; let (did, cid_strings) = match parse_get_blocks_query(&query_string) { Ok(parsed) => parsed, - Err(msg) => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": msg})), - ) - .into_response(); - } + Err(msg) => return ApiError::InvalidRequest(msg).into_response(), }; let _account = match assert_repo_availability(&state.db, &did, false).await { @@ -55,65 +41,47 @@ pub async fn get_blocks(State(state): State, RawQuery(query): RawQuery Err(e) => return e.into_response(), }; - let mut cids = Vec::new(); - for s in &cid_strings { - match Cid::from_str(s) { - Ok(cid) => cids.push(cid), - Err(_) => return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": format!("Invalid CID: {}", s)})), - ) - .into_response(), + let cids: Vec = match cid_strings + .iter() + .map(|s| Cid::from_str(s).map_err(|_| s.clone())) + .collect::, _>>() + { + Ok(cids) => cids, + Err(invalid) => { + return ApiError::InvalidRequest(format!("Invalid CID: {}", invalid)).into_response() } - } + }; if cids.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "InvalidRequest", "message": "No CIDs provided"})), - ) - .into_response(); + return ApiError::InvalidRequest("No CIDs provided".into()).into_response(); } - let blocks_res = state.block_store.get_many(&cids).await; - let blocks = match blocks_res { + let blocks = match state.block_store.get_many(&cids).await { Ok(blocks) => blocks, Err(e) => { error!("Failed to get blocks: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to get blocks"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; - let mut missing_cids: Vec = Vec::new(); - for (i, block_opt) in blocks.iter().enumerate() { - if block_opt.is_none() { - missing_cids.push(cids[i].to_string()); - } - } + let missing_cids: Vec = blocks + .iter() + .zip(&cids) + .filter_map(|(block_opt, cid)| block_opt.is_none().then(|| cid.to_string())) + .collect(); if !missing_cids.is_empty() { - return ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "InvalidRequest", - "message": format!("Could not find blocks: {}", missing_cids.join(", ")) - })), - ) - .into_response(); + return ApiError::InvalidRequest(format!( + "Could not find blocks: {}", + missing_cids.join(", ") + )) + .into_response(); } let header = match crate::sync::car::encode_car_header_null_root() { Ok(h) => h, Err(e) => { error!("Failed to encode CAR header: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to encode CAR"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let mut car_bytes = header; @@ -157,26 +125,12 @@ pub async fn get_repo( Err(e) => return e.into_response(), }; - let head_str = match account.repo_root_cid { - Some(cid) => cid, - None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "RepoNotFound", "message": "Repo not initialized"})), - ) - .into_response(); - } + let Some(head_str) = account.repo_root_cid else { + return ApiError::RepoNotFound(Some("Repo not initialized".into())).into_response(); }; - let head_cid = match Cid::from_str(&head_str) { - Ok(c) => c, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Invalid head CID"})), - ) - .into_response(); - } + let Ok(head_cid) = Cid::from_str(&head_str) else { + return ApiError::InternalError(None).into_response(); }; if let Some(since) = &query.since { @@ -186,11 +140,8 @@ pub async fn get_repo( let mut car_bytes = match encode_car_header(&head_cid) { Ok(h) => h, Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Failed to encode CAR header: {}", e)})), - ) - .into_response(); + error!("Failed to encode CAR header: {}", e); + return ApiError::InternalError(None).into_response(); } }; let mut stack = vec![head_cid]; @@ -249,11 +200,7 @@ async fn get_repo_since(state: &AppState, did: &str, head_cid: &Cid, since: &str Ok(e) => e, Err(e) => { error!("DB error in get_repo_since: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Database error"})), - ) - .into_response(); + return ApiError::InternalError(Some("Database error".into())).into_response(); } }; @@ -279,10 +226,7 @@ async fn get_repo_since(state: &AppState, did: &str, head_cid: &Cid, since: &str let mut car_bytes = match encode_car_header(head_cid) { Ok(h) => h, Err(e) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": format!("Failed to encode CAR header: {}", e)})), - ) + return ApiError::InternalError(Some(format!("Failed to encode CAR header: {}", e))) .into_response(); } }; @@ -300,11 +244,7 @@ async fn get_repo_since(state: &AppState, did: &str, head_cid: &Cid, since: &str Ok(b) => b, Err(e) => { error!("Block store error in get_repo_since: {:?}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to get blocks"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to get blocks".into())).into_response(); } }; @@ -377,89 +317,47 @@ pub async fn get_record( let commit_cid_str = match account.repo_root_cid { Some(cid) => cid, None => { - return ( - StatusCode::BAD_REQUEST, - Json(json!({"error": "RepoNotFound", "message": "Repo not initialized"})), - ) - .into_response(); + return ApiError::RepoNotFound(Some("Repo not initialized".into())).into_response(); } }; - let commit_cid = match Cid::from_str(&commit_cid_str) { - Ok(c) => c, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Invalid commit CID"})), - ) - .into_response(); - } + let Ok(commit_cid) = Cid::from_str(&commit_cid_str) else { + return ApiError::InternalError(Some("Invalid commit CID".into())).into_response(); }; let commit_bytes = match state.block_store.get(&commit_cid).await { Ok(Some(b)) => b, _ => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Commit block not found"})), - ) - .into_response(); + return ApiError::InternalError(Some("Commit block not found".into())).into_response(); } }; - let commit = match Commit::from_cbor(&commit_bytes) { - Ok(c) => c, - Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to parse commit"})), - ) - .into_response(); - } + let Ok(commit) = Commit::from_cbor(&commit_bytes) else { + return ApiError::InternalError(Some("Failed to parse commit".into())).into_response(); }; let mst = Mst::load(Arc::new(state.block_store.clone()), commit.data, None); let key = format!("{}/{}", query.collection, query.rkey); let record_cid = match mst.get(&key).await { Ok(Some(cid)) => cid, Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "RecordNotFound", "message": "Record not found"})), - ) - .into_response(); + return ApiError::RecordNotFound.into_response(); } Err(_) => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to lookup record"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to lookup record".into())).into_response(); } }; let record_block = match state.block_store.get(&record_cid).await { Ok(Some(b)) => b, _ => { - return ( - StatusCode::NOT_FOUND, - Json(json!({"error": "RecordNotFound", "message": "Record block not found"})), - ) - .into_response(); + return ApiError::RecordNotFound.into_response(); } }; let mut proof_blocks: BTreeMap = BTreeMap::new(); if mst.blocks_for_path(&key, &mut proof_blocks).await.is_err() { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError", "message": "Failed to build proof path"})), - ) - .into_response(); + return ApiError::InternalError(Some("Failed to build proof path".into())).into_response(); } let header = match encode_car_header(&commit_cid) { Ok(h) => h, Err(e) => { error!("Failed to encode CAR header: {}", e); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": "InternalError"})), - ) - .into_response(); + return ApiError::InternalError(None).into_response(); } }; let mut car_bytes = header; diff --git a/src/sync/util.rs b/src/sync/util.rs index 976fbd8..80699fd 100644 --- a/src/sync/util.rs +++ b/src/sync/util.rs @@ -1,11 +1,10 @@ +use crate::api::error::ApiError; use crate::state::AppState; use crate::sync::firehose::SequencedEvent; use crate::sync::frame::{ AccountFrame, CommitFrame, ErrorFrameBody, ErrorFrameHeader, FrameHeader, IdentityFrame, InfoFrame, SyncFrame, }; -use axum::Json; -use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use bytes::Bytes; use cid::Cid; @@ -13,14 +12,13 @@ use iroh_car::{CarHeader, CarWriter}; use jacquard_repo::commit::Commit; use jacquard_repo::storage::BlockStore; use serde::Serialize; -use serde_json::json; use sqlx::PgPool; use std::collections::{BTreeMap, HashMap}; use std::io::Cursor; use std::str::FromStr; use tokio::io::AsyncWriteExt; -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum AccountStatus { Active, @@ -33,16 +31,72 @@ pub enum AccountStatus { impl AccountStatus { pub fn as_str(&self) -> Option<&'static str> { match self { - AccountStatus::Active => None, - AccountStatus::Takendown => Some("takendown"), - AccountStatus::Suspended => Some("suspended"), - AccountStatus::Deactivated => Some("deactivated"), - AccountStatus::Deleted => Some("deleted"), + Self::Active => None, + Self::Takendown => Some("takendown"), + Self::Suspended => Some("suspended"), + Self::Deactivated => Some("deactivated"), + Self::Deleted => Some("deleted"), } } pub fn is_active(&self) -> bool { - matches!(self, AccountStatus::Active) + matches!(self, Self::Active) + } + + pub fn is_takendown(&self) -> bool { + matches!(self, Self::Takendown) + } + + pub fn is_suspended(&self) -> bool { + matches!(self, Self::Suspended) + } + + pub fn is_deactivated(&self) -> bool { + matches!(self, Self::Deactivated) + } + + pub fn is_deleted(&self) -> bool { + matches!(self, Self::Deleted) + } + + pub fn allows_read(&self) -> bool { + matches!(self, Self::Active | Self::Deactivated) + } + + pub fn allows_write(&self) -> bool { + matches!(self, Self::Active) + } + + pub fn from_db_fields(takedown_ref: Option<&str>, deactivated_at: Option>) -> Self { + if takedown_ref.is_some() { + Self::Takendown + } else if deactivated_at.is_some() { + Self::Deactivated + } else { + Self::Active + } + } +} + +impl From for AccountStatus { + fn from(state: crate::types::AccountState) -> Self { + match state { + crate::types::AccountState::Active => AccountStatus::Active, + crate::types::AccountState::Deactivated { .. } => AccountStatus::Deactivated, + crate::types::AccountState::TakenDown { .. } => AccountStatus::Takendown, + crate::types::AccountState::Migrated { .. } => AccountStatus::Deactivated, + } + } +} + +impl From<&crate::types::AccountState> for AccountStatus { + fn from(state: &crate::types::AccountState) -> Self { + match state { + crate::types::AccountState::Active => AccountStatus::Active, + crate::types::AccountState::Deactivated { .. } => AccountStatus::Deactivated, + crate::types::AccountState::TakenDown { .. } => AccountStatus::Takendown, + crate::types::AccountState::Migrated { .. } => AccountStatus::Deactivated, + } } } @@ -63,38 +117,15 @@ pub enum RepoAvailabilityError { impl IntoResponse for RepoAvailabilityError { fn into_response(self) -> Response { match self { - RepoAvailabilityError::NotFound(did) => ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "RepoNotFound", - "message": format!("Could not find repo for DID: {}", did) - })), - ) - .into_response(), - RepoAvailabilityError::Takendown(did) => ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "RepoTakendown", - "message": format!("Repo has been takendown: {}", did) - })), - ) - .into_response(), - RepoAvailabilityError::Deactivated(did) => ( - StatusCode::BAD_REQUEST, - Json(json!({ - "error": "RepoDeactivated", - "message": format!("Repo has been deactivated: {}", did) - })), - ) - .into_response(), - RepoAvailabilityError::Internal(msg) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({ - "error": "InternalError", - "message": msg - })), - ) - .into_response(), + RepoAvailabilityError::NotFound(did) => { + ApiError::RepoNotFound(Some(format!("Could not find repo for DID: {}", did))) + .into_response() + } + RepoAvailabilityError::Takendown(_) => ApiError::RepoTakendown.into_response(), + RepoAvailabilityError::Deactivated(_) => ApiError::RepoDeactivated.into_response(), + RepoAvailabilityError::Internal(msg) => { + ApiError::InternalError(Some(msg)).into_response() + } } } } diff --git a/src/types.rs b/src/types.rs new file mode 100644 index 0000000..95f4ad1 --- /dev/null +++ b/src/types.rs @@ -0,0 +1,1738 @@ +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; +use std::fmt; +use std::ops::Deref; +use std::str::FromStr; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Did(String); + +impl<'de> Deserialize<'de> for Did { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Did::new(&s).map_err(|e| serde::de::Error::custom(e.to_string())) + } +} + +impl From for String { + fn from(did: Did) -> Self { + did.0 + } +} + +impl From for Did { + fn from(s: String) -> Self { + Did(s) + } +} + +impl<'a> From<&'a Did> for Cow<'a, str> { + fn from(did: &'a Did) -> Self { + Cow::Borrowed(&did.0) + } +} + +impl Did { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Did::new(&s).map_err(|_| DidError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn is_plc(&self) -> bool { + self.0.starts_with("did:plc:") + } + + pub fn is_web(&self) -> bool { + self.0.starts_with("did:web:") + } +} + +impl AsRef for Did { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl PartialEq for Did { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Did { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Did { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Did) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Did) -> bool { + *self == other.0 + } +} + +impl Deref for Did { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl fmt::Display for Did { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Did { + type Err = DidError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum DidError { + Invalid(String), +} + +impl fmt::Display for DidError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid DID: {}", s), + } + } +} + +impl std::error::Error for DidError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Handle(String); + +impl From for String { + fn from(handle: Handle) -> Self { + handle.0 + } +} + +impl From for Handle { + fn from(s: String) -> Self { + Handle(s) + } +} + +impl<'a> From<&'a Handle> for Cow<'a, str> { + fn from(handle: &'a Handle) -> Self { + Cow::Borrowed(&handle.0) + } +} + +impl Handle { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Handle::new(&s).map_err(|_| HandleError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for Handle { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for Handle { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Handle { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Handle { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Handle { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Handle) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Handle) -> bool { + *self == other.0 + } +} + +impl fmt::Display for Handle { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Handle { + type Err = HandleError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum HandleError { + Invalid(String), +} + +impl fmt::Display for HandleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid handle: {}", s), + } + } +} + +impl std::error::Error for HandleError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum AtIdentifier { + Did(Did), + Handle(Handle), +} + +impl AtIdentifier { + pub fn new(s: impl AsRef) -> Result { + let s = s.as_ref(); + if s.starts_with("did:") { + Did::new(s) + .map(AtIdentifier::Did) + .map_err(|_| AtIdentifierError::Invalid(s.to_string())) + } else { + Handle::new(s) + .map(AtIdentifier::Handle) + .map_err(|_| AtIdentifierError::Invalid(s.to_string())) + } + } + + pub fn as_str(&self) -> &str { + match self { + AtIdentifier::Did(d) => d.as_str(), + AtIdentifier::Handle(h) => h.as_str(), + } + } + + pub fn into_inner(self) -> String { + match self { + AtIdentifier::Did(d) => d.into_inner(), + AtIdentifier::Handle(h) => h.into_inner(), + } + } + + pub fn is_did(&self) -> bool { + matches!(self, AtIdentifier::Did(_)) + } + + pub fn is_handle(&self) -> bool { + matches!(self, AtIdentifier::Handle(_)) + } + + pub fn as_did(&self) -> Option<&Did> { + match self { + AtIdentifier::Did(d) => Some(d), + AtIdentifier::Handle(_) => None, + } + } + + pub fn as_handle(&self) -> Option<&Handle> { + match self { + AtIdentifier::Handle(h) => Some(h), + AtIdentifier::Did(_) => None, + } + } +} + +impl From for AtIdentifier { + fn from(did: Did) -> Self { + AtIdentifier::Did(did) + } +} + +impl From for AtIdentifier { + fn from(handle: Handle) -> Self { + AtIdentifier::Handle(handle) + } +} + +impl AsRef for AtIdentifier { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Deref for AtIdentifier { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_str() + } +} + +impl fmt::Display for AtIdentifier { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl FromStr for AtIdentifier { + type Err = AtIdentifierError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +impl Serialize for AtIdentifier { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for AtIdentifier { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + AtIdentifier::new(&s).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone)] +pub enum AtIdentifierError { + Invalid(String), +} + +impl fmt::Display for AtIdentifierError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid AT identifier: {}", s), + } + } +} + +impl std::error::Error for AtIdentifierError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(type_name = "rkey")] +pub struct Rkey(String); + +impl From for String { + fn from(rkey: Rkey) -> Self { + rkey.0 + } +} + +impl From for Rkey { + fn from(s: String) -> Self { + Rkey(s) + } +} + +impl<'a> From<&'a Rkey> for Cow<'a, str> { + fn from(rkey: &'a Rkey) -> Self { + Cow::Borrowed(&rkey.0) + } +} + +impl Rkey { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Rkey::new(&s).map_err(|_| RkeyError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn generate() -> Self { + use jacquard::types::integer::LimitedU32; + Self(jacquard::types::string::Tid::now(LimitedU32::MIN).to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn is_tid(&self) -> bool { + jacquard::types::string::Tid::from_str(&self.0).is_ok() + } +} + +impl AsRef for Rkey { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for Rkey { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Rkey { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Rkey { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Rkey { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Rkey) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Rkey) -> bool { + *self == other.0 + } +} + +impl fmt::Display for Rkey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Rkey { + type Err = RkeyError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum RkeyError { + Invalid(String), +} + +impl fmt::Display for RkeyError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid rkey: {}", s), + } + } +} + +impl std::error::Error for RkeyError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(type_name = "nsid")] +pub struct Nsid(String); + +impl From for String { + fn from(nsid: Nsid) -> Self { + nsid.0 + } +} + +impl From for Nsid { + fn from(s: String) -> Self { + Nsid(s) + } +} + +impl<'a> From<&'a Nsid> for Cow<'a, str> { + fn from(nsid: &'a Nsid) -> Self { + Cow::Borrowed(&nsid.0) + } +} + +impl Nsid { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Nsid::new(&s).map_err(|_| NsidError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn authority(&self) -> Option<&str> { + let parts: Vec<&str> = self.0.rsplitn(2, '.').collect(); + if parts.len() == 2 { + Some(parts[1]) + } else { + None + } + } + + pub fn name(&self) -> Option<&str> { + self.0.rsplit('.').next() + } +} + +impl AsRef for Nsid { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for Nsid { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Nsid { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Nsid { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Nsid { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Nsid) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Nsid) -> bool { + *self == other.0 + } +} + +impl fmt::Display for Nsid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Nsid { + type Err = NsidError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum NsidError { + Invalid(String), +} + +impl fmt::Display for NsidError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid NSID: {}", s), + } + } +} + +impl std::error::Error for NsidError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(type_name = "at_uri")] +pub struct AtUri(String); + +impl From for String { + fn from(uri: AtUri) -> Self { + uri.0 + } +} + +impl From for AtUri { + fn from(s: String) -> Self { + AtUri(s) + } +} + +impl<'a> From<&'a AtUri> for Cow<'a, str> { + fn from(uri: &'a AtUri) -> Self { + Cow::Borrowed(&uri.0) + } +} + +impl AtUri { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::AtUri::new(&s).map_err(|_| AtUriError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn from_parts(did: &str, collection: &str, rkey: &str) -> Self { + Self(format!("at://{}/{}/{}", did, collection, rkey)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for AtUri { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for AtUri { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for AtUri { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for AtUri { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for AtUri { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &AtUri) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &AtUri) -> bool { + *self == other.0 + } +} + +impl fmt::Display for AtUri { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for AtUri { + type Err = AtUriError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum AtUriError { + Invalid(String), +} + +impl fmt::Display for AtUriError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid AT URI: {}", s), + } + } +} + +impl std::error::Error for AtUriError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Tid(String); + +impl From for String { + fn from(tid: Tid) -> Self { + tid.0 + } +} + +impl From for Tid { + fn from(s: String) -> Self { + Tid(s) + } +} + +impl<'a> From<&'a Tid> for Cow<'a, str> { + fn from(tid: &'a Tid) -> Self { + Cow::Borrowed(&tid.0) + } +} + +impl Tid { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Tid::from_str(&s).map_err(|_| TidError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn now() -> Self { + use jacquard::types::integer::LimitedU32; + Self(jacquard::types::string::Tid::now(LimitedU32::MIN).to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for Tid { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for Tid { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Tid { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Tid { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Tid { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Tid) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Tid) -> bool { + *self == other.0 + } +} + +impl fmt::Display for Tid { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Tid { + type Err = TidError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum TidError { + Invalid(String), +} + +impl fmt::Display for TidError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid TID: {}", s), + } + } +} + +impl std::error::Error for TidError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Datetime(String); + +impl From for String { + fn from(dt: Datetime) -> Self { + dt.0 + } +} + +impl From for Datetime { + fn from(s: String) -> Self { + Datetime(s) + } +} + +impl<'a> From<&'a Datetime> for Cow<'a, str> { + fn from(dt: &'a Datetime) -> Self { + Cow::Borrowed(&dt.0) + } +} + +impl Datetime { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Datetime::from_str(&s) + .map_err(|_| DatetimeError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn now() -> Self { + Self(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for Datetime { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for Datetime { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Datetime { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Datetime { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Datetime { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Datetime) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Datetime) -> bool { + *self == other.0 + } +} + +impl fmt::Display for Datetime { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Datetime { + type Err = DatetimeError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum DatetimeError { + Invalid(String), +} + +impl fmt::Display for DatetimeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid datetime: {}", s), + } + } +} + +impl std::error::Error for DatetimeError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Language(String); + +impl From for String { + fn from(lang: Language) -> Self { + lang.0 + } +} + +impl From for Language { + fn from(s: String) -> Self { + Language(s) + } +} + +impl<'a> From<&'a Language> for Cow<'a, str> { + fn from(lang: &'a Language) -> Self { + Cow::Borrowed(&lang.0) + } +} + +impl Language { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + jacquard::types::string::Language::from_str(&s) + .map_err(|_| LanguageError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for Language { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for Language { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for Language { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for Language { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for Language { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &Language) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &Language) -> bool { + *self == other.0 + } +} + +impl fmt::Display for Language { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for Language { + type Err = LanguageError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum LanguageError { + Invalid(String), +} + +impl fmt::Display for LanguageError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid language tag: {}", s), + } + } +} + +impl std::error::Error for LanguageError {} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct CidLink(String); + +impl From for String { + fn from(cid: CidLink) -> Self { + cid.0 + } +} + +impl From for CidLink { + fn from(s: String) -> Self { + CidLink(s) + } +} + +impl<'a> From<&'a CidLink> for Cow<'a, str> { + fn from(cid: &'a CidLink) -> Self { + Cow::Borrowed(&cid.0) + } +} + +impl CidLink { + pub fn new(s: impl Into) -> Result { + let s = s.into(); + cid::Cid::from_str(&s).map_err(|_| CidLinkError::Invalid(s.clone()))?; + Ok(Self(s)) + } + + pub fn new_unchecked(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn to_cid(&self) -> Result { + cid::Cid::from_str(&self.0) + } +} + +impl AsRef for CidLink { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for CidLink { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl PartialEq for CidLink { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq<&str> for CidLink { + fn eq(&self, other: &&str) -> bool { + self.0 == *other + } +} + +impl PartialEq for CidLink { + fn eq(&self, other: &String) -> bool { + self.0 == *other + } +} + +impl PartialEq for String { + fn eq(&self, other: &CidLink) -> bool { + *self == other.0 + } +} + +impl PartialEq for &str { + fn eq(&self, other: &CidLink) -> bool { + *self == other.0 + } +} + +impl fmt::Display for CidLink { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for CidLink { + type Err = CidLinkError; + + fn from_str(s: &str) -> Result { + Self::new(s) + } +} + +#[derive(Debug, Clone)] +pub enum CidLinkError { + Invalid(String), +} + +impl fmt::Display for CidLinkError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Invalid(s) => write!(f, "invalid CID: {}", s), + } + } +} + +impl std::error::Error for CidLinkError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AccountState { + Active, + Deactivated { + at: chrono::DateTime, + }, + TakenDown { + reference: String, + }, + Migrated { + at: chrono::DateTime, + to_pds: String, + }, +} + +impl AccountState { + pub fn from_db_fields( + deactivated_at: Option>, + takedown_ref: Option, + migrated_to_pds: Option, + migrated_at: Option>, + ) -> Self { + if let Some(reference) = takedown_ref { + AccountState::TakenDown { reference } + } else if let (Some(at), Some(to_pds)) = (deactivated_at, migrated_to_pds) { + let migrated_at = migrated_at.unwrap_or(at); + AccountState::Migrated { + at: migrated_at, + to_pds, + } + } else if let Some(at) = deactivated_at { + AccountState::Deactivated { at } + } else { + AccountState::Active + } + } + + pub fn is_active(&self) -> bool { + matches!(self, AccountState::Active) + } + + pub fn is_deactivated(&self) -> bool { + matches!(self, AccountState::Deactivated { .. }) + } + + pub fn is_takendown(&self) -> bool { + matches!(self, AccountState::TakenDown { .. }) + } + + pub fn is_migrated(&self) -> bool { + matches!(self, AccountState::Migrated { .. }) + } + + pub fn can_login(&self) -> bool { + matches!(self, AccountState::Active) + } + + pub fn can_access_repo(&self) -> bool { + matches!(self, AccountState::Active | AccountState::Deactivated { .. }) + } + + pub fn status_string(&self) -> &'static str { + match self { + AccountState::Active => "active", + AccountState::Deactivated { .. } => "deactivated", + AccountState::TakenDown { .. } => "takendown", + AccountState::Migrated { .. } => "deactivated", + } + } + + pub fn status_for_session(&self) -> Option<&'static str> { + match self { + AccountState::Active => None, + AccountState::Deactivated { .. } => Some("deactivated"), + AccountState::TakenDown { .. } => Some("takendown"), + AccountState::Migrated { .. } => Some("migrated"), + } + } +} + +impl fmt::Display for AccountState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AccountState::Active => write!(f, "active"), + AccountState::Deactivated { at } => write!(f, "deactivated ({})", at), + AccountState::TakenDown { reference } => write!(f, "takendown ({})", reference), + AccountState::Migrated { to_pds, .. } => write!(f, "migrated to {}", to_pds), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(transparent)] +pub struct PlainPassword(String); + +impl PlainPassword { + pub fn new(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +impl AsRef for PlainPassword { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl AsRef<[u8]> for PlainPassword { + fn as_ref(&self) -> &[u8] { + self.0.as_bytes() + } +} + +impl Deref for PlainPassword { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +#[derive(Debug, Clone, Serialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct PasswordHash(String); + +impl PasswordHash { + pub fn from_hash(hash: impl Into) -> Self { + Self(hash.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for PasswordHash { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl From for PasswordHash { + fn from(s: String) -> Self { + Self(s) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TokenSource { + Session, + OAuth { client_id: Option }, + ServiceAuth { lxm: Option, aud: Option }, +} + +impl TokenSource { + pub fn is_session(&self) -> bool { + matches!(self, TokenSource::Session) + } + + pub fn is_oauth(&self) -> bool { + matches!(self, TokenSource::OAuth { .. }) + } + + pub fn is_service_auth(&self) -> bool { + matches!(self, TokenSource::ServiceAuth { .. }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct JwkThumbprint(String); + +impl JwkThumbprint { + pub fn new(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for JwkThumbprint { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for JwkThumbprint { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for JwkThumbprint { + fn from(s: String) -> Self { + Self(s) + } +} + +impl PartialEq for JwkThumbprint { + fn eq(&self, other: &str) -> bool { + self.0 == other + } +} + +impl PartialEq for JwkThumbprint { + fn eq(&self, other: &String) -> bool { + &self.0 == other + } +} + +impl PartialEq for String { + fn eq(&self, other: &JwkThumbprint) -> bool { + self == &other.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct DPoPProofId(String); + +impl DPoPProofId { + pub fn new(s: impl Into) -> Self { + Self(s.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_inner(self) -> String { + self.0 + } +} + +impl AsRef for DPoPProofId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl Deref for DPoPProofId { + type Target = str; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl From for DPoPProofId { + fn from(s: String) -> Self { + Self(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_did_validation() { + assert!(Did::new("did:plc:abc123").is_ok()); + assert!(Did::new("did:web:example.com").is_ok()); + assert!(Did::new("not-a-did").is_err()); + assert!(Did::new("").is_err()); + } + + #[test] + fn test_did_methods() { + let plc = Did::new("did:plc:abc123").unwrap(); + assert!(plc.is_plc()); + assert!(!plc.is_web()); + assert_eq!(plc.as_str(), "did:plc:abc123"); + + let web = Did::new("did:web:example.com").unwrap(); + assert!(!web.is_plc()); + assert!(web.is_web()); + } + + #[test] + fn test_did_conversions() { + let did = Did::new("did:plc:test123").unwrap(); + let s: String = did.clone().into(); + assert_eq!(s, "did:plc:test123"); + assert_eq!(format!("{}", did), "did:plc:test123"); + } + + #[test] + fn test_did_serde() { + let did = Did::new("did:plc:test123").unwrap(); + let json = serde_json::to_string(&did).unwrap(); + assert_eq!(json, "\"did:plc:test123\""); + + let parsed: Did = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, did); + } + + #[test] + fn test_handle_validation() { + assert!(Handle::new("user.bsky.social").is_ok()); + assert!(Handle::new("test.example.com").is_ok()); + assert!(Handle::new("invalid handle with spaces").is_err()); + } + + #[test] + fn test_rkey_validation() { + assert!(Rkey::new("self").is_ok()); + assert!(Rkey::new("3jzfcijpj2z2a").is_ok()); + assert!(Rkey::new("invalid/rkey").is_err()); + } + + #[test] + fn test_rkey_generate() { + let rkey = Rkey::generate(); + assert!(rkey.is_tid()); + assert!(!rkey.as_str().is_empty()); + } + + #[test] + fn test_nsid_validation() { + assert!(Nsid::new("app.bsky.feed.post").is_ok()); + assert!(Nsid::new("com.atproto.repo.createRecord").is_ok()); + assert!(Nsid::new("invalid").is_err()); + } + + #[test] + fn test_nsid_parts() { + let nsid = Nsid::new("app.bsky.feed.post").unwrap(); + assert_eq!(nsid.name(), Some("post")); + } + + #[test] + fn test_at_uri_validation() { + assert!(AtUri::new("at://did:plc:abc123/app.bsky.feed.post/xyz").is_ok()); + assert!(AtUri::new("not-an-at-uri").is_err()); + } + + #[test] + fn test_at_uri_from_parts() { + let uri = AtUri::from_parts("did:plc:abc123", "app.bsky.feed.post", "xyz"); + assert_eq!(uri.as_str(), "at://did:plc:abc123/app.bsky.feed.post/xyz"); + } + + #[test] + fn test_type_safety() { + fn takes_did(_: &Did) {} + fn takes_handle(_: &Handle) {} + + let did = Did::new("did:plc:test").unwrap(); + let handle = Handle::new("test.bsky.social").unwrap(); + + takes_did(&did); + takes_handle(&handle); + } + + #[test] + fn test_tid_validation() { + let tid = Tid::now(); + assert!(!tid.as_str().is_empty()); + assert!(Tid::new(tid.as_str()).is_ok()); + assert!(Tid::new("invalid").is_err()); + } + + #[test] + fn test_datetime_validation() { + assert!(Datetime::new("2024-01-15T12:30:45.123Z").is_ok()); + assert!(Datetime::new("not-a-date").is_err()); + let now = Datetime::now(); + assert!(!now.as_str().is_empty()); + } + + #[test] + fn test_language_validation() { + assert!(Language::new("en").is_ok()); + assert!(Language::new("en-US").is_ok()); + assert!(Language::new("ja").is_ok()); + } + + #[test] + fn test_cidlink_validation() { + assert!(CidLink::new("bafyreib74ckyq525l3y6an5txykwwtb3dgex6ofzakml53di77oxwr5pfe").is_ok()); + assert!(CidLink::new("not-a-cid").is_err()); + } + + #[test] + fn test_at_identifier_validation() { + let did_ident = AtIdentifier::new("did:plc:abc123").unwrap(); + assert!(did_ident.is_did()); + assert!(!did_ident.is_handle()); + assert!(did_ident.as_did().is_some()); + assert!(did_ident.as_handle().is_none()); + + let handle_ident = AtIdentifier::new("user.bsky.social").unwrap(); + assert!(!handle_ident.is_did()); + assert!(handle_ident.is_handle()); + assert!(handle_ident.as_did().is_none()); + assert!(handle_ident.as_handle().is_some()); + + assert!(AtIdentifier::new("invalid identifier").is_err()); + } + + #[test] + fn test_at_identifier_serde() { + let ident = AtIdentifier::new("did:plc:test123").unwrap(); + let json = serde_json::to_string(&ident).unwrap(); + assert_eq!(json, "\"did:plc:test123\""); + + let parsed: AtIdentifier = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.as_str(), "did:plc:test123"); + } + + #[test] + fn test_account_state_active() { + let state = AccountState::from_db_fields(None, None, None, None); + assert!(state.is_active()); + assert!(!state.is_deactivated()); + assert!(!state.is_takendown()); + assert!(!state.is_migrated()); + assert!(state.can_login()); + assert!(state.can_access_repo()); + assert_eq!(state.status_string(), "active"); + } + + #[test] + fn test_account_state_deactivated() { + let now = chrono::Utc::now(); + let state = AccountState::from_db_fields(Some(now), None, None, None); + assert!(!state.is_active()); + assert!(state.is_deactivated()); + assert!(!state.is_takendown()); + assert!(!state.is_migrated()); + assert!(!state.can_login()); + assert!(state.can_access_repo()); + assert_eq!(state.status_string(), "deactivated"); + } + + #[test] + fn test_account_state_takendown() { + let state = AccountState::from_db_fields(None, Some("mod-action-123".into()), None, None); + assert!(!state.is_active()); + assert!(!state.is_deactivated()); + assert!(state.is_takendown()); + assert!(!state.is_migrated()); + assert!(!state.can_login()); + assert!(!state.can_access_repo()); + assert_eq!(state.status_string(), "takendown"); + } + + #[test] + fn test_account_state_migrated() { + let now = chrono::Utc::now(); + let state = + AccountState::from_db_fields(Some(now), None, Some("https://other.pds".into()), None); + assert!(!state.is_active()); + assert!(!state.is_deactivated()); + assert!(!state.is_takendown()); + assert!(state.is_migrated()); + assert!(!state.can_login()); + assert!(!state.can_access_repo()); + assert_eq!(state.status_string(), "deactivated"); + } + + #[test] + fn test_account_state_takedown_priority() { + let now = chrono::Utc::now(); + let state = AccountState::from_db_fields( + Some(now), + Some("mod-action".into()), + Some("https://other.pds".into()), + None, + ); + assert!(state.is_takendown()); + } +} diff --git a/src/util.rs b/src/util.rs index 17bc9a0..8f2a58e 100644 --- a/src/util.rs +++ b/src/util.rs @@ -9,6 +9,8 @@ use std::str::FromStr; use std::sync::OnceLock; use uuid::Uuid; +use crate::types::{Did, Handle}; + const BASE32_ALPHABET: &str = "abcdefghijklmnopqrstuvwxyz234567"; const DEFAULT_MAX_BLOB_SIZE: usize = 10 * 1024 * 1024 * 1024; @@ -62,8 +64,8 @@ pub async fn get_user_id_by_did(db: &PgPool, did: &str) -> Result Result { diff --git a/src/validation/mod.rs b/src/validation/mod.rs index 15d9fa6..5105c5c 100644 --- a/src/validation/mod.rs +++ b/src/validation/mod.rs @@ -28,6 +28,16 @@ pub enum ValidationStatus { Invalid, } +impl std::fmt::Display for ValidationStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Valid => write!(f, "valid"), + Self::Unknown => write!(f, "unknown"), + Self::Invalid => write!(f, "invalid"), + } + } +} + pub struct RecordValidator { require_lexicon: bool, } @@ -553,36 +563,28 @@ impl std::fmt::Display for PasswordValidationError { impl std::error::Error for PasswordValidationError {} pub fn validate_password(password: &str) -> Result<(), PasswordValidationError> { - let mut errors = Vec::new(); - - if password.len() < 8 { - errors.push("Password must be at least 8 characters".to_string()); - } - - if password.len() > 256 { - errors.push("Password must be at most 256 characters".to_string()); - } - - if !password.chars().any(|c| c.is_ascii_lowercase()) { - errors.push("Password must contain at least one lowercase letter".to_string()); - } - - if !password.chars().any(|c| c.is_ascii_uppercase()) { - errors.push("Password must contain at least one uppercase letter".to_string()); - } - - if !password.chars().any(|c| c.is_ascii_digit()) { - errors.push("Password must contain at least one number".to_string()); - } - - if is_common_password(password) { - errors.push("Password is too common, please choose a different one".to_string()); - } + let errors: Vec<&'static str> = [ + (password.len() < 8).then_some("Password must be at least 8 characters"), + (password.len() > 256).then_some("Password must be at most 256 characters"), + (!password.chars().any(|c| c.is_ascii_lowercase())) + .then_some("Password must contain at least one lowercase letter"), + (!password.chars().any(|c| c.is_ascii_uppercase())) + .then_some("Password must contain at least one uppercase letter"), + (!password.chars().any(|c| c.is_ascii_digit())) + .then_some("Password must contain at least one number"), + is_common_password(password) + .then_some("Password is too common, please choose a different one"), + ] + .into_iter() + .flatten() + .collect(); if errors.is_empty() { Ok(()) } else { - Err(PasswordValidationError { errors }) + Err(PasswordValidationError { + errors: errors.iter().map(|s| (*s).to_string()).collect(), + }) } } diff --git a/tests/admin_email.rs b/tests/admin_email.rs index 001ba6b..c912704 100644 --- a/tests/admin_email.rs +++ b/tests/admin_email.rs @@ -137,8 +137,6 @@ async fn test_send_email_missing_recipient() { .await .expect("Failed to send email"); assert_eq!(res.status(), StatusCode::BAD_REQUEST); - let body: Value = res.json().await.expect("Invalid JSON"); - assert_eq!(body["error"], "InvalidRequest"); } #[tokio::test] diff --git a/tests/delete_account.rs b/tests/delete_account.rs index c94c8f5..b5da48c 100644 --- a/tests/delete_account.rs +++ b/tests/delete_account.rs @@ -414,5 +414,5 @@ async fn test_delete_account_nonexistent_user() { .expect("Failed to send delete request"); assert_eq!(delete_res.status(), StatusCode::BAD_REQUEST); let body: Value = delete_res.json().await.unwrap(); - assert_eq!(body["error"], "AccountNotFound"); + assert_eq!(body["error"], "InvalidRequest"); } diff --git a/tests/import_verification.rs b/tests/import_verification.rs index dd8c307..7255c0e 100644 --- a/tests/import_verification.rs +++ b/tests/import_verification.rs @@ -102,8 +102,8 @@ async fn test_import_rejects_car_for_different_user() { assert_eq!(import_res.status(), StatusCode::FORBIDDEN); let body: serde_json::Value = import_res.json().await.unwrap(); assert!( - body["error"] == "InvalidRequest" || body["error"] == "DidMismatch", - "Expected DidMismatch or InvalidRequest error, got: {:?}", + body["error"] == "InvalidRepo" || body["error"] == "InvalidRequest" || body["error"] == "DidMismatch", + "Expected InvalidRepo, DidMismatch, or InvalidRequest error, got: {:?}", body ); } diff --git a/tests/lifecycle_record.rs b/tests/lifecycle_record.rs index 93b4878..65e93fa 100644 --- a/tests/lifecycle_record.rs +++ b/tests/lifecycle_record.rs @@ -771,5 +771,5 @@ async fn test_list_records_comprehensive() { .send() .await .expect("Failed with nonexistent repo"); - assert_eq!(not_found_res.status(), StatusCode::NOT_FOUND); + assert_eq!(not_found_res.status(), StatusCode::BAD_REQUEST); } -- 2.43.0 From 6404b1dc4df74fc7b865776fc9c630a0c240854a Mon Sep 17 00:00:00 2001 From: lewis Date: Sun, 4 Jan 2026 18:38:15 +0200 Subject: [PATCH] More local methods to not proxy --- Cargo.lock | 21 +++++ Cargo.toml | 1 + frontend/src/lib/migration/atproto-client.ts | 22 ++++++ frontend/src/lib/migration/blob-migration.ts | 8 +- src/api/error.rs | 2 +- src/api/proxy.rs | 83 ++++++++++++++++++-- src/api/repo/blob.rs | 35 ++++++++- src/storage/mod.rs | 30 +++++++ 8 files changed, 187 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b506abe..68b10d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1109,6 +1109,17 @@ dependencies = [ "slab", ] +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -3005,6 +3016,15 @@ dependencies = [ "rustversion", ] +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + [[package]] name = "inout" version = "0.1.4" @@ -6323,6 +6343,7 @@ dependencies = [ "hmac", "http 1.4.0", "image", + "infer", "ipld-core", "iroh-car", "jacquard", diff --git a/Cargo.toml b/Cargo.toml index 69bf2fb..facabed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ governor = "0.10" hex = "0.4" hkdf = "0.12" hmac = "0.12" +infer = "0.19" aes-gcm = "0.10" jacquard = { version = "0.9.5", default-features = false, features = ["api", "api_bluesky", "api_full", "derive", "dns"] } jacquard-axum = "0.9.6" diff --git a/frontend/src/lib/migration/atproto-client.ts b/frontend/src/lib/migration/atproto-client.ts index 4d9f583..b792616 100644 --- a/frontend/src/lib/migration/atproto-client.ts +++ b/frontend/src/lib/migration/atproto-client.ts @@ -227,6 +227,28 @@ export class AtprotoClient { }); } + async getBlobWithContentType( + did: string, + cid: string, + ): Promise<{ data: Uint8Array; contentType: string }> { + const url = `${this.baseUrl}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; + const headers: Record = {}; + if (this.accessToken) { + headers["Authorization"] = `Bearer ${this.accessToken}`; + } + const res = await fetch(url, { headers }); + if (!res.ok) { + const err = await res.json().catch(() => ({ + error: "Unknown", + message: res.statusText, + })); + throw new Error(err.message || err.error || res.statusText); + } + const contentType = res.headers.get("content-type") || "application/octet-stream"; + const data = new Uint8Array(await res.arrayBuffer()); + return { data, contentType }; + } + async uploadBlob( data: Uint8Array, mimeType: string, diff --git a/frontend/src/lib/migration/blob-migration.ts b/frontend/src/lib/migration/blob-migration.ts index 54079d5..fc6e1d2 100644 --- a/frontend/src/lib/migration/blob-migration.ts +++ b/frontend/src/lib/migration/blob-migration.ts @@ -87,15 +87,17 @@ export async function migrateBlobs( }); console.log("[blob-migration] Fetching blob", cid, "from source"); - const blobData = await sourceClient.getBlob(userDid, cid); + const { data: blobData, contentType } = await sourceClient.getBlobWithContentType(userDid, cid); console.log( "[blob-migration] Got blob", cid, "size:", blobData.byteLength, + "contentType:", + contentType, ); - await localClient.uploadBlob(blobData, "application/octet-stream"); - console.log("[blob-migration] Uploaded blob", cid); + await localClient.uploadBlob(blobData, contentType); + console.log("[blob-migration] Uploaded blob", cid, "with contentType:", contentType); migrated++; onProgress({ blobsMigrated: migrated }); } catch (e) { diff --git a/src/api/error.rs b/src/api/error.rs index 49c7ca9..9603dc8 100644 --- a/src/api/error.rs +++ b/src/api/error.rs @@ -480,7 +480,7 @@ impl From for ApiError { Self::AuthenticationFailed(None) } crate::auth::extractor::AuthError::TokenExpired => { - Self::AuthenticationFailed(Some("Token has expired".to_string())) + Self::ExpiredToken(Some("Token has expired".to_string())) } crate::auth::extractor::AuthError::AccountDeactivated => Self::AccountDeactivated, crate::auth::extractor::AuthError::AccountTakedown => Self::AccountTakedown, diff --git a/src/api/proxy.rs b/src/api/proxy.rs index 1ea3cab..c566f00 100644 --- a/src/api/proxy.rs +++ b/src/api/proxy.rs @@ -15,22 +15,87 @@ use tower::{Service, util::BoxCloneSyncService}; use tracing::{error, info, warn}; const PROTECTED_METHODS: &[&str] = &[ + "app.bsky.actor.getPreferences", + "app.bsky.actor.putPreferences", + "com.atproto.admin.deleteAccount", + "com.atproto.admin.disableAccountInvites", + "com.atproto.admin.disableInviteCodes", + "com.atproto.admin.enableAccountInvites", + "com.atproto.admin.getAccountInfo", + "com.atproto.admin.getAccountInfos", + "com.atproto.admin.getInviteCodes", + "com.atproto.admin.getSubjectStatus", + "com.atproto.admin.searchAccounts", "com.atproto.admin.sendEmail", + "com.atproto.admin.updateAccountEmail", + "com.atproto.admin.updateAccountHandle", + "com.atproto.admin.updateAccountPassword", + "com.atproto.admin.updateSubjectStatus", + "com.atproto.identity.getRecommendedDidCredentials", "com.atproto.identity.requestPlcOperationSignature", "com.atproto.identity.signPlcOperation", + "com.atproto.identity.submitPlcOperation", "com.atproto.identity.updateHandle", + "com.atproto.repo.applyWrites", + "com.atproto.repo.createRecord", + "com.atproto.repo.deleteRecord", + "com.atproto.repo.importRepo", + "com.atproto.repo.putRecord", + "com.atproto.repo.uploadBlob", "com.atproto.server.activateAccount", + "com.atproto.server.checkAccountStatus", "com.atproto.server.confirmEmail", + "com.atproto.server.confirmSignup", + "com.atproto.server.createAccount", "com.atproto.server.createAppPassword", + "com.atproto.server.createInviteCode", + "com.atproto.server.createInviteCodes", + "com.atproto.server.createSession", + "com.atproto.server.createTotpSecret", "com.atproto.server.deactivateAccount", + "com.atproto.server.deleteAccount", + "com.atproto.server.deletePasskey", + "com.atproto.server.deleteSession", + "com.atproto.server.describeServer", + "com.atproto.server.disableTotp", + "com.atproto.server.enableTotp", + "com.atproto.server.finishPasskeyRegistration", "com.atproto.server.getAccountInviteCodes", + "com.atproto.server.getServiceAuth", "com.atproto.server.getSession", + "com.atproto.server.getTotpStatus", "com.atproto.server.listAppPasswords", + "com.atproto.server.listPasskeys", + "com.atproto.server.refreshSession", + "com.atproto.server.regenerateBackupCodes", "com.atproto.server.requestAccountDelete", "com.atproto.server.requestEmailConfirmation", "com.atproto.server.requestEmailUpdate", + "com.atproto.server.requestPasswordReset", + "com.atproto.server.resendMigrationVerification", + "com.atproto.server.resendVerification", + "com.atproto.server.reserveSigningKey", + "com.atproto.server.resetPassword", "com.atproto.server.revokeAppPassword", + "com.atproto.server.startPasskeyRegistration", "com.atproto.server.updateEmail", + "com.atproto.server.updatePasskey", + "com.atproto.server.verifyMigrationEmail", + "com.atproto.sync.getBlob", + "com.atproto.sync.getBlocks", + "com.atproto.sync.getCheckout", + "com.atproto.sync.getHead", + "com.atproto.sync.getLatestCommit", + "com.atproto.sync.getRecord", + "com.atproto.sync.getRepo", + "com.atproto.sync.getRepoStatus", + "com.atproto.sync.listBlobs", + "com.atproto.sync.listRepos", + "com.atproto.sync.notifyOfUpdate", + "com.atproto.sync.requestCrawl", + "com.atproto.sync.subscribeRepos", + "com.atproto.temp.checkSignupQueue", + "com.atproto.temp.dereferenceScope", ]; fn is_protected_method(method: &str) -> bool { @@ -89,13 +154,17 @@ impl> Service String { + if let Some(kind) = infer::get(data) { + let detected = kind.mime_type().to_string(); + if detected != client_hint { + debug!( + "MIME type detection: client sent '{}', detected '{}'", + client_hint, detected + ); + } + detected + } else { + if client_hint == "*/*" || client_hint.is_empty() { + warn!("Could not detect MIME type and client sent invalid hint: '{}'", client_hint); + "application/octet-stream".to_string() + } else { + client_hint.to_string() + } + } +} pub async fn upload_blob( State(state): State, @@ -91,11 +111,10 @@ pub async fn upload_blob( return ApiError::Forbidden.into_response(); } - let mime_type = headers + let client_mime_hint = headers .get("content-type") .and_then(|h| h.to_str().ok()) - .unwrap_or("application/octet-stream") - .to_string(); + .unwrap_or("application/octet-stream"); let user_query = sqlx::query!("SELECT id FROM users WHERE did = $1", did) .fetch_optional(&state.db) @@ -136,6 +155,14 @@ pub async fn upload_blob( .into_response(); } + let mime_type = match state.blob_store.get_head(&temp_key, 8192).await { + Ok(head_bytes) => detect_mime_type(&head_bytes, client_mime_hint), + Err(e) => { + warn!("Failed to read blob head for MIME detection: {:?}", e); + client_mime_hint.to_string() + } + }; + let multihash = match Multihash::wrap(0x12, &upload_result.sha256_hash) { Ok(mh) => mh, Err(e) => { diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 62a0d0b..4374295 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -34,6 +34,7 @@ pub trait BlobStorage: Send + Sync { async fn put_bytes(&self, key: &str, data: Bytes) -> Result<(), StorageError>; async fn get(&self, key: &str) -> Result, StorageError>; async fn get_bytes(&self, key: &str) -> Result; + async fn get_head(&self, key: &str, size: usize) -> Result; async fn delete(&self, key: &str) -> Result<(), StorageError>; async fn put_stream( &self, @@ -233,6 +234,35 @@ impl BlobStorage for S3BlobStorage { Ok(data) } + async fn get_head(&self, key: &str, size: usize) -> Result { + let range = format!("bytes=0-{}", size.saturating_sub(1)); + let resp = self + .client + .get_object() + .bucket(&self.bucket) + .key(key) + .range(range) + .send() + .await + .map_err(|e| { + crate::metrics::record_s3_operation("get_head", "error"); + StorageError::S3(e.to_string()) + })?; + + let data = resp + .body + .collect() + .await + .map_err(|e| { + crate::metrics::record_s3_operation("get_head", "error"); + StorageError::S3(e.to_string()) + })? + .into_bytes(); + + crate::metrics::record_s3_operation("get_head", "success"); + Ok(data) + } + async fn delete(&self, key: &str) -> Result<(), StorageError> { let result = self .client -- 2.43.0 From dcdb8e28875321be58c77534dd73b6e32816751f Mon Sep 17 00:00:00 2001 From: lewis Date: Sun, 4 Jan 2026 23:43:11 +0200 Subject: [PATCH] More functional and typesafe frontend --- frontend/deno.lock | 662 ++---- frontend/package.json | 21 +- frontend/src/App.svelte | 18 +- frontend/src/components/ReauthModal.svelte | 67 +- frontend/src/components/Skeleton.svelte | 77 + frontend/src/components/Toast.svelte | 188 ++ frontend/src/lib/api-validated.ts | 345 +++ frontend/src/lib/api.ts | 1975 ++++++++++------- frontend/src/lib/auth.svelte.ts | 651 ++++-- frontend/src/lib/crypto.ts | 5 +- frontend/src/lib/migration/atproto-client.ts | 24 +- frontend/src/lib/migration/blob-migration.ts | 4 +- frontend/src/lib/oauth.ts | 5 +- .../lib/registration/VerificationStep.svelte | 2 +- frontend/src/lib/registration/flow.svelte.ts | 2 +- frontend/src/lib/router.svelte.ts | 126 +- frontend/src/lib/toast.svelte.ts | 74 + frontend/src/lib/types/api.ts | 486 ++++ frontend/src/lib/types/branded.ts | 188 ++ frontend/src/lib/types/exhaustive.ts | 49 + frontend/src/lib/types/index.ts | 5 + frontend/src/lib/types/result.ts | 94 + frontend/src/lib/types/routes.ts | 83 + frontend/src/lib/types/schemas.ts | 332 +++ frontend/src/lib/utils/array.ts | 190 ++ frontend/src/lib/utils/async.ts | 246 ++ frontend/src/lib/utils/index.ts | 3 + frontend/src/lib/utils/option.ts | 79 + frontend/src/lib/validation.ts | 260 +++ frontend/src/lib/webauthn.ts | 156 ++ frontend/src/routes/ActAs.svelte | 24 +- frontend/src/routes/Admin.svelte | 116 +- frontend/src/routes/AppPasswords.svelte | 69 +- frontend/src/routes/Comms.svelte | 103 +- frontend/src/routes/Controllers.svelte | 105 +- frontend/src/routes/Dashboard.svelte | 172 +- frontend/src/routes/DelegationAudit.svelte | 72 +- frontend/src/routes/DidDocumentEditor.svelte | 87 +- frontend/src/routes/InviteCodes.svelte | 66 +- frontend/src/routes/Login.svelte | 108 +- frontend/src/routes/Migration.svelte | 6 +- frontend/src/routes/OAuth2FA.svelte | 4 +- frontend/src/routes/OAuthAccounts.svelte | 14 +- frontend/src/routes/OAuthConsent.svelte | 38 +- frontend/src/routes/OAuthDelegation.svelte | 62 +- frontend/src/routes/OAuthLogin.svelte | 66 +- frontend/src/routes/OAuthPasskey.svelte | 56 +- frontend/src/routes/OAuthTotp.svelte | 4 +- frontend/src/routes/RecoverPasskey.svelte | 6 +- frontend/src/routes/Register.svelte | 15 +- frontend/src/routes/RegisterPasskey.svelte | 54 +- frontend/src/routes/RepoExplorer.svelte | 96 +- .../src/routes/RequestPasskeyRecovery.svelte | 6 +- frontend/src/routes/ResetPassword.svelte | 17 +- frontend/src/routes/Security.svelte | 240 +- frontend/src/routes/Sessions.svelte | 75 +- frontend/src/routes/Settings.svelte | 197 +- frontend/src/routes/TrustedDevices.svelte | 84 +- frontend/src/routes/Verify.svelte | 39 +- src/api/error.rs | 4 +- 60 files changed, 5923 insertions(+), 2499 deletions(-) create mode 100644 frontend/src/components/Skeleton.svelte create mode 100644 frontend/src/components/Toast.svelte create mode 100644 frontend/src/lib/api-validated.ts create mode 100644 frontend/src/lib/toast.svelte.ts create mode 100644 frontend/src/lib/types/api.ts create mode 100644 frontend/src/lib/types/branded.ts create mode 100644 frontend/src/lib/types/exhaustive.ts create mode 100644 frontend/src/lib/types/index.ts create mode 100644 frontend/src/lib/types/result.ts create mode 100644 frontend/src/lib/types/routes.ts create mode 100644 frontend/src/lib/types/schemas.ts create mode 100644 frontend/src/lib/utils/array.ts create mode 100644 frontend/src/lib/utils/async.ts create mode 100644 frontend/src/lib/utils/index.ts create mode 100644 frontend/src/lib/utils/option.ts create mode 100644 frontend/src/lib/validation.ts create mode 100644 frontend/src/lib/webauthn.ts diff --git a/frontend/deno.lock b/frontend/deno.lock index a72040c..c3a9dc8 100644 --- a/frontend/deno.lock +++ b/frontend/deno.lock @@ -5,20 +5,20 @@ "npm:@atcute/crypto@^2.3.0": "2.3.0", "npm:@atcute/did-plc@~0.3.1": "0.3.1", "npm:@atcute/multibase@^1.1.6": "1.1.6", - "npm:@noble/secp256k1@^2.1.0": "2.3.0", - "npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3", - "npm:@testing-library/jest-dom@^6.6.3": "6.9.1", - "npm:@testing-library/svelte@^5.2.6": "5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1", - "npm:@testing-library/user-event@^14.5.2": "14.6.1_@testing-library+dom@10.4.1", + "npm:@noble/secp256k1@3": "3.0.0", + "npm:@sveltejs/vite-plugin-svelte@^6.2.1": "6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__picomatch@4.0.3", + "npm:@testing-library/jest-dom@^6.9.1": "6.9.1", + "npm:@testing-library/svelte@^5.3.1": "5.3.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__picomatch@4.0.3_vitest@4.0.16__jsdom@25.0.1__vite@7.3.0___picomatch@4.0.3_jsdom@25.0.1", + "npm:@testing-library/user-event@^14.6.1": "14.6.1_@testing-library+dom@10.4.1", "npm:jsdom@^25.0.1": "25.0.1", - "npm:multiformats@^13.3.1": "13.4.2", - "npm:svelte-check@*": "4.3.5_svelte@5.45.10__acorn@8.15.0_typescript@5.9.3", - "npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.45.10__acorn@8.15.0", - "npm:svelte@5": "5.45.10_acorn@8.15.0", - "npm:vite@*": "6.4.1_picomatch@4.0.3", - "npm:vite@6": "6.4.1_picomatch@4.0.3", - "npm:vitest@*": "2.1.9_jsdom@25.0.1_vite@5.4.21", - "npm:vitest@^2.1.8": "2.1.9_jsdom@25.0.1_vite@5.4.21" + "npm:multiformats@^13.4.2": "13.4.2", + "npm:svelte-i18n@^4.0.1": "4.0.1_svelte@5.46.1__acorn@8.15.0", + "npm:svelte@^5.46.1": "5.46.1_acorn@8.15.0", + "npm:vite@*": "7.3.0_picomatch@4.0.3", + "npm:vite@^7.3.0": "7.3.0_picomatch@4.0.3", + "npm:vitest@*": "4.0.16_jsdom@25.0.1_vite@7.3.0__picomatch@4.0.3", + "npm:vitest@^4.0.16": "4.0.16_jsdom@25.0.1_vite@7.3.0__picomatch@4.0.3", + "npm:zod@^4.3.5": "4.3.5" }, "npm": { "@adobe/css-tools@4.4.4": { @@ -54,7 +54,7 @@ "dependencies": [ "@atcute/multibase", "@atcute/uint8array", - "@noble/secp256k1@3.0.0" + "@noble/secp256k1" ] }, "@atcute/did-plc@0.3.1": { @@ -96,8 +96,8 @@ "@atcute/uint8array@1.0.6": { "integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==" }, - "@atcute/util-fetch@1.0.4": { - "integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==", + "@atcute/util-fetch@1.0.5": { + "integrity": "sha512-qjHj01BGxjSjIFdPiAjSARnodJIIyKxnCMMEcXMESo9TAyND6XZQqrie5fia+LlYWVXdpsTds8uFQwc9jdKTig==", "dependencies": [ "@badrap/valita" ] @@ -158,13 +158,8 @@ "os": ["aix"], "cpu": ["ppc64"] }, - "@esbuild/aix-ppc64@0.21.5": { - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "os": ["aix"], - "cpu": ["ppc64"] - }, - "@esbuild/aix-ppc64@0.25.12": { - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "@esbuild/aix-ppc64@0.27.2": { + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "os": ["aix"], "cpu": ["ppc64"] }, @@ -173,13 +168,8 @@ "os": ["android"], "cpu": ["arm64"] }, - "@esbuild/android-arm64@0.21.5": { - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "os": ["android"], - "cpu": ["arm64"] - }, - "@esbuild/android-arm64@0.25.12": { - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "@esbuild/android-arm64@0.27.2": { + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "os": ["android"], "cpu": ["arm64"] }, @@ -188,13 +178,8 @@ "os": ["android"], "cpu": ["arm"] }, - "@esbuild/android-arm@0.21.5": { - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "os": ["android"], - "cpu": ["arm"] - }, - "@esbuild/android-arm@0.25.12": { - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "@esbuild/android-arm@0.27.2": { + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "os": ["android"], "cpu": ["arm"] }, @@ -203,13 +188,8 @@ "os": ["android"], "cpu": ["x64"] }, - "@esbuild/android-x64@0.21.5": { - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "os": ["android"], - "cpu": ["x64"] - }, - "@esbuild/android-x64@0.25.12": { - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "@esbuild/android-x64@0.27.2": { + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "os": ["android"], "cpu": ["x64"] }, @@ -218,13 +198,8 @@ "os": ["darwin"], "cpu": ["arm64"] }, - "@esbuild/darwin-arm64@0.21.5": { - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@esbuild/darwin-arm64@0.25.12": { - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "@esbuild/darwin-arm64@0.27.2": { + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "os": ["darwin"], "cpu": ["arm64"] }, @@ -233,13 +208,8 @@ "os": ["darwin"], "cpu": ["x64"] }, - "@esbuild/darwin-x64@0.21.5": { - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@esbuild/darwin-x64@0.25.12": { - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "@esbuild/darwin-x64@0.27.2": { + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "os": ["darwin"], "cpu": ["x64"] }, @@ -248,13 +218,8 @@ "os": ["freebsd"], "cpu": ["arm64"] }, - "@esbuild/freebsd-arm64@0.21.5": { - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "os": ["freebsd"], - "cpu": ["arm64"] - }, - "@esbuild/freebsd-arm64@0.25.12": { - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "@esbuild/freebsd-arm64@0.27.2": { + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "os": ["freebsd"], "cpu": ["arm64"] }, @@ -263,13 +228,8 @@ "os": ["freebsd"], "cpu": ["x64"] }, - "@esbuild/freebsd-x64@0.21.5": { - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@esbuild/freebsd-x64@0.25.12": { - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "@esbuild/freebsd-x64@0.27.2": { + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "os": ["freebsd"], "cpu": ["x64"] }, @@ -278,13 +238,8 @@ "os": ["linux"], "cpu": ["arm64"] }, - "@esbuild/linux-arm64@0.21.5": { - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@esbuild/linux-arm64@0.25.12": { - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "@esbuild/linux-arm64@0.27.2": { + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "os": ["linux"], "cpu": ["arm64"] }, @@ -293,13 +248,8 @@ "os": ["linux"], "cpu": ["arm"] }, - "@esbuild/linux-arm@0.21.5": { - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@esbuild/linux-arm@0.25.12": { - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "@esbuild/linux-arm@0.27.2": { + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "os": ["linux"], "cpu": ["arm"] }, @@ -308,13 +258,8 @@ "os": ["linux"], "cpu": ["ia32"] }, - "@esbuild/linux-ia32@0.21.5": { - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "os": ["linux"], - "cpu": ["ia32"] - }, - "@esbuild/linux-ia32@0.25.12": { - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "@esbuild/linux-ia32@0.27.2": { + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "os": ["linux"], "cpu": ["ia32"] }, @@ -323,13 +268,8 @@ "os": ["linux"], "cpu": ["loong64"] }, - "@esbuild/linux-loong64@0.21.5": { - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "os": ["linux"], - "cpu": ["loong64"] - }, - "@esbuild/linux-loong64@0.25.12": { - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "@esbuild/linux-loong64@0.27.2": { + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "os": ["linux"], "cpu": ["loong64"] }, @@ -338,13 +278,8 @@ "os": ["linux"], "cpu": ["mips64el"] }, - "@esbuild/linux-mips64el@0.21.5": { - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "os": ["linux"], - "cpu": ["mips64el"] - }, - "@esbuild/linux-mips64el@0.25.12": { - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "@esbuild/linux-mips64el@0.27.2": { + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "os": ["linux"], "cpu": ["mips64el"] }, @@ -353,13 +288,8 @@ "os": ["linux"], "cpu": ["ppc64"] }, - "@esbuild/linux-ppc64@0.21.5": { - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "os": ["linux"], - "cpu": ["ppc64"] - }, - "@esbuild/linux-ppc64@0.25.12": { - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "@esbuild/linux-ppc64@0.27.2": { + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "os": ["linux"], "cpu": ["ppc64"] }, @@ -368,13 +298,8 @@ "os": ["linux"], "cpu": ["riscv64"] }, - "@esbuild/linux-riscv64@0.21.5": { - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "os": ["linux"], - "cpu": ["riscv64"] - }, - "@esbuild/linux-riscv64@0.25.12": { - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "@esbuild/linux-riscv64@0.27.2": { + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "os": ["linux"], "cpu": ["riscv64"] }, @@ -383,13 +308,8 @@ "os": ["linux"], "cpu": ["s390x"] }, - "@esbuild/linux-s390x@0.21.5": { - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "os": ["linux"], - "cpu": ["s390x"] - }, - "@esbuild/linux-s390x@0.25.12": { - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "@esbuild/linux-s390x@0.27.2": { + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "os": ["linux"], "cpu": ["s390x"] }, @@ -398,18 +318,13 @@ "os": ["linux"], "cpu": ["x64"] }, - "@esbuild/linux-x64@0.21.5": { - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "@esbuild/linux-x64@0.27.2": { + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "os": ["linux"], "cpu": ["x64"] }, - "@esbuild/linux-x64@0.25.12": { - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@esbuild/netbsd-arm64@0.25.12": { - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "@esbuild/netbsd-arm64@0.27.2": { + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "os": ["netbsd"], "cpu": ["arm64"] }, @@ -418,18 +333,13 @@ "os": ["netbsd"], "cpu": ["x64"] }, - "@esbuild/netbsd-x64@0.21.5": { - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "os": ["netbsd"], - "cpu": ["x64"] - }, - "@esbuild/netbsd-x64@0.25.12": { - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "@esbuild/netbsd-x64@0.27.2": { + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "os": ["netbsd"], "cpu": ["x64"] }, - "@esbuild/openbsd-arm64@0.25.12": { - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "@esbuild/openbsd-arm64@0.27.2": { + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "os": ["openbsd"], "cpu": ["arm64"] }, @@ -438,18 +348,13 @@ "os": ["openbsd"], "cpu": ["x64"] }, - "@esbuild/openbsd-x64@0.21.5": { - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "@esbuild/openbsd-x64@0.27.2": { + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "os": ["openbsd"], "cpu": ["x64"] }, - "@esbuild/openbsd-x64@0.25.12": { - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "os": ["openbsd"], - "cpu": ["x64"] - }, - "@esbuild/openharmony-arm64@0.25.12": { - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "@esbuild/openharmony-arm64@0.27.2": { + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "os": ["openharmony"], "cpu": ["arm64"] }, @@ -458,13 +363,8 @@ "os": ["sunos"], "cpu": ["x64"] }, - "@esbuild/sunos-x64@0.21.5": { - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "os": ["sunos"], - "cpu": ["x64"] - }, - "@esbuild/sunos-x64@0.25.12": { - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "@esbuild/sunos-x64@0.27.2": { + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "os": ["sunos"], "cpu": ["x64"] }, @@ -473,13 +373,8 @@ "os": ["win32"], "cpu": ["arm64"] }, - "@esbuild/win32-arm64@0.21.5": { - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@esbuild/win32-arm64@0.25.12": { - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "@esbuild/win32-arm64@0.27.2": { + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "os": ["win32"], "cpu": ["arm64"] }, @@ -488,13 +383,8 @@ "os": ["win32"], "cpu": ["ia32"] }, - "@esbuild/win32-ia32@0.21.5": { - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "os": ["win32"], - "cpu": ["ia32"] - }, - "@esbuild/win32-ia32@0.25.12": { - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "@esbuild/win32-ia32@0.27.2": { + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "os": ["win32"], "cpu": ["ia32"] }, @@ -503,13 +393,8 @@ "os": ["win32"], "cpu": ["x64"] }, - "@esbuild/win32-x64@0.21.5": { - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "os": ["win32"], - "cpu": ["x64"] - }, - "@esbuild/win32-x64@0.25.12": { - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "@esbuild/win32-x64@0.27.2": { + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "os": ["win32"], "cpu": ["x64"] }, @@ -576,119 +461,116 @@ "@jridgewell/sourcemap-codec" ] }, - "@noble/secp256k1@2.3.0": { - "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==" - }, "@noble/secp256k1@3.0.0": { "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==" }, - "@rollup/rollup-android-arm-eabi@4.53.3": { - "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", + "@rollup/rollup-android-arm-eabi@4.54.0": { + "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==", "os": ["android"], "cpu": ["arm"] }, - "@rollup/rollup-android-arm64@4.53.3": { - "integrity": "sha512-CbDGaMpdE9sh7sCmTrTUyllhrg65t6SwhjlMJsLr+J8YjFuPmCEjbBSx4Z/e4SmDyH3aB5hGaJUP2ltV/vcs4w==", + "@rollup/rollup-android-arm64@4.54.0": { + "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==", "os": ["android"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-arm64@4.53.3": { - "integrity": "sha512-Nr7SlQeqIBpOV6BHHGZgYBuSdanCXuw09hon14MGOLGmXAFYjx1wNvquVPmpZnl0tLjg25dEdr4IQ6GgyToCUA==", + "@rollup/rollup-darwin-arm64@4.54.0": { + "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==", "os": ["darwin"], "cpu": ["arm64"] }, - "@rollup/rollup-darwin-x64@4.53.3": { - "integrity": "sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==", + "@rollup/rollup-darwin-x64@4.54.0": { + "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==", "os": ["darwin"], "cpu": ["x64"] }, - "@rollup/rollup-freebsd-arm64@4.53.3": { - "integrity": "sha512-yMTrCrK92aGyi7GuDNtGn2sNW+Gdb4vErx4t3Gv/Tr+1zRb8ax4z8GWVRfr3Jw8zJWvpGHNpss3vVlbF58DZ4w==", + "@rollup/rollup-freebsd-arm64@4.54.0": { + "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==", "os": ["freebsd"], "cpu": ["arm64"] }, - "@rollup/rollup-freebsd-x64@4.53.3": { - "integrity": "sha512-lMfF8X7QhdQzseM6XaX0vbno2m3hlyZFhwcndRMw8fbAGUGL3WFMBdK0hbUBIUYcEcMhVLr1SIamDeuLBnXS+Q==", + "@rollup/rollup-freebsd-x64@4.54.0": { + "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==", "os": ["freebsd"], "cpu": ["x64"] }, - "@rollup/rollup-linux-arm-gnueabihf@4.53.3": { - "integrity": "sha512-k9oD15soC/Ln6d2Wv/JOFPzZXIAIFLp6B+i14KhxAfnq76ajt0EhYc5YPeX6W1xJkAdItcVT+JhKl1QZh44/qw==", + "@rollup/rollup-linux-arm-gnueabihf@4.54.0": { + "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm-musleabihf@4.53.3": { - "integrity": "sha512-vTNlKq+N6CK/8UktsrFuc+/7NlEYVxgaEgRXVUVK258Z5ymho29skzW1sutgYjqNnquGwVUObAaxae8rZ6YMhg==", + "@rollup/rollup-linux-arm-musleabihf@4.54.0": { + "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==", "os": ["linux"], "cpu": ["arm"] }, - "@rollup/rollup-linux-arm64-gnu@4.53.3": { - "integrity": "sha512-RGrFLWgMhSxRs/EWJMIFM1O5Mzuz3Xy3/mnxJp/5cVhZ2XoCAxJnmNsEyeMJtpK+wu0FJFWz+QF4mjCA7AUQ3w==", + "@rollup/rollup-linux-arm64-gnu@4.54.0": { + "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-arm64-musl@4.53.3": { - "integrity": "sha512-kASyvfBEWYPEwe0Qv4nfu6pNkITLTb32p4yTgzFCocHnJLAHs+9LjUu9ONIhvfT/5lv4YS5muBHyuV84epBo/A==", + "@rollup/rollup-linux-arm64-musl@4.54.0": { + "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==", "os": ["linux"], "cpu": ["arm64"] }, - "@rollup/rollup-linux-loong64-gnu@4.53.3": { - "integrity": "sha512-JiuKcp2teLJwQ7vkJ95EwESWkNRFJD7TQgYmCnrPtlu50b4XvT5MOmurWNrCj3IFdyjBQ5p9vnrX4JM6I8OE7g==", + "@rollup/rollup-linux-loong64-gnu@4.54.0": { + "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==", "os": ["linux"], "cpu": ["loong64"] }, - "@rollup/rollup-linux-ppc64-gnu@4.53.3": { - "integrity": "sha512-EoGSa8nd6d3T7zLuqdojxC20oBfNT8nexBbB/rkxgKj5T5vhpAQKKnD+h3UkoMuTyXkP5jTjK/ccNRmQrPNDuw==", + "@rollup/rollup-linux-ppc64-gnu@4.54.0": { + "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==", "os": ["linux"], "cpu": ["ppc64"] }, - "@rollup/rollup-linux-riscv64-gnu@4.53.3": { - "integrity": "sha512-4s+Wped2IHXHPnAEbIB0YWBv7SDohqxobiiPA1FIWZpX+w9o2i4LezzH/NkFUl8LRci/8udci6cLq+jJQlh+0g==", + "@rollup/rollup-linux-riscv64-gnu@4.54.0": { + "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-riscv64-musl@4.53.3": { - "integrity": "sha512-68k2g7+0vs2u9CxDt5ktXTngsxOQkSEV/xBbwlqYcUrAVh6P9EgMZvFsnHy4SEiUl46Xf0IObWVbMvPrr2gw8A==", + "@rollup/rollup-linux-riscv64-musl@4.54.0": { + "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==", "os": ["linux"], "cpu": ["riscv64"] }, - "@rollup/rollup-linux-s390x-gnu@4.53.3": { - "integrity": "sha512-VYsFMpULAz87ZW6BVYw3I6sWesGpsP9OPcyKe8ofdg9LHxSbRMd7zrVrr5xi/3kMZtpWL/wC+UIJWJYVX5uTKg==", + "@rollup/rollup-linux-s390x-gnu@4.54.0": { + "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==", "os": ["linux"], "cpu": ["s390x"] }, - "@rollup/rollup-linux-x64-gnu@4.53.3": { - "integrity": "sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==", + "@rollup/rollup-linux-x64-gnu@4.54.0": { + "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-linux-x64-musl@4.53.3": { - "integrity": "sha512-eoROhjcc6HbZCJr+tvVT8X4fW3/5g/WkGvvmwz/88sDtSJzO7r/blvoBDgISDiCjDRZmHpwud7h+6Q9JxFwq1Q==", + "@rollup/rollup-linux-x64-musl@4.54.0": { + "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==", "os": ["linux"], "cpu": ["x64"] }, - "@rollup/rollup-openharmony-arm64@4.53.3": { - "integrity": "sha512-OueLAWgrNSPGAdUdIjSWXw+u/02BRTcnfw9PN41D2vq/JSEPnJnVuBgw18VkN8wcd4fjUs+jFHVM4t9+kBSNLw==", + "@rollup/rollup-openharmony-arm64@4.54.0": { + "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==", "os": ["openharmony"], "cpu": ["arm64"] }, - "@rollup/rollup-win32-arm64-msvc@4.53.3": { - "integrity": "sha512-GOFuKpsxR/whszbF/bzydebLiXIHSgsEUp6M0JI8dWvi+fFa1TD6YQa4aSZHtpmh2/uAlj/Dy+nmby3TJ3pkTw==", + "@rollup/rollup-win32-arm64-msvc@4.54.0": { + "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==", "os": ["win32"], "cpu": ["arm64"] }, - "@rollup/rollup-win32-ia32-msvc@4.53.3": { - "integrity": "sha512-iah+THLcBJdpfZ1TstDFbKNznlzoxa8fmnFYK4V67HvmuNYkVdAywJSoteUszvBQ9/HqN2+9AZghbajMsFT+oA==", + "@rollup/rollup-win32-ia32-msvc@4.54.0": { + "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==", "os": ["win32"], "cpu": ["ia32"] }, - "@rollup/rollup-win32-x64-gnu@4.53.3": { - "integrity": "sha512-J9QDiOIZlZLdcot5NXEepDkstocktoVjkaKUtqzgzpt2yWjGlbYiKyp05rWwk4nypbYUNoFAztEgixoLaSETkg==", + "@rollup/rollup-win32-x64-gnu@4.54.0": { + "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==", "os": ["win32"], "cpu": ["x64"] }, - "@rollup/rollup-win32-x64-msvc@4.53.3": { - "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", + "@rollup/rollup-win32-x64-msvc@4.54.0": { + "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==", "os": ["win32"], "cpu": ["x64"] }, @@ -701,25 +583,24 @@ "acorn" ] }, - "@sveltejs/vite-plugin-svelte-inspector@4.0.1_@sveltejs+vite-plugin-svelte@5.1.1__svelte@5.45.10___acorn@8.15.0__vite@6.4.1___picomatch@4.0.3_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3": { - "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", + "@sveltejs/vite-plugin-svelte-inspector@5.0.1_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.46.1___acorn@8.15.0__vite@7.3.0___picomatch@4.0.3_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__picomatch@4.0.3": { + "integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==", "dependencies": [ "@sveltejs/vite-plugin-svelte", "debug", "svelte", - "vite@6.4.1_picomatch@4.0.3" + "vite" ] }, - "@sveltejs/vite-plugin-svelte@5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3": { - "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", + "@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__picomatch@4.0.3": { + "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dependencies": [ "@sveltejs/vite-plugin-svelte-inspector", "debug", "deepmerge", - "kleur", "magic-string", "svelte", - "vite@6.4.1_picomatch@4.0.3", + "vite", "vitefu" ] }, @@ -747,16 +628,23 @@ "redent" ] }, - "@testing-library/svelte@5.2.9_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3_vitest@2.1.9__jsdom@25.0.1__vite@5.4.21_jsdom@25.0.1": { - "integrity": "sha512-p0Lg/vL1iEsEasXKSipvW9nBCtItQGhYvxL8OZ4w7/IDdC+LGoSJw4mMS5bndVFON/gWryitEhMr29AlO4FvBg==", + "@testing-library/svelte-core@1.0.0_svelte@5.46.1__acorn@8.15.0": { + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dependencies": [ + "svelte" + ] + }, + "@testing-library/svelte@5.3.1_svelte@5.46.1__acorn@8.15.0_vite@7.3.0__picomatch@4.0.3_vitest@4.0.16__jsdom@25.0.1__vite@7.3.0___picomatch@4.0.3_jsdom@25.0.1": { + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", "dependencies": [ "@testing-library/dom", + "@testing-library/svelte-core", "svelte", - "vite@6.4.1_picomatch@4.0.3", + "vite", "vitest" ], "optionalPeers": [ - "vite@6.4.1_picomatch@4.0.3", + "vite", "vitest" ] }, @@ -769,62 +657,70 @@ "@types/aria-query@5.0.4": { "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" }, + "@types/chai@5.2.3": { + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dependencies": [ + "@types/deep-eql", + "assertion-error" + ] + }, + "@types/deep-eql@4.0.2": { + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==" + }, "@types/estree@1.0.8": { "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==" }, - "@vitest/expect@2.1.9": { - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "@vitest/expect@4.0.16": { + "integrity": "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA==", "dependencies": [ + "@standard-schema/spec", + "@types/chai", "@vitest/spy", "@vitest/utils", "chai", "tinyrainbow" ] }, - "@vitest/mocker@2.1.9_vite@5.4.21": { - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "@vitest/mocker@4.0.16_vite@7.3.0__picomatch@4.0.3": { + "integrity": "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg==", "dependencies": [ "@vitest/spy", "estree-walker@3.0.3", "magic-string", - "vite@5.4.21" + "vite" ], "optionalPeers": [ - "vite@5.4.21" + "vite" ] }, - "@vitest/pretty-format@2.1.9": { - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "@vitest/pretty-format@4.0.16": { + "integrity": "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA==", "dependencies": [ "tinyrainbow" ] }, - "@vitest/runner@2.1.9": { - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "@vitest/runner@4.0.16": { + "integrity": "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q==", "dependencies": [ "@vitest/utils", "pathe" ] }, - "@vitest/snapshot@2.1.9": { - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "@vitest/snapshot@4.0.16": { + "integrity": "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA==", "dependencies": [ "@vitest/pretty-format", "magic-string", "pathe" ] }, - "@vitest/spy@2.1.9": { - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dependencies": [ - "tinyspy" - ] + "@vitest/spy@4.0.16": { + "integrity": "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw==" }, - "@vitest/utils@2.1.9": { - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "@vitest/utils@4.0.16": { + "integrity": "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA==", "dependencies": [ "@vitest/pretty-format", - "loupe", "tinyrainbow" ] }, @@ -859,9 +755,6 @@ "axobject-query@4.1.0": { "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" }, - "cac@6.7.14": { - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==" - }, "call-bind-apply-helpers@1.0.2": { "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "dependencies": [ @@ -869,24 +762,8 @@ "function-bind" ] }, - "chai@5.3.3": { - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dependencies": [ - "assertion-error", - "check-error", - "deep-eql", - "loupe", - "pathval" - ] - }, - "check-error@2.1.1": { - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==" - }, - "chokidar@4.0.3": { - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dependencies": [ - "readdirp" - ] + "chai@6.2.2": { + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==" }, "cli-color@2.0.4": { "integrity": "sha512-zlnpg0jNcibNrO7GG9IeHH7maWFeCz+Ja1wx/7tZNU5ASSSSZ+/qZciM0/LHCYxSdqv5h2sdbQ/PXYdOuetXvA==", @@ -940,9 +817,6 @@ "decimal.js@10.6.0": { "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==" }, - "deep-eql@5.0.2": { - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==" - }, "deepmerge@4.3.1": { "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" }, @@ -1060,65 +934,35 @@ "scripts": true, "bin": true }, - "esbuild@0.21.5": { - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "optionalDependencies": [ - "@esbuild/aix-ppc64@0.21.5", - "@esbuild/android-arm@0.21.5", - "@esbuild/android-arm64@0.21.5", - "@esbuild/android-x64@0.21.5", - "@esbuild/darwin-arm64@0.21.5", - "@esbuild/darwin-x64@0.21.5", - "@esbuild/freebsd-arm64@0.21.5", - "@esbuild/freebsd-x64@0.21.5", - "@esbuild/linux-arm@0.21.5", - "@esbuild/linux-arm64@0.21.5", - "@esbuild/linux-ia32@0.21.5", - "@esbuild/linux-loong64@0.21.5", - "@esbuild/linux-mips64el@0.21.5", - "@esbuild/linux-ppc64@0.21.5", - "@esbuild/linux-riscv64@0.21.5", - "@esbuild/linux-s390x@0.21.5", - "@esbuild/linux-x64@0.21.5", - "@esbuild/netbsd-x64@0.21.5", - "@esbuild/openbsd-x64@0.21.5", - "@esbuild/sunos-x64@0.21.5", - "@esbuild/win32-arm64@0.21.5", - "@esbuild/win32-ia32@0.21.5", - "@esbuild/win32-x64@0.21.5" - ], - "scripts": true, - "bin": true - }, - "esbuild@0.25.12": { - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "esbuild@0.27.2": { + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "optionalDependencies": [ - "@esbuild/aix-ppc64@0.25.12", - "@esbuild/android-arm@0.25.12", - "@esbuild/android-arm64@0.25.12", - "@esbuild/android-x64@0.25.12", - "@esbuild/darwin-arm64@0.25.12", - "@esbuild/darwin-x64@0.25.12", - "@esbuild/freebsd-arm64@0.25.12", - "@esbuild/freebsd-x64@0.25.12", - "@esbuild/linux-arm@0.25.12", - "@esbuild/linux-arm64@0.25.12", - "@esbuild/linux-ia32@0.25.12", - "@esbuild/linux-loong64@0.25.12", - "@esbuild/linux-mips64el@0.25.12", - "@esbuild/linux-ppc64@0.25.12", - "@esbuild/linux-riscv64@0.25.12", - "@esbuild/linux-s390x@0.25.12", - "@esbuild/linux-x64@0.25.12", + "@esbuild/aix-ppc64@0.27.2", + "@esbuild/android-arm@0.27.2", + "@esbuild/android-arm64@0.27.2", + "@esbuild/android-x64@0.27.2", + "@esbuild/darwin-arm64@0.27.2", + "@esbuild/darwin-x64@0.27.2", + "@esbuild/freebsd-arm64@0.27.2", + "@esbuild/freebsd-x64@0.27.2", + "@esbuild/linux-arm@0.27.2", + "@esbuild/linux-arm64@0.27.2", + "@esbuild/linux-ia32@0.27.2", + "@esbuild/linux-loong64@0.27.2", + "@esbuild/linux-mips64el@0.27.2", + "@esbuild/linux-ppc64@0.27.2", + "@esbuild/linux-riscv64@0.27.2", + "@esbuild/linux-s390x@0.27.2", + "@esbuild/linux-x64@0.27.2", "@esbuild/netbsd-arm64", - "@esbuild/netbsd-x64@0.25.12", + "@esbuild/netbsd-x64@0.27.2", "@esbuild/openbsd-arm64", - "@esbuild/openbsd-x64@0.25.12", + "@esbuild/openbsd-x64@0.27.2", "@esbuild/openharmony-arm64", - "@esbuild/sunos-x64@0.25.12", - "@esbuild/win32-arm64@0.25.12", - "@esbuild/win32-ia32@0.25.12", - "@esbuild/win32-x64@0.25.12" + "@esbuild/sunos-x64@0.27.2", + "@esbuild/win32-arm64@0.27.2", + "@esbuild/win32-ia32@0.27.2", + "@esbuild/win32-x64@0.27.2" ], "scripts": true, "bin": true @@ -1318,15 +1162,9 @@ "xml-name-validator" ] }, - "kleur@4.1.5": { - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" - }, "locate-character@3.0.0": { "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" }, - "loupe@3.2.1": { - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==" - }, "lru-cache@10.4.3": { "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, @@ -1393,17 +1231,17 @@ "nwsapi@2.2.23": { "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==" }, + "obug@2.1.1": { + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==" + }, "parse5@7.3.0": { "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dependencies": [ "entities" ] }, - "pathe@1.1.2": { - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==" - }, - "pathval@2.0.1": { - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==" + "pathe@2.0.3": { + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" }, "picocolors@1.1.1": { "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" @@ -1433,9 +1271,6 @@ "react-is@17.0.2": { "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, - "readdirp@4.1.2": { - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" - }, "redent@3.0.0": { "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dependencies": [ @@ -1443,8 +1278,8 @@ "strip-indent" ] }, - "rollup@4.53.3": { - "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", + "rollup@4.54.0": { + "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==", "dependencies": [ "@types/estree" ], @@ -1514,20 +1349,7 @@ "min-indent" ] }, - "svelte-check@4.3.5_svelte@5.45.10__acorn@8.15.0_typescript@5.9.3": { - "integrity": "sha512-e4VWZETyXaKGhpkxOXP+B/d0Fp/zKViZoJmneZWe/05Y2aqSKj3YN2nLfYPJBQ87WEiY4BQCQ9hWGu9mPT1a1Q==", - "dependencies": [ - "@jridgewell/trace-mapping", - "chokidar", - "fdir", - "picocolors", - "sade", - "svelte", - "typescript" - ], - "bin": true - }, - "svelte-i18n@4.0.1_svelte@5.45.10__acorn@8.15.0": { + "svelte-i18n@4.0.1_svelte@5.46.1__acorn@8.15.0": { "integrity": "sha512-jaykGlGT5PUaaq04JWbJREvivlCnALtT+m87Kbm0fxyYHynkQaxQMnIKHLm2WeIuBRoljzwgyvz0Z6/CMwfdmQ==", "dependencies": [ "cli-color", @@ -1541,8 +1363,8 @@ ], "bin": true }, - "svelte@5.45.10_acorn@8.15.0": { - "integrity": "sha512-GiWXq6akkEN3zVDMQ1BVlRolmks5JkEdzD/67mvXOz6drRfuddT5JwsGZjMGSnsTRv/PjAXX8fqBcOr2g2qc/Q==", + "svelte@5.46.1_acorn@8.15.0": { + "integrity": "sha512-ynjfCHD3nP2el70kN5Pmg37sSi0EjOm9FgHYQdC4giWG/hzO3AatzXXJJgP305uIhGQxSufJLuYWtkY8uK/8RA==", "dependencies": [ "@jridgewell/remapping", "@jridgewell/sourcemap-codec", @@ -1581,8 +1403,8 @@ "tinybench@2.9.0": { "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==" }, - "tinyexec@0.3.2": { - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==" + "tinyexec@1.0.2": { + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==" }, "tinyglobby@0.2.15_picomatch@4.0.3": { "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", @@ -1591,14 +1413,8 @@ "picomatch" ] }, - "tinypool@1.1.1": { - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==" - }, - "tinyrainbow@1.2.0": { - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==" - }, - "tinyspy@3.0.2": { - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==" + "tinyrainbow@3.0.3": { + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==" }, "tldts-core@6.1.86": { "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==" @@ -1628,40 +1444,13 @@ "type@2.7.3": { "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==" }, - "typescript@5.9.3": { - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "bin": true - }, - "unicode-segmenter@0.14.4": { - "integrity": "sha512-pR5VCiCrLrKOL6FRW61jnk9+wyMtKKowq+jyFY9oc6uHbWKhDL4yVRiI4YZPksGMK72Pahh8m0cn/0JvbDDyJg==" + "unicode-segmenter@0.14.5": { + "integrity": "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==" }, - "vite-node@2.1.9": { - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "vite@7.3.0_picomatch@4.0.3": { + "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dependencies": [ - "cac", - "debug", - "es-module-lexer", - "pathe", - "vite@5.4.21" - ], - "bin": true - }, - "vite@5.4.21": { - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dependencies": [ - "esbuild@0.21.5", - "postcss", - "rollup" - ], - "optionalDependencies": [ - "fsevents" - ], - "bin": true - }, - "vite@6.4.1_picomatch@4.0.3": { - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dependencies": [ - "esbuild@0.25.12", + "esbuild@0.27.2", "fdir", "picomatch", "postcss", @@ -1673,17 +1462,17 @@ ], "bin": true }, - "vitefu@1.1.1_vite@6.4.1__picomatch@4.0.3": { + "vitefu@1.1.1_vite@7.3.0__picomatch@4.0.3": { "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", "dependencies": [ - "vite@6.4.1_picomatch@4.0.3" + "vite" ], "optionalPeers": [ - "vite@6.4.1_picomatch@4.0.3" + "vite" ] }, - "vitest@2.1.9_jsdom@25.0.1_vite@5.4.21": { - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "vitest@4.0.16_jsdom@25.0.1_vite@7.3.0__picomatch@4.0.3": { + "integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==", "dependencies": [ "@vitest/expect", "@vitest/mocker", @@ -1692,19 +1481,19 @@ "@vitest/snapshot", "@vitest/spy", "@vitest/utils", - "chai", - "debug", + "es-module-lexer", "expect-type", "jsdom", "magic-string", + "obug", "pathe", + "picomatch", "std-env", "tinybench", "tinyexec", - "tinypool", + "tinyglobby", "tinyrainbow", - "vite@5.4.21", - "vite-node", + "vite", "why-is-node-running" ], "optionalPeers": [ @@ -1725,7 +1514,8 @@ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", "dependencies": [ "iconv-lite" - ] + ], + "deprecated": true }, "whatwg-mimetype@4.0.0": { "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==" @@ -1756,6 +1546,9 @@ }, "zimmerframe@1.1.4": { "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" + }, + "zod@4.3.5": { + "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==" } }, "workspace": { @@ -1765,17 +1558,18 @@ "npm:@atcute/crypto@^2.3.0", "npm:@atcute/did-plc@~0.3.1", "npm:@atcute/multibase@^1.1.6", - "npm:@noble/secp256k1@^2.1.0", - "npm:@sveltejs/vite-plugin-svelte@5", - "npm:@testing-library/jest-dom@^6.6.3", - "npm:@testing-library/svelte@^5.2.6", - "npm:@testing-library/user-event@^14.5.2", + "npm:@noble/secp256k1@3", + "npm:@sveltejs/vite-plugin-svelte@^6.2.1", + "npm:@testing-library/jest-dom@^6.9.1", + "npm:@testing-library/svelte@^5.3.1", + "npm:@testing-library/user-event@^14.6.1", "npm:jsdom@^25.0.1", - "npm:multiformats@^13.3.1", + "npm:multiformats@^13.4.2", "npm:svelte-i18n@^4.0.1", - "npm:svelte@5", - "npm:vite@6", - "npm:vitest@^2.1.8" + "npm:svelte@^5.46.1", + "npm:vite@^7.3.0", + "npm:vitest@^4.0.16", + "npm:zod@^4.3.5" ] } } diff --git a/frontend/package.json b/frontend/package.json index 875a16e..d0aa605 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,18 +16,19 @@ "@atcute/crypto": "^2.3.0", "@atcute/did-plc": "^0.3.1", "@atcute/multibase": "^1.1.6", - "@noble/secp256k1": "^2.1.0", - "multiformats": "^13.3.1", - "svelte-i18n": "^4.0.1" + "@noble/secp256k1": "^3.0.0", + "multiformats": "^13.4.2", + "svelte-i18n": "^4.0.1", + "zod": "^4.3.5" }, "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/svelte": "^5.2.6", - "@testing-library/user-event": "^14.5.2", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/svelte": "^5.3.1", + "@testing-library/user-event": "^14.6.1", "jsdom": "^25.0.1", - "svelte": "^5.0.0", - "vite": "^6.0.0", - "vitest": "^2.1.8" + "svelte": "^5.46.1", + "vite": "^7.3.0", + "vitest": "^4.0.16" } } diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 5bac107..1adaa5a 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -4,6 +4,7 @@ import { initServerConfig } from './lib/serverConfig.svelte' import { initI18n } from './lib/i18n' import { isLoading as i18nLoading } from 'svelte-i18n' + import Toast from './components/Toast.svelte' import Login from './routes/Login.svelte' import Register from './routes/Register.svelte' import RegisterPasskey from './routes/RegisterPasskey.svelte' @@ -36,7 +37,7 @@ import DidDocumentEditor from './routes/DidDocumentEditor.svelte' initI18n() - const auth = getAuthState() + const auth = $derived(getAuthState()) let oauthCallbackPending = $state(hasOAuthCallback()) @@ -59,10 +60,10 @@ }) $effect(() => { - if (auth.loading) return + if (auth.kind === 'loading') return const path = getCurrentPath() if (path === '/') { - if (auth.session) { + if (auth.kind === 'authenticated') { navigate('/dashboard', true) } else { navigate('/login', true) @@ -142,14 +143,13 @@
- {#if auth.loading || $i18nLoading || oauthCallbackPending} -
-

Loading...

-
+ {#if auth.kind === 'loading' || $i18nLoading || oauthCallbackPending} +
{:else} {/if}
+ diff --git a/frontend/src/components/ReauthModal.svelte b/frontend/src/components/ReauthModal.svelte index 56e91aa..e0a27ae 100644 --- a/frontend/src/components/ReauthModal.svelte +++ b/frontend/src/components/ReauthModal.svelte @@ -2,6 +2,12 @@ import { getAuthState, getValidToken } from '../lib/auth.svelte' import { api, ApiError } from '../lib/api' import { _ } from '../lib/i18n' + import type { Session } from '../lib/types/api' + import { + prepareRequestOptions, + serializeAssertionResponse, + type WebAuthnRequestOptionsResponse, + } from '../lib/webauthn' interface Props { show: boolean @@ -12,7 +18,13 @@ let { show = $bindable(), availableMethods = ['password'], onSuccess, onCancel }: Props = $props() - const auth = getAuthState() + const auth = $derived(getAuthState()) + + function getSession(): Session | null { + return auth.kind === 'authenticated' ? auth.session : null + } + + const session = $derived(getSession()) let activeMethod = $state<'password' | 'totp' | 'passkey'>('password') let password = $state('') let totpCode = $state('') @@ -37,40 +49,9 @@ } }) - function arrayBufferToBase64Url(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer) - let binary = '' - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]) - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') - } - - function base64UrlToArrayBuffer(base64url: string): ArrayBuffer { - const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/') - const padded = base64 + '='.repeat((4 - base64.length % 4) % 4) - const binary = atob(padded) - const bytes = new Uint8Array(binary.length) - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i) - } - return bytes.buffer - } - - function prepareAuthOptions(options: any): PublicKeyCredentialRequestOptions { - return { - ...options.publicKey, - challenge: base64UrlToArrayBuffer(options.publicKey.challenge), - allowCredentials: options.publicKey.allowCredentials?.map((cred: any) => ({ - ...cred, - id: base64UrlToArrayBuffer(cred.id) - })) || [] - } - } - async function handlePasswordSubmit(e: Event) { e.preventDefault() - if (!auth.session || !password) return + if (!session || !password) return loading = true error = '' try { @@ -91,7 +72,7 @@ async function handleTotpSubmit(e: Event) { e.preventDefault() - if (!auth.session || !totpCode) return + if (!session || !totpCode) return loading = true error = '' try { @@ -111,7 +92,7 @@ } async function handlePasskeyAuth() { - if (!auth.session) return + if (!session) return if (!window.PublicKeyCredential) { error = 'Passkeys are not supported in this browser' return @@ -125,7 +106,7 @@ return } const { options } = await api.reauthPasskeyStart(token) - const publicKeyOptions = prepareAuthOptions(options) + const publicKeyOptions = prepareRequestOptions(options as WebAuthnRequestOptionsResponse) const credential = await navigator.credentials.get({ publicKey: publicKeyOptions }) @@ -133,19 +114,7 @@ error = 'Passkey authentication was cancelled' return } - const pkCredential = credential as PublicKeyCredential - const response = pkCredential.response as AuthenticatorAssertionResponse - const credentialResponse = { - id: pkCredential.id, - type: pkCredential.type, - rawId: arrayBufferToBase64Url(pkCredential.rawId), - response: { - clientDataJSON: arrayBufferToBase64Url(response.clientDataJSON), - authenticatorData: arrayBufferToBase64Url(response.authenticatorData), - signature: arrayBufferToBase64Url(response.signature), - userHandle: response.userHandle ? arrayBufferToBase64Url(response.userHandle) : null, - }, - } + const credentialResponse = serializeAssertionResponse(credential as PublicKeyCredential) await api.reauthPasskeyFinish(token, credentialResponse) show = false onSuccess() diff --git a/frontend/src/components/Skeleton.svelte b/frontend/src/components/Skeleton.svelte new file mode 100644 index 0000000..4fbb16e --- /dev/null +++ b/frontend/src/components/Skeleton.svelte @@ -0,0 +1,77 @@ + + +{#if variant === 'card'} +
+
+
+
+
+ {#each Array(lines) as _} +
+ {/each} +
+
+{:else if variant === 'circle'} +
+{:else} + {#each Array(lines) as _, i} +
+ {/each} +{/if} + + diff --git a/frontend/src/components/Toast.svelte b/frontend/src/components/Toast.svelte new file mode 100644 index 0000000..ab77c00 --- /dev/null +++ b/frontend/src/components/Toast.svelte @@ -0,0 +1,188 @@ + + +{#if toasts.length > 0} +
+ {#each toasts as toast (toast.id)} + + {/each} +
+{/if} + + diff --git a/frontend/src/lib/api-validated.ts b/frontend/src/lib/api-validated.ts new file mode 100644 index 0000000..827eff8 --- /dev/null +++ b/frontend/src/lib/api-validated.ts @@ -0,0 +1,345 @@ +import { z } from 'zod' +import { ok, err, type Result } from './types/result' +import { ApiError } from './api' +import type { AccessToken, RefreshToken, Did, Handle, Nsid, Rkey } from './types/branded' +import { + sessionSchema, + serverDescriptionSchema, + appPasswordSchema, + createdAppPasswordSchema, + listSessionsResponseSchema, + totpStatusSchema, + totpSecretSchema, + enableTotpResponseSchema, + listPasskeysResponseSchema, + listTrustedDevicesResponseSchema, + reauthStatusSchema, + notificationPrefsSchema, + didDocumentSchema, + repoDescriptionSchema, + listRecordsResponseSchema, + recordResponseSchema, + createRecordResponseSchema, + serverStatsSchema, + serverConfigSchema, + passwordStatusSchema, + successResponseSchema, + legacyLoginPreferenceSchema, + accountInfoSchema, + searchAccountsResponseSchema, + listBackupsResponseSchema, + createBackupResponseSchema, + type ValidatedSession, + type ValidatedServerDescription, + type ValidatedListSessionsResponse, + type ValidatedTotpStatus, + type ValidatedTotpSecret, + type ValidatedEnableTotpResponse, + type ValidatedListPasskeysResponse, + type ValidatedListTrustedDevicesResponse, + type ValidatedReauthStatus, + type ValidatedNotificationPrefs, + type ValidatedDidDocument, + type ValidatedRepoDescription, + type ValidatedListRecordsResponse, + type ValidatedRecordResponse, + type ValidatedCreateRecordResponse, + type ValidatedServerStats, + type ValidatedServerConfig, + type ValidatedPasswordStatus, + type ValidatedSuccessResponse, + type ValidatedLegacyLoginPreference, + type ValidatedAccountInfo, + type ValidatedSearchAccountsResponse, + type ValidatedListBackupsResponse, + type ValidatedCreateBackupResponse, + type ValidatedCreatedAppPassword, + type ValidatedAppPassword, +} from './types/schemas' + +const API_BASE = '/xrpc' + +interface XrpcOptions { + method?: 'GET' | 'POST' + params?: Record + body?: unknown + token?: string +} + +class ValidationError extends Error { + constructor( + public issues: z.ZodIssue[], + message: string = 'API response validation failed' + ) { + super(message) + this.name = 'ValidationError' + } +} + +async function xrpcValidated( + method: string, + schema: z.ZodType, + options?: XrpcOptions +): Promise> { + const { method: httpMethod = 'GET', params, body, token } = options ?? {} + let url = `${API_BASE}/${method}` + if (params) { + const searchParams = new URLSearchParams(params) + url += `?${searchParams}` + } + const headers: Record = {} + if (token) { + headers['Authorization'] = `Bearer ${token}` + } + if (body) { + headers['Content-Type'] = 'application/json' + } + + try { + const res = await fetch(url, { + method: httpMethod, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + + if (!res.ok) { + const errData = await res.json().catch(() => ({ + error: 'Unknown', + message: res.statusText, + })) + return err(new ApiError(res.status, errData.error, errData.message)) + } + + const data = await res.json() + const parsed = schema.safeParse(data) + + if (!parsed.success) { + return err(new ValidationError(parsed.error.issues)) + } + + return ok(parsed.data) + } catch (e) { + if (e instanceof ApiError || e instanceof ValidationError) { + return err(e) + } + return err(new ApiError(0, 'Unknown', e instanceof Error ? e.message : String(e))) + } +} + +export const validatedApi = { + getSession(token: AccessToken): Promise> { + return xrpcValidated('com.atproto.server.getSession', sessionSchema, { token }) + }, + + refreshSession(refreshJwt: RefreshToken): Promise> { + return xrpcValidated('com.atproto.server.refreshSession', sessionSchema, { + method: 'POST', + token: refreshJwt, + }) + }, + + createSession( + identifier: string, + password: string + ): Promise> { + return xrpcValidated('com.atproto.server.createSession', sessionSchema, { + method: 'POST', + body: { identifier, password }, + }) + }, + + describeServer(): Promise> { + return xrpcValidated('com.atproto.server.describeServer', serverDescriptionSchema) + }, + + listAppPasswords( + token: AccessToken + ): Promise> { + return xrpcValidated( + 'com.atproto.server.listAppPasswords', + z.object({ passwords: z.array(appPasswordSchema) }), + { token } + ) + }, + + createAppPassword( + token: AccessToken, + name: string, + scopes?: string + ): Promise> { + return xrpcValidated('com.atproto.server.createAppPassword', createdAppPasswordSchema, { + method: 'POST', + token, + body: { name, scopes }, + }) + }, + + listSessions(token: AccessToken): Promise> { + return xrpcValidated('_account.listSessions', listSessionsResponseSchema, { token }) + }, + + getTotpStatus(token: AccessToken): Promise> { + return xrpcValidated('com.atproto.server.getTotpStatus', totpStatusSchema, { token }) + }, + + createTotpSecret(token: AccessToken): Promise> { + return xrpcValidated('com.atproto.server.createTotpSecret', totpSecretSchema, { + method: 'POST', + token, + }) + }, + + enableTotp( + token: AccessToken, + code: string + ): Promise> { + return xrpcValidated('com.atproto.server.enableTotp', enableTotpResponseSchema, { + method: 'POST', + token, + body: { code }, + }) + }, + + listPasskeys(token: AccessToken): Promise> { + return xrpcValidated('com.atproto.server.listPasskeys', listPasskeysResponseSchema, { token }) + }, + + listTrustedDevices( + token: AccessToken + ): Promise> { + return xrpcValidated('_account.listTrustedDevices', listTrustedDevicesResponseSchema, { token }) + }, + + getReauthStatus(token: AccessToken): Promise> { + return xrpcValidated('_account.getReauthStatus', reauthStatusSchema, { token }) + }, + + getNotificationPrefs( + token: AccessToken + ): Promise> { + return xrpcValidated('_account.getNotificationPrefs', notificationPrefsSchema, { token }) + }, + + getDidDocument(token: AccessToken): Promise> { + return xrpcValidated('_account.getDidDocument', didDocumentSchema, { token }) + }, + + describeRepo( + token: AccessToken, + repo: Did + ): Promise> { + return xrpcValidated('com.atproto.repo.describeRepo', repoDescriptionSchema, { + token, + params: { repo }, + }) + }, + + listRecords( + token: AccessToken, + repo: Did, + collection: Nsid, + options?: { limit?: number; cursor?: string; reverse?: boolean } + ): Promise> { + const params: Record = { repo, collection } + if (options?.limit) params.limit = String(options.limit) + if (options?.cursor) params.cursor = options.cursor + if (options?.reverse) params.reverse = 'true' + return xrpcValidated('com.atproto.repo.listRecords', listRecordsResponseSchema, { + token, + params, + }) + }, + + getRecord( + token: AccessToken, + repo: Did, + collection: Nsid, + rkey: Rkey + ): Promise> { + return xrpcValidated('com.atproto.repo.getRecord', recordResponseSchema, { + token, + params: { repo, collection, rkey }, + }) + }, + + createRecord( + token: AccessToken, + repo: Did, + collection: Nsid, + record: unknown, + rkey?: Rkey + ): Promise> { + return xrpcValidated('com.atproto.repo.createRecord', createRecordResponseSchema, { + method: 'POST', + token, + body: { repo, collection, record, rkey }, + }) + }, + + getServerStats(token: AccessToken): Promise> { + return xrpcValidated('_admin.getServerStats', serverStatsSchema, { token }) + }, + + getServerConfig(): Promise> { + return xrpcValidated('_server.getConfig', serverConfigSchema) + }, + + getPasswordStatus(token: AccessToken): Promise> { + return xrpcValidated('_account.getPasswordStatus', passwordStatusSchema, { token }) + }, + + changePassword( + token: AccessToken, + currentPassword: string, + newPassword: string + ): Promise> { + return xrpcValidated('_account.changePassword', successResponseSchema, { + method: 'POST', + token, + body: { currentPassword, newPassword }, + }) + }, + + getLegacyLoginPreference( + token: AccessToken + ): Promise> { + return xrpcValidated('_account.getLegacyLoginPreference', legacyLoginPreferenceSchema, { token }) + }, + + getAccountInfo( + token: AccessToken, + did: Did + ): Promise> { + return xrpcValidated('com.atproto.admin.getAccountInfo', accountInfoSchema, { + token, + params: { did }, + }) + }, + + searchAccounts( + token: AccessToken, + options?: { handle?: string; cursor?: string; limit?: number } + ): Promise> { + const params: Record = {} + if (options?.handle) params.handle = options.handle + if (options?.cursor) params.cursor = options.cursor + if (options?.limit) params.limit = String(options.limit) + return xrpcValidated('com.atproto.admin.searchAccounts', searchAccountsResponseSchema, { + token, + params, + }) + }, + + listBackups(token: AccessToken): Promise> { + return xrpcValidated('_backup.listBackups', listBackupsResponseSchema, { token }) + }, + + createBackup(token: AccessToken): Promise> { + return xrpcValidated('_backup.createBackup', createBackupResponseSchema, { + method: 'POST', + token, + }) + }, +} + +export { ValidationError } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3cf10c8..339ba8f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1,172 +1,207 @@ -const API_BASE = "/xrpc"; +import { ok, err, type Result } from './types/result' +import type { + Did, + Handle, + AccessToken, + RefreshToken, + Cid, + Rkey, + AtUri, + Nsid, + ISODateString, + EmailAddress, + InviteCode as InviteCodeBrand, +} from './types/branded' +import { + unsafeAsDid, + unsafeAsHandle, + unsafeAsAccessToken, + unsafeAsRefreshToken, + unsafeAsCid, + unsafeAsISODate, + unsafeAsEmail, + unsafeAsInviteCode, +} from './types/branded' +import type { + Session, + DidDocument, + AppPassword, + CreatedAppPassword, + InviteCodeInfo, + ServerDescription, + NotificationPrefs, + NotificationHistoryResponse, + ServerStats, + ServerConfig, + UploadBlobResponse, + ListSessionsResponse, + SearchAccountsResponse, + GetInviteCodesResponse, + AccountInfo, + RepoDescription, + ListRecordsResponse, + RecordResponse, + CreateRecordResponse, + TotpStatus, + TotpSecret, + EnableTotpResponse, + RegenerateBackupCodesResponse, + ListPasskeysResponse, + StartPasskeyRegistrationResponse, + FinishPasskeyRegistrationResponse, + ListTrustedDevicesResponse, + ReauthStatus, + ReauthResponse, + ReauthPasskeyStartResponse, + ReserveSigningKeyResponse, + RecommendedDidCredentials, + PasskeyAccountCreateResponse, + CompletePasskeySetupResponse, + VerifyTokenResponse, + ListBackupsResponse, + CreateBackupResponse, + SetBackupEnabledResponse, + EmailUpdateResponse, + LegacyLoginPreference, + UpdateLegacyLoginResponse, + UpdateLocaleResponse, + PasswordStatus, + SuccessResponse, + CheckEmailVerifiedResponse, + VerifyMigrationEmailResponse, + ResendMigrationVerificationResponse, + ListReposResponse, + VerificationChannel, + DidType, + ApiErrorCode, + VerificationMethod as VerificationMethodType, + CreateAccountParams, + CreateAccountResult, + ConfirmSignupResult, +} from './types/api' + +const API_BASE = '/xrpc' export class ApiError extends Error { - public did?: string; - public reauthMethods?: string[]; + public did?: Did + public reauthMethods?: string[] constructor( public status: number, - public error: string, + public error: ApiErrorCode, message: string, did?: string, reauthMethods?: string[], ) { - super(message); - this.name = "ApiError"; - this.did = did; - this.reauthMethods = reauthMethods; + super(message) + this.name = 'ApiError' + this.did = did ? unsafeAsDid(did) : undefined + this.reauthMethods = reauthMethods } } -let tokenRefreshCallback: (() => Promise) | null = null; +let tokenRefreshCallback: (() => Promise) | null = null export function setTokenRefreshCallback( callback: () => Promise, ) { - tokenRefreshCallback = callback; + tokenRefreshCallback = callback } -async function xrpc(method: string, options?: { - method?: "GET" | "POST"; - params?: Record; - body?: unknown; - token?: string; - skipRetry?: boolean; -}): Promise { - const { method: httpMethod = "GET", params, body, token, skipRetry } = - options ?? {}; - let url = `${API_BASE}/${method}`; +interface XrpcOptions { + method?: 'GET' | 'POST' + params?: Record + body?: unknown + token?: string + skipRetry?: boolean +} + +async function xrpc(method: string, options?: XrpcOptions): Promise { + const { method: httpMethod = 'GET', params, body, token, skipRetry } = + options ?? {} + let url = `${API_BASE}/${method}` if (params) { - const searchParams = new URLSearchParams(params); - url += `?${searchParams}`; + const searchParams = new URLSearchParams(params) + url += `?${searchParams}` } - const headers: Record = {}; + const headers: Record = {} if (token) { - headers["Authorization"] = `Bearer ${token}`; + headers['Authorization'] = `Bearer ${token}` } if (body) { - headers["Content-Type"] = "application/json"; + headers['Content-Type'] = 'application/json' } const res = await fetch(url, { method: httpMethod, headers, body: body ? JSON.stringify(body) : undefined, - }); + }) if (!res.ok) { - const err = await res.json().catch(() => ({ - error: "Unknown", + const errData = await res.json().catch(() => ({ + error: 'Unknown', message: res.statusText, - })); + })) if ( res.status === 401 && - (err.error === "AuthenticationFailed" || err.error === "ExpiredToken") && + (errData.error === 'AuthenticationFailed' || errData.error === 'ExpiredToken') && token && tokenRefreshCallback && !skipRetry ) { - const newToken = await tokenRefreshCallback(); + const newToken = await tokenRefreshCallback() if (newToken && newToken !== token) { - return xrpc(method, { ...options, token: newToken, skipRetry: true }); + return xrpc(method, { ...options, token: newToken, skipRetry: true }) } } throw new ApiError( res.status, - err.error, - err.message, - err.did, - err.reauthMethods, - ); + errData.error as ApiErrorCode, + errData.message, + errData.did, + errData.reauthMethods, + ) } - return res.json(); + return res.json() } -export interface Session { - did: string; - handle: string; - email?: string; - emailConfirmed?: boolean; - preferredChannel?: string; - preferredChannelVerified?: boolean; - isAdmin?: boolean; - active?: boolean; - status?: "active" | "deactivated" | "migrated"; - migratedToPds?: string; - migratedAt?: string; - accessJwt: string; - refreshJwt: string; +async function xrpcResult( + method: string, + options?: XrpcOptions +): Promise> { + try { + const value = await xrpc(method, options) + return ok(value) + } catch (e) { + if (e instanceof ApiError) { + return err(e) + } + return err(new ApiError(0, 'Unknown', e instanceof Error ? e.message : String(e))) + } } export interface VerificationMethod { - id: string; - type: string; - publicKeyMultibase: string; -} - -export interface DidDocument { - "@context": string[]; - id: string; - alsoKnownAs: string[]; - verificationMethod: Array<{ - id: string; - type: string; - controller: string; - publicKeyMultibase: string; - }>; - service: Array<{ - id: string; - type: string; - serviceEndpoint: string; - }>; + id: string + type: string + publicKeyMultibase: string } -export interface AppPassword { - name: string; - createdAt: string; - scopes?: string; - createdByController?: string; -} - -export interface InviteCode { - code: string; - available: number; - disabled: boolean; - forAccount: string; - createdBy: string; - createdAt: string; - uses: { usedBy: string; usedByHandle?: string; usedAt: string }[]; -} - -export type VerificationChannel = "email" | "discord" | "telegram" | "signal"; - -export type DidType = "plc" | "web" | "web-external"; - -export interface CreateAccountParams { - handle: string; - email: string; - password: string; - inviteCode?: string; - didType?: DidType; - did?: string; - signingKey?: string; - verificationChannel?: VerificationChannel; - discordId?: string; - telegramUsername?: string; - signalNumber?: string; -} - -export interface CreateAccountResult { - handle: string; - did: string; - verificationRequired: boolean; - verificationChannel: string; -} - -export interface ConfirmSignupResult { - accessJwt: string; - refreshJwt: string; - handle: string; - did: string; - email?: string; - emailConfirmed?: boolean; - preferredChannel?: string; - preferredChannelVerified?: boolean; +export type { Session, DidDocument, AppPassword, InviteCodeInfo as InviteCode } +export type { VerificationChannel, DidType, CreateAccountParams, CreateAccountResult, ConfirmSignupResult } + +function castSession(raw: unknown): Session { + const s = raw as Record + return { + did: unsafeAsDid(s.did as string), + handle: unsafeAsHandle(s.handle as string), + email: s.email ? unsafeAsEmail(s.email as string) : undefined, + emailConfirmed: s.emailConfirmed as boolean | undefined, + preferredChannel: s.preferredChannel as VerificationChannel | undefined, + preferredChannelVerified: s.preferredChannelVerified as boolean | undefined, + isAdmin: s.isAdmin as boolean | undefined, + active: s.active as boolean | undefined, + status: s.status as Session['status'], + migratedToPds: s.migratedToPds as string | undefined, + migratedAt: s.migratedAt ? unsafeAsISODate(s.migratedAt as string) : undefined, + accessJwt: unsafeAsAccessToken(s.accessJwt as string), + refreshJwt: unsafeAsRefreshToken(s.refreshJwt as string), + } } export const api = { @@ -174,15 +209,15 @@ export const api = { params: CreateAccountParams, byodToken?: string, ): Promise { - const url = `${API_BASE}/com.atproto.server.createAccount`; + const url = `${API_BASE}/com.atproto.server.createAccount` const headers: Record = { - "Content-Type": "application/json", - }; + 'Content-Type': 'application/json', + } if (byodToken) { - headers["Authorization"] = `Bearer ${byodToken}`; + headers['Authorization'] = `Bearer ${byodToken}` } const response = await fetch(url, { - method: "POST", + method: 'POST', headers, body: JSON.stringify({ handle: params.handle, @@ -197,30 +232,30 @@ export const api = { telegramUsername: params.telegramUsername, signalNumber: params.signalNumber, }), - }); - const data = await response.json(); + }) + const data = await response.json() if (!response.ok) { - throw new ApiError(response.status, data.error, data.message); + throw new ApiError(response.status, data.error, data.message) } - return data; + return data }, async createAccountWithServiceAuth( serviceAuthToken: string, params: { - did: string; - handle: string; - email: string; - password: string; - inviteCode?: string; + did: Did + handle: Handle + email: EmailAddress + password: string + inviteCode?: string }, ): Promise { - const url = `${API_BASE}/com.atproto.server.createAccount`; + const url = `${API_BASE}/com.atproto.server.createAccount` const response = await fetch(url, { - method: "POST", + method: 'POST', headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${serviceAuthToken}`, + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${serviceAuthToken}`, }, body: JSON.stringify({ did: params.did, @@ -229,1023 +264,1387 @@ export const api = { password: params.password, inviteCode: params.inviteCode, }), - }); - const data = await response.json(); + }) + const data = await response.json() if (!response.ok) { - throw new ApiError(response.status, data.error, data.message); + throw new ApiError(response.status, data.error, data.message) } - return data; + return castSession(data) }, confirmSignup( - did: string, + did: Did, verificationCode: string, ): Promise { - return xrpc("com.atproto.server.confirmSignup", { - method: "POST", + return xrpc('com.atproto.server.confirmSignup', { + method: 'POST', body: { did, verificationCode }, - }); + }) }, - resendVerification(did: string): Promise<{ success: boolean }> { - return xrpc("com.atproto.server.resendVerification", { - method: "POST", + resendVerification(did: Did): Promise<{ success: boolean }> { + return xrpc('com.atproto.server.resendVerification', { + method: 'POST', body: { did }, - }); + }) }, - createSession(identifier: string, password: string): Promise { - return xrpc("com.atproto.server.createSession", { - method: "POST", + async createSession(identifier: string, password: string): Promise { + const raw = await xrpc('com.atproto.server.createSession', { + method: 'POST', body: { identifier, password }, - }); + }) + return castSession(raw) }, checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { - return xrpc("_checkEmailVerified", { - method: "POST", + return xrpc('_checkEmailVerified', { + method: 'POST', body: { identifier }, - }); + }) }, - getSession(token: string): Promise { - return xrpc("com.atproto.server.getSession", { token }); + async getSession(token: AccessToken): Promise { + const raw = await xrpc('com.atproto.server.getSession', { token }) + return castSession(raw) }, - refreshSession(refreshJwt: string): Promise { - return xrpc("com.atproto.server.refreshSession", { - method: "POST", + async refreshSession(refreshJwt: RefreshToken): Promise { + const raw = await xrpc('com.atproto.server.refreshSession', { + method: 'POST', token: refreshJwt, - }); + }) + return castSession(raw) }, - async deleteSession(token: string): Promise { - await xrpc("com.atproto.server.deleteSession", { - method: "POST", + async deleteSession(token: AccessToken): Promise { + await xrpc('com.atproto.server.deleteSession', { + method: 'POST', token, - }); + }) }, - listAppPasswords(token: string): Promise<{ passwords: AppPassword[] }> { - return xrpc("com.atproto.server.listAppPasswords", { token }); + listAppPasswords(token: AccessToken): Promise<{ passwords: AppPassword[] }> { + return xrpc('com.atproto.server.listAppPasswords', { token }) }, createAppPassword( - token: string, + token: AccessToken, name: string, scopes?: string, - ): Promise< - { name: string; password: string; createdAt: string; scopes?: string } - > { - return xrpc("com.atproto.server.createAppPassword", { - method: "POST", + ): Promise { + return xrpc('com.atproto.server.createAppPassword', { + method: 'POST', token, body: { name, scopes }, - }); + }) }, - async revokeAppPassword(token: string, name: string): Promise { - await xrpc("com.atproto.server.revokeAppPassword", { - method: "POST", + async revokeAppPassword(token: AccessToken, name: string): Promise { + await xrpc('com.atproto.server.revokeAppPassword', { + method: 'POST', token, body: { name }, - }); + }) }, - getAccountInviteCodes(token: string): Promise<{ codes: InviteCode[] }> { - return xrpc("com.atproto.server.getAccountInviteCodes", { token }); + getAccountInviteCodes(token: AccessToken): Promise<{ codes: InviteCodeInfo[] }> { + return xrpc('com.atproto.server.getAccountInviteCodes', { token }) }, createInviteCode( - token: string, + token: AccessToken, useCount: number = 1, ): Promise<{ code: string }> { - return xrpc("com.atproto.server.createInviteCode", { - method: "POST", + return xrpc('com.atproto.server.createInviteCode', { + method: 'POST', token, body: { useCount }, - }); + }) }, - async requestPasswordReset(email: string): Promise { - await xrpc("com.atproto.server.requestPasswordReset", { - method: "POST", + async requestPasswordReset(email: EmailAddress): Promise { + await xrpc('com.atproto.server.requestPasswordReset', { + method: 'POST', body: { email }, - }); + }) }, async resetPassword(token: string, password: string): Promise { - await xrpc("com.atproto.server.resetPassword", { - method: "POST", + await xrpc('com.atproto.server.resetPassword', { + method: 'POST', body: { token, password }, - }); + }) }, - requestEmailUpdate( - token: string, - ): Promise<{ tokenRequired: boolean }> { - return xrpc("com.atproto.server.requestEmailUpdate", { - method: "POST", + requestEmailUpdate(token: AccessToken): Promise { + return xrpc('com.atproto.server.requestEmailUpdate', { + method: 'POST', token, - }); + }) }, async updateEmail( - token: string, + token: AccessToken, email: string, emailToken?: string, ): Promise { - await xrpc("com.atproto.server.updateEmail", { - method: "POST", + await xrpc('com.atproto.server.updateEmail', { + method: 'POST', token, body: { email, token: emailToken }, - }); + }) }, - async updateHandle(token: string, handle: string): Promise { - await xrpc("com.atproto.identity.updateHandle", { - method: "POST", + async updateHandle(token: AccessToken, handle: Handle): Promise { + await xrpc('com.atproto.identity.updateHandle', { + method: 'POST', token, body: { handle }, - }); + }) }, - async requestAccountDelete(token: string): Promise { - await xrpc("com.atproto.server.requestAccountDelete", { - method: "POST", + async requestAccountDelete(token: AccessToken): Promise { + await xrpc('com.atproto.server.requestAccountDelete', { + method: 'POST', token, - }); + }) }, async deleteAccount( - did: string, + did: Did, password: string, deleteToken: string, ): Promise { - await xrpc("com.atproto.server.deleteAccount", { - method: "POST", + await xrpc('com.atproto.server.deleteAccount', { + method: 'POST', body: { did, password, token: deleteToken }, - }); - }, - - describeServer(): Promise<{ - availableUserDomains: string[]; - inviteCodeRequired: boolean; - links?: { privacyPolicy?: string; termsOfService?: string }; - version?: string; - availableCommsChannels?: string[]; - selfHostedDidWebEnabled?: boolean; - }> { - return xrpc("com.atproto.server.describeServer"); - }, - - listRepos(limit?: number): Promise<{ - repos: Array<{ did: string; head: string; rev: string }>; - cursor?: string; - }> { - const params: Record = {}; - if (limit) params.limit = String(limit); - return xrpc("com.atproto.sync.listRepos", { params }); - }, - - getNotificationPrefs(token: string): Promise<{ - preferredChannel: string; - email: string; - discordId: string | null; - discordVerified: boolean; - telegramUsername: string | null; - telegramVerified: boolean; - signalNumber: string | null; - signalVerified: boolean; - }> { - return xrpc("_account.getNotificationPrefs", { token }); - }, - - updateNotificationPrefs(token: string, prefs: { - preferredChannel?: string; - discordId?: string; - telegramUsername?: string; - signalNumber?: string; - }): Promise<{ success: boolean }> { - return xrpc("_account.updateNotificationPrefs", { - method: "POST", + }) + }, + + describeServer(): Promise { + return xrpc('com.atproto.server.describeServer') + }, + + listRepos(limit?: number): Promise { + const params: Record = {} + if (limit) params.limit = String(limit) + return xrpc('com.atproto.sync.listRepos', { params }) + }, + + getNotificationPrefs(token: AccessToken): Promise { + return xrpc('_account.getNotificationPrefs', { token }) + }, + + updateNotificationPrefs(token: AccessToken, prefs: { + preferredChannel?: string + discordId?: string + telegramUsername?: string + signalNumber?: string + }): Promise { + return xrpc('_account.updateNotificationPrefs', { + method: 'POST', token, body: prefs, - }); + }) }, confirmChannelVerification( - token: string, + token: AccessToken, channel: string, identifier: string, code: string, - ): Promise<{ success: boolean }> { - return xrpc("_account.confirmChannelVerification", { - method: "POST", + ): Promise { + return xrpc('_account.confirmChannelVerification', { + method: 'POST', token, body: { channel, identifier, code }, - }); - }, - - getNotificationHistory(token: string): Promise<{ - notifications: Array<{ - createdAt: string; - channel: string; - notificationType: string; - status: string; - subject: string | null; - body: string; - }>; - }> { - return xrpc("_account.getNotificationHistory", { token }); - }, - - getServerStats(token: string): Promise<{ - userCount: number; - repoCount: number; - recordCount: number; - blobStorageBytes: number; - }> { - return xrpc("_admin.getServerStats", { token }); - }, - - getServerConfig(): Promise<{ - serverName: string; - primaryColor: string | null; - primaryColorDark: string | null; - secondaryColor: string | null; - secondaryColorDark: string | null; - logoCid: string | null; - }> { - return xrpc("_server.getConfig"); + }) + }, + + getNotificationHistory(token: AccessToken): Promise { + return xrpc('_account.getNotificationHistory', { token }) + }, + + getServerStats(token: AccessToken): Promise { + return xrpc('_admin.getServerStats', { token }) + }, + + getServerConfig(): Promise { + return xrpc('_server.getConfig') }, updateServerConfig( - token: string, + token: AccessToken, config: { - serverName?: string; - primaryColor?: string; - primaryColorDark?: string; - secondaryColor?: string; - secondaryColorDark?: string; - logoCid?: string; + serverName?: string + primaryColor?: string + primaryColorDark?: string + secondaryColor?: string + secondaryColorDark?: string + logoCid?: string }, - ): Promise<{ success: boolean }> { - return xrpc("_admin.updateServerConfig", { - method: "POST", + ): Promise { + return xrpc('_admin.updateServerConfig', { + method: 'POST', token, body: config, - }); + }) }, - async uploadBlob( - token: string, - file: File, - ): Promise< - { - blob: { - $type: string; - ref: { $link: string }; - mimeType: string; - size: number; - }; - } - > { - const res = await fetch("/xrpc/com.atproto.repo.uploadBlob", { - method: "POST", + async uploadBlob(token: AccessToken, file: File): Promise { + const res = await fetch('/xrpc/com.atproto.repo.uploadBlob', { + method: 'POST', headers: { - "Authorization": `Bearer ${token}`, - "Content-Type": file.type, + 'Authorization': `Bearer ${token}`, + 'Content-Type': file.type, }, body: file, - }); + }) if (!res.ok) { - const err = await res.json().catch(() => ({ - error: "Unknown", + const errData = await res.json().catch(() => ({ + error: 'Unknown', message: res.statusText, - })); - throw new ApiError(res.status, err.error, err.message); + })) + throw new ApiError(res.status, errData.error, errData.message) } - return res.json(); + return res.json() }, async changePassword( - token: string, + token: AccessToken, currentPassword: string, newPassword: string, ): Promise { - await xrpc("_account.changePassword", { - method: "POST", + await xrpc('_account.changePassword', { + method: 'POST', token, body: { currentPassword, newPassword }, - }); + }) }, - removePassword(token: string): Promise<{ success: boolean }> { - return xrpc("_account.removePassword", { - method: "POST", + removePassword(token: AccessToken): Promise { + return xrpc('_account.removePassword', { + method: 'POST', token, - }); + }) }, - getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { - return xrpc("_account.getPasswordStatus", { token }); + getPasswordStatus(token: AccessToken): Promise { + return xrpc('_account.getPasswordStatus', { token }) }, - getLegacyLoginPreference( - token: string, - ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { - return xrpc("_account.getLegacyLoginPreference", { token }); + getLegacyLoginPreference(token: AccessToken): Promise { + return xrpc('_account.getLegacyLoginPreference', { token }) }, updateLegacyLoginPreference( - token: string, + token: AccessToken, allowLegacyLogin: boolean, - ): Promise<{ allowLegacyLogin: boolean }> { - return xrpc("_account.updateLegacyLoginPreference", { - method: "POST", + ): Promise { + return xrpc('_account.updateLegacyLoginPreference', { + method: 'POST', token, body: { allowLegacyLogin }, - }); + }) }, - updateLocale( - token: string, - preferredLocale: string, - ): Promise<{ preferredLocale: string }> { - return xrpc("_account.updateLocale", { - method: "POST", + updateLocale(token: AccessToken, preferredLocale: string): Promise { + return xrpc('_account.updateLocale', { + method: 'POST', token, body: { preferredLocale }, - }); + }) }, - listSessions(token: string): Promise<{ - sessions: Array<{ - id: string; - sessionType: string; - clientName: string | null; - createdAt: string; - expiresAt: string; - isCurrent: boolean; - }>; - }> { - return xrpc("_account.listSessions", { token }); + listSessions(token: AccessToken): Promise { + return xrpc('_account.listSessions', { token }) }, - async revokeSession(token: string, sessionId: string): Promise { - await xrpc("_account.revokeSession", { - method: "POST", + async revokeSession(token: AccessToken, sessionId: string): Promise { + await xrpc('_account.revokeSession', { + method: 'POST', token, body: { sessionId }, - }); - }, - - revokeAllSessions(token: string): Promise<{ revokedCount: number }> { - return xrpc("_account.revokeAllSessions", { - method: "POST", - token, - }); - }, - - searchAccounts(token: string, options?: { - handle?: string; - cursor?: string; - limit?: number; - }): Promise<{ - cursor?: string; - accounts: Array<{ - did: string; - handle: string; - email?: string; - indexedAt: string; - emailConfirmedAt?: string; - deactivatedAt?: string; - }>; - }> { - const params: Record = {}; - if (options?.handle) params.handle = options.handle; - if (options?.cursor) params.cursor = options.cursor; - if (options?.limit) params.limit = String(options.limit); - return xrpc("com.atproto.admin.searchAccounts", { token, params }); - }, - - getInviteCodes(token: string, options?: { - sort?: "recent" | "usage"; - cursor?: string; - limit?: number; - }): Promise<{ - cursor?: string; - codes: Array<{ - code: string; - available: number; - disabled: boolean; - forAccount: string; - createdBy: string; - createdAt: string; - uses: Array<{ usedBy: string; usedAt: string }>; - }>; - }> { - const params: Record = {}; - if (options?.sort) params.sort = options.sort; - if (options?.cursor) params.cursor = options.cursor; - if (options?.limit) params.limit = String(options.limit); - return xrpc("com.atproto.admin.getInviteCodes", { token, params }); + }) + }, + + revokeAllSessions(token: AccessToken): Promise<{ revokedCount: number }> { + return xrpc('_account.revokeAllSessions', { + method: 'POST', + token, + }) + }, + + searchAccounts(token: AccessToken, options?: { + handle?: string + cursor?: string + limit?: number + }): Promise { + const params: Record = {} + if (options?.handle) params.handle = options.handle + if (options?.cursor) params.cursor = options.cursor + if (options?.limit) params.limit = String(options.limit) + return xrpc('com.atproto.admin.searchAccounts', { token, params }) + }, + + getInviteCodes(token: AccessToken, options?: { + sort?: 'recent' | 'usage' + cursor?: string + limit?: number + }): Promise { + const params: Record = {} + if (options?.sort) params.sort = options.sort + if (options?.cursor) params.cursor = options.cursor + if (options?.limit) params.limit = String(options.limit) + return xrpc('com.atproto.admin.getInviteCodes', { token, params }) }, async disableInviteCodes( - token: string, + token: AccessToken, codes?: string[], accounts?: string[], ): Promise { - await xrpc("com.atproto.admin.disableInviteCodes", { - method: "POST", + await xrpc('com.atproto.admin.disableInviteCodes', { + method: 'POST', token, body: { codes, accounts }, - }); + }) }, - getAccountInfo(token: string, did: string): Promise<{ - did: string; - handle: string; - email?: string; - indexedAt: string; - emailConfirmedAt?: string; - invitesDisabled?: boolean; - deactivatedAt?: string; - }> { - return xrpc("com.atproto.admin.getAccountInfo", { token, params: { did } }); + getAccountInfo(token: AccessToken, did: Did): Promise { + return xrpc('com.atproto.admin.getAccountInfo', { token, params: { did } }) }, - async disableAccountInvites(token: string, account: string): Promise { - await xrpc("com.atproto.admin.disableAccountInvites", { - method: "POST", + async disableAccountInvites(token: AccessToken, account: Did): Promise { + await xrpc('com.atproto.admin.disableAccountInvites', { + method: 'POST', token, body: { account }, - }); + }) }, - async enableAccountInvites(token: string, account: string): Promise { - await xrpc("com.atproto.admin.enableAccountInvites", { - method: "POST", + async enableAccountInvites(token: AccessToken, account: Did): Promise { + await xrpc('com.atproto.admin.enableAccountInvites', { + method: 'POST', token, body: { account }, - }); + }) }, - async adminDeleteAccount(token: string, did: string): Promise { - await xrpc("com.atproto.admin.deleteAccount", { - method: "POST", + async adminDeleteAccount(token: AccessToken, did: Did): Promise { + await xrpc('com.atproto.admin.deleteAccount', { + method: 'POST', token, body: { did }, - }); + }) }, - describeRepo(token: string, repo: string): Promise<{ - handle: string; - did: string; - didDoc: unknown; - collections: string[]; - handleIsCorrect: boolean; - }> { - return xrpc("com.atproto.repo.describeRepo", { + describeRepo(token: AccessToken, repo: Did): Promise { + return xrpc('com.atproto.repo.describeRepo', { token, params: { repo }, - }); + }) }, - listRecords(token: string, repo: string, collection: string, options?: { - limit?: number; - cursor?: string; - reverse?: boolean; - }): Promise<{ - records: Array<{ uri: string; cid: string; value: unknown }>; - cursor?: string; - }> { - const params: Record = { repo, collection }; - if (options?.limit) params.limit = String(options.limit); - if (options?.cursor) params.cursor = options.cursor; - if (options?.reverse) params.reverse = "true"; - return xrpc("com.atproto.repo.listRecords", { token, params }); + listRecords(token: AccessToken, repo: Did, collection: Nsid, options?: { + limit?: number + cursor?: string + reverse?: boolean + }): Promise { + const params: Record = { repo, collection } + if (options?.limit) params.limit = String(options.limit) + if (options?.cursor) params.cursor = options.cursor + if (options?.reverse) params.reverse = 'true' + return xrpc('com.atproto.repo.listRecords', { token, params }) }, getRecord( - token: string, - repo: string, - collection: string, - rkey: string, - ): Promise<{ - uri: string; - cid: string; - value: unknown; - }> { - return xrpc("com.atproto.repo.getRecord", { + token: AccessToken, + repo: Did, + collection: Nsid, + rkey: Rkey, + ): Promise { + return xrpc('com.atproto.repo.getRecord', { token, params: { repo, collection, rkey }, - }); + }) }, createRecord( - token: string, - repo: string, - collection: string, + token: AccessToken, + repo: Did, + collection: Nsid, record: unknown, - rkey?: string, - ): Promise<{ - uri: string; - cid: string; - }> { - return xrpc("com.atproto.repo.createRecord", { - method: "POST", + rkey?: Rkey, + ): Promise { + return xrpc('com.atproto.repo.createRecord', { + method: 'POST', token, body: { repo, collection, record, rkey }, - }); + }) }, putRecord( - token: string, - repo: string, - collection: string, - rkey: string, + token: AccessToken, + repo: Did, + collection: Nsid, + rkey: Rkey, record: unknown, - ): Promise<{ - uri: string; - cid: string; - }> { - return xrpc("com.atproto.repo.putRecord", { - method: "POST", + ): Promise { + return xrpc('com.atproto.repo.putRecord', { + method: 'POST', token, body: { repo, collection, rkey, record }, - }); + }) }, async deleteRecord( - token: string, - repo: string, - collection: string, - rkey: string, + token: AccessToken, + repo: Did, + collection: Nsid, + rkey: Rkey, ): Promise { - await xrpc("com.atproto.repo.deleteRecord", { - method: "POST", + await xrpc('com.atproto.repo.deleteRecord', { + method: 'POST', token, body: { repo, collection, rkey }, - }); + }) }, - getTotpStatus( - token: string, - ): Promise<{ enabled: boolean; hasBackupCodes: boolean }> { - return xrpc("com.atproto.server.getTotpStatus", { token }); + getTotpStatus(token: AccessToken): Promise { + return xrpc('com.atproto.server.getTotpStatus', { token }) }, - createTotpSecret( - token: string, - ): Promise<{ uri: string; qrBase64: string }> { - return xrpc("com.atproto.server.createTotpSecret", { - method: "POST", + createTotpSecret(token: AccessToken): Promise { + return xrpc('com.atproto.server.createTotpSecret', { + method: 'POST', token, - }); + }) }, - enableTotp( - token: string, - code: string, - ): Promise<{ success: boolean; backupCodes: string[] }> { - return xrpc("com.atproto.server.enableTotp", { - method: "POST", + enableTotp(token: AccessToken, code: string): Promise { + return xrpc('com.atproto.server.enableTotp', { + method: 'POST', token, body: { code }, - }); + }) }, disableTotp( - token: string, + token: AccessToken, password: string, code: string, - ): Promise<{ success: boolean }> { - return xrpc("com.atproto.server.disableTotp", { - method: "POST", + ): Promise { + return xrpc('com.atproto.server.disableTotp', { + method: 'POST', token, body: { password, code }, - }); + }) }, regenerateBackupCodes( - token: string, + token: AccessToken, password: string, code: string, - ): Promise<{ backupCodes: string[] }> { - return xrpc("com.atproto.server.regenerateBackupCodes", { - method: "POST", + ): Promise { + return xrpc('com.atproto.server.regenerateBackupCodes', { + method: 'POST', token, body: { password, code }, - }); + }) }, startPasskeyRegistration( - token: string, + token: AccessToken, friendlyName?: string, - ): Promise<{ options: unknown }> { - return xrpc("com.atproto.server.startPasskeyRegistration", { - method: "POST", + ): Promise { + return xrpc('com.atproto.server.startPasskeyRegistration', { + method: 'POST', token, body: { friendlyName }, - }); + }) }, finishPasskeyRegistration( - token: string, + token: AccessToken, credential: unknown, friendlyName?: string, - ): Promise<{ id: string; credentialId: string }> { - return xrpc("com.atproto.server.finishPasskeyRegistration", { - method: "POST", + ): Promise { + return xrpc('com.atproto.server.finishPasskeyRegistration', { + method: 'POST', token, body: { credential, friendlyName }, - }); + }) }, - listPasskeys(token: string): Promise<{ - passkeys: Array<{ - id: string; - credentialId: string; - friendlyName: string | null; - createdAt: string; - lastUsed: string | null; - }>; - }> { - return xrpc("com.atproto.server.listPasskeys", { token }); + listPasskeys(token: AccessToken): Promise { + return xrpc('com.atproto.server.listPasskeys', { token }) }, - async deletePasskey(token: string, id: string): Promise { - await xrpc("com.atproto.server.deletePasskey", { - method: "POST", + async deletePasskey(token: AccessToken, id: string): Promise { + await xrpc('com.atproto.server.deletePasskey', { + method: 'POST', token, body: { id }, - }); + }) }, async updatePasskey( - token: string, + token: AccessToken, id: string, friendlyName: string, ): Promise { - await xrpc("com.atproto.server.updatePasskey", { - method: "POST", + await xrpc('com.atproto.server.updatePasskey', { + method: 'POST', token, body: { id, friendlyName }, - }); + }) }, - listTrustedDevices(token: string): Promise<{ - devices: Array<{ - id: string; - userAgent: string | null; - friendlyName: string | null; - trustedAt: string | null; - trustedUntil: string | null; - lastSeenAt: string; - }>; - }> { - return xrpc("_account.listTrustedDevices", { token }); + listTrustedDevices(token: AccessToken): Promise { + return xrpc('_account.listTrustedDevices', { token }) }, - revokeTrustedDevice( - token: string, - deviceId: string, - ): Promise<{ success: boolean }> { - return xrpc("_account.revokeTrustedDevice", { - method: "POST", + revokeTrustedDevice(token: AccessToken, deviceId: string): Promise { + return xrpc('_account.revokeTrustedDevice', { + method: 'POST', token, body: { deviceId }, - }); + }) }, updateTrustedDevice( - token: string, + token: AccessToken, deviceId: string, friendlyName: string, - ): Promise<{ success: boolean }> { - return xrpc("_account.updateTrustedDevice", { - method: "POST", + ): Promise { + return xrpc('_account.updateTrustedDevice', { + method: 'POST', token, body: { deviceId, friendlyName }, - }); + }) }, - getReauthStatus(token: string): Promise<{ - requiresReauth: boolean; - lastReauthAt: string | null; - availableMethods: string[]; - }> { - return xrpc("_account.getReauthStatus", { token }); + getReauthStatus(token: AccessToken): Promise { + return xrpc('_account.getReauthStatus', { token }) }, - reauthPassword( - token: string, - password: string, - ): Promise<{ success: boolean; reauthAt: string }> { - return xrpc("_account.reauthPassword", { - method: "POST", + reauthPassword(token: AccessToken, password: string): Promise { + return xrpc('_account.reauthPassword', { + method: 'POST', token, body: { password }, - }); + }) }, - reauthTotp( - token: string, - code: string, - ): Promise<{ success: boolean; reauthAt: string }> { - return xrpc("_account.reauthTotp", { - method: "POST", + reauthTotp(token: AccessToken, code: string): Promise { + return xrpc('_account.reauthTotp', { + method: 'POST', token, body: { code }, - }); + }) }, - reauthPasskeyStart(token: string): Promise<{ options: unknown }> { - return xrpc("_account.reauthPasskeyStart", { - method: "POST", + reauthPasskeyStart(token: AccessToken): Promise { + return xrpc('_account.reauthPasskeyStart', { + method: 'POST', token, - }); + }) }, - reauthPasskeyFinish( - token: string, - credential: unknown, - ): Promise<{ success: boolean; reauthAt: string }> { - return xrpc("_account.reauthPasskeyFinish", { - method: "POST", + reauthPasskeyFinish(token: AccessToken, credential: unknown): Promise { + return xrpc('_account.reauthPasskeyFinish', { + method: 'POST', token, body: { credential }, - }); + }) }, - reserveSigningKey(did?: string): Promise<{ signingKey: string }> { - return xrpc("com.atproto.server.reserveSigningKey", { - method: "POST", + reserveSigningKey(did?: Did): Promise { + return xrpc('com.atproto.server.reserveSigningKey', { + method: 'POST', body: { did }, - }); + }) }, - getRecommendedDidCredentials(token: string): Promise<{ - rotationKeys?: string[]; - alsoKnownAs?: string[]; - verificationMethods?: { atproto?: string }; - services?: { atproto_pds?: { type: string; endpoint: string } }; - }> { - return xrpc("com.atproto.identity.getRecommendedDidCredentials", { token }); + getRecommendedDidCredentials(token: AccessToken): Promise { + return xrpc('com.atproto.identity.getRecommendedDidCredentials', { token }) }, - async activateAccount(token: string): Promise { - await xrpc("com.atproto.server.activateAccount", { - method: "POST", + async activateAccount(token: AccessToken): Promise { + await xrpc('com.atproto.server.activateAccount', { + method: 'POST', token, - }); + }) }, async createPasskeyAccount(params: { - handle: string; - email?: string; - inviteCode?: string; - didType?: DidType; - did?: string; - signingKey?: string; - verificationChannel?: VerificationChannel; - discordId?: string; - telegramUsername?: string; - signalNumber?: string; - }, byodToken?: string): Promise<{ - did: string; - handle: string; - setupToken: string; - setupExpiresAt: string; - }> { - const url = `${API_BASE}/_account.createPasskeyAccount`; + handle: Handle + email?: EmailAddress + inviteCode?: string + didType?: DidType + did?: Did + signingKey?: string + verificationChannel?: VerificationChannel + discordId?: string + telegramUsername?: string + signalNumber?: string + }, byodToken?: string): Promise { + const url = `${API_BASE}/_account.createPasskeyAccount` const headers: Record = { - "Content-Type": "application/json", - }; + 'Content-Type': 'application/json', + } if (byodToken) { - headers["Authorization"] = `Bearer ${byodToken}`; + headers['Authorization'] = `Bearer ${byodToken}` } const res = await fetch(url, { - method: "POST", + method: 'POST', headers, body: JSON.stringify(params), - }); + }) if (!res.ok) { - const err = await res.json().catch(() => ({ - error: "Unknown", + const errData = await res.json().catch(() => ({ + error: 'Unknown', message: res.statusText, - })); - throw new ApiError(res.status, err.error, err.message); + })) + throw new ApiError(res.status, errData.error, errData.message) } - return res.json(); + return res.json() }, startPasskeyRegistrationForSetup( - did: string, + did: Did, setupToken: string, friendlyName?: string, - ): Promise<{ options: unknown }> { - return xrpc("_account.startPasskeyRegistrationForSetup", { - method: "POST", + ): Promise { + return xrpc('_account.startPasskeyRegistrationForSetup', { + method: 'POST', body: { did, setupToken, friendlyName }, - }); + }) }, completePasskeySetup( - did: string, + did: Did, setupToken: string, passkeyCredential: unknown, passkeyFriendlyName?: string, - ): Promise<{ - did: string; - handle: string; - appPassword: string; - appPasswordName: string; - }> { - return xrpc("_account.completePasskeySetup", { - method: "POST", + ): Promise { + return xrpc('_account.completePasskeySetup', { + method: 'POST', body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, - }); + }) }, - requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { - return xrpc("_account.requestPasskeyRecovery", { - method: "POST", + requestPasskeyRecovery(email: EmailAddress): Promise { + return xrpc('_account.requestPasskeyRecovery', { + method: 'POST', body: { email }, - }); + }) }, recoverPasskeyAccount( - did: string, + did: Did, recoveryToken: string, newPassword: string, - ): Promise<{ success: boolean }> { - return xrpc("_account.recoverPasskeyAccount", { - method: "POST", + ): Promise { + return xrpc('_account.recoverPasskeyAccount', { + method: 'POST', body: { did, recoveryToken, newPassword }, - }); + }) }, - verifyMigrationEmail( - token: string, - email: string, - ): Promise<{ success: boolean; did: string }> { - return xrpc("com.atproto.server.verifyMigrationEmail", { - method: "POST", + verifyMigrationEmail(token: string, email: EmailAddress): Promise { + return xrpc('com.atproto.server.verifyMigrationEmail', { + method: 'POST', body: { token, email }, - }); + }) }, - resendMigrationVerification(email: string): Promise<{ sent: boolean }> { - return xrpc("com.atproto.server.resendMigrationVerification", { - method: "POST", + resendMigrationVerification(email: EmailAddress): Promise { + return xrpc('com.atproto.server.resendMigrationVerification', { + method: 'POST', body: { email }, - }); + }) }, verifyToken( token: string, identifier: string, - accessToken?: string, - ): Promise<{ - success: boolean; - did: string; - purpose: string; - channel: string; - }> { - return xrpc("_account.verifyToken", { - method: "POST", + accessToken?: AccessToken, + ): Promise { + return xrpc('_account.verifyToken', { + method: 'POST', body: { token, identifier }, token: accessToken, - }); + }) }, - getDidDocument(token: string): Promise { - return xrpc("_account.getDidDocument", { token }); + getDidDocument(token: AccessToken): Promise { + return xrpc('_account.getDidDocument', { token }) }, updateDidDocument( - token: string, + token: AccessToken, params: { - verificationMethods?: VerificationMethod[]; - alsoKnownAs?: string[]; - serviceEndpoint?: string; + verificationMethods?: VerificationMethod[] + alsoKnownAs?: string[] + serviceEndpoint?: string }, - ): Promise<{ success: boolean }> { - return xrpc("_account.updateDidDocument", { - method: "POST", + ): Promise { + return xrpc('_account.updateDidDocument', { + method: 'POST', token, body: params, - }); + }) }, - async deactivateAccount( - token: string, - deleteAfter?: string, - ): Promise { - await xrpc("com.atproto.server.deactivateAccount", { - method: "POST", + async deactivateAccount(token: AccessToken, deleteAfter?: string): Promise { + await xrpc('com.atproto.server.deactivateAccount', { + method: 'POST', token, body: { deleteAfter }, - }); + }) }, - async getRepo(token: string, did: string): Promise { - const url = `${API_BASE}/com.atproto.sync.getRepo?did=${ - encodeURIComponent(did) - }`; + async getRepo(token: AccessToken, did: Did): Promise { + const url = `${API_BASE}/com.atproto.sync.getRepo?did=${encodeURIComponent(did)}` const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, - }); + }) if (!res.ok) { - const err = await res.json().catch(() => ({ - error: "Unknown", + const errData = await res.json().catch(() => ({ + error: 'Unknown', message: res.statusText, - })); - throw new ApiError(res.status, err.error, err.message); + })) + throw new ApiError(res.status, errData.error, errData.message) } - return res.arrayBuffer(); + return res.arrayBuffer() }, - listBackups(token: string): Promise<{ - backups: Array<{ - id: string; - repoRev: string; - repoRootCid: string; - blockCount: number; - sizeBytes: number; - createdAt: string; - }>; - backupEnabled: boolean; - }> { - return xrpc("_backup.listBackups", { token }); + listBackups(token: AccessToken): Promise { + return xrpc('_backup.listBackups', { token }) }, - async getBackup(token: string, id: string): Promise { - const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`; + async getBackup(token: AccessToken, id: string): Promise { + const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}` const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, - }); + }) if (!res.ok) { - const err = await res.json().catch(() => ({ - error: "Unknown", + const errData = await res.json().catch(() => ({ + error: 'Unknown', message: res.statusText, - })); - throw new ApiError(res.status, err.error, err.message); + })) + throw new ApiError(res.status, errData.error, errData.message) } - return res.blob(); + return res.blob() }, - createBackup(token: string): Promise<{ - id: string; - repoRev: string; - sizeBytes: number; - blockCount: number; - }> { - return xrpc("_backup.createBackup", { - method: "POST", + createBackup(token: AccessToken): Promise { + return xrpc('_backup.createBackup', { + method: 'POST', token, - }); + }) }, - async deleteBackup(token: string, id: string): Promise { - await xrpc("_backup.deleteBackup", { - method: "POST", + async deleteBackup(token: AccessToken, id: string): Promise { + await xrpc('_backup.deleteBackup', { + method: 'POST', token, params: { id }, - }); + }) }, - setBackupEnabled( - token: string, - enabled: boolean, - ): Promise<{ enabled: boolean }> { - return xrpc("_backup.setEnabled", { - method: "POST", + setBackupEnabled(token: AccessToken, enabled: boolean): Promise { + return xrpc('_backup.setEnabled', { + method: 'POST', token, body: { enabled }, - }); + }) }, - async importRepo(token: string, car: Uint8Array): Promise { - const url = `${API_BASE}/com.atproto.repo.importRepo`; + async importRepo(token: AccessToken, car: Uint8Array): Promise { + const url = `${API_BASE}/com.atproto.repo.importRepo` const res = await fetch(url, { - method: "POST", + method: 'POST', headers: { Authorization: `Bearer ${token}`, - "Content-Type": "application/vnd.ipld.car", + 'Content-Type': 'application/vnd.ipld.car', }, body: car, - }); + }) if (!res.ok) { - const err = await res.json().catch(() => ({ - error: "Unknown", + const errData = await res.json().catch(() => ({ + error: 'Unknown', message: res.statusText, - })); - throw new ApiError(res.status, err.error, err.message); + })) + throw new ApiError(res.status, errData.error, errData.message) } }, -}; +} + +export const typedApi = { + createSession( + identifier: string, + password: string + ): Promise> { + return xrpcResult('com.atproto.server.createSession', { + method: 'POST', + body: { identifier, password }, + }).then(r => r.ok ? ok(castSession(r.value)) : r) + }, + + getSession(token: AccessToken): Promise> { + return xrpcResult('com.atproto.server.getSession', { token }) + .then(r => r.ok ? ok(castSession(r.value)) : r) + }, + + refreshSession(refreshJwt: RefreshToken): Promise> { + return xrpcResult('com.atproto.server.refreshSession', { + method: 'POST', + token: refreshJwt, + }).then(r => r.ok ? ok(castSession(r.value)) : r) + }, + + describeServer(): Promise> { + return xrpcResult('com.atproto.server.describeServer') + }, + + listAppPasswords(token: AccessToken): Promise> { + return xrpcResult('com.atproto.server.listAppPasswords', { token }) + }, + + createAppPassword( + token: AccessToken, + name: string, + scopes?: string + ): Promise> { + return xrpcResult('com.atproto.server.createAppPassword', { + method: 'POST', + token, + body: { name, scopes }, + }) + }, + + revokeAppPassword(token: AccessToken, name: string): Promise> { + return xrpcResult('com.atproto.server.revokeAppPassword', { + method: 'POST', + token, + body: { name }, + }) + }, + + listSessions(token: AccessToken): Promise> { + return xrpcResult('_account.listSessions', { token }) + }, + + revokeSession(token: AccessToken, sessionId: string): Promise> { + return xrpcResult('_account.revokeSession', { + method: 'POST', + token, + body: { sessionId }, + }) + }, + + getTotpStatus(token: AccessToken): Promise> { + return xrpcResult('com.atproto.server.getTotpStatus', { token }) + }, + + createTotpSecret(token: AccessToken): Promise> { + return xrpcResult('com.atproto.server.createTotpSecret', { + method: 'POST', + token, + }) + }, + + enableTotp(token: AccessToken, code: string): Promise> { + return xrpcResult('com.atproto.server.enableTotp', { + method: 'POST', + token, + body: { code }, + }) + }, + + disableTotp( + token: AccessToken, + password: string, + code: string + ): Promise> { + return xrpcResult('com.atproto.server.disableTotp', { + method: 'POST', + token, + body: { password, code }, + }) + }, + + listPasskeys(token: AccessToken): Promise> { + return xrpcResult('com.atproto.server.listPasskeys', { token }) + }, + + deletePasskey(token: AccessToken, id: string): Promise> { + return xrpcResult('com.atproto.server.deletePasskey', { + method: 'POST', + token, + body: { id }, + }) + }, + + listTrustedDevices(token: AccessToken): Promise> { + return xrpcResult('_account.listTrustedDevices', { token }) + }, + + getReauthStatus(token: AccessToken): Promise> { + return xrpcResult('_account.getReauthStatus', { token }) + }, + + getNotificationPrefs(token: AccessToken): Promise> { + return xrpcResult('_account.getNotificationPrefs', { token }) + }, + + updateHandle(token: AccessToken, handle: Handle): Promise> { + return xrpcResult('com.atproto.identity.updateHandle', { + method: 'POST', + token, + body: { handle }, + }) + }, + + describeRepo(token: AccessToken, repo: Did): Promise> { + return xrpcResult('com.atproto.repo.describeRepo', { + token, + params: { repo }, + }) + }, + + listRecords( + token: AccessToken, + repo: Did, + collection: Nsid, + options?: { limit?: number; cursor?: string; reverse?: boolean } + ): Promise> { + const params: Record = { repo, collection } + if (options?.limit) params.limit = String(options.limit) + if (options?.cursor) params.cursor = options.cursor + if (options?.reverse) params.reverse = 'true' + return xrpcResult('com.atproto.repo.listRecords', { token, params }) + }, + + getRecord( + token: AccessToken, + repo: Did, + collection: Nsid, + rkey: Rkey + ): Promise> { + return xrpcResult('com.atproto.repo.getRecord', { + token, + params: { repo, collection, rkey }, + }) + }, + + deleteRecord( + token: AccessToken, + repo: Did, + collection: Nsid, + rkey: Rkey + ): Promise> { + return xrpcResult('com.atproto.repo.deleteRecord', { + method: 'POST', + token, + body: { repo, collection, rkey }, + }) + }, + + searchAccounts( + token: AccessToken, + options?: { handle?: string; cursor?: string; limit?: number } + ): Promise> { + const params: Record = {} + if (options?.handle) params.handle = options.handle + if (options?.cursor) params.cursor = options.cursor + if (options?.limit) params.limit = String(options.limit) + return xrpcResult('com.atproto.admin.searchAccounts', { token, params }) + }, + + getAccountInfo(token: AccessToken, did: Did): Promise> { + return xrpcResult('com.atproto.admin.getAccountInfo', { token, params: { did } }) + }, + + getServerStats(token: AccessToken): Promise> { + return xrpcResult('_admin.getServerStats', { token }) + }, + + listBackups(token: AccessToken): Promise> { + return xrpcResult('_backup.listBackups', { token }) + }, + + createBackup(token: AccessToken): Promise> { + return xrpcResult('_backup.createBackup', { + method: 'POST', + token, + }) + }, + + getDidDocument(token: AccessToken): Promise> { + return xrpcResult('_account.getDidDocument', { token }) + }, + + deleteSession(token: AccessToken): Promise> { + return xrpcResult('com.atproto.server.deleteSession', { + method: 'POST', + token, + }) + }, + + revokeAllSessions(token: AccessToken): Promise> { + return xrpcResult('_account.revokeAllSessions', { + method: 'POST', + token, + }) + }, + + getAccountInviteCodes(token: AccessToken): Promise> { + return xrpcResult('com.atproto.server.getAccountInviteCodes', { token }) + }, + + createInviteCode(token: AccessToken, useCount: number = 1): Promise> { + return xrpcResult('com.atproto.server.createInviteCode', { + method: 'POST', + token, + body: { useCount }, + }) + }, + + changePassword( + token: AccessToken, + currentPassword: string, + newPassword: string + ): Promise> { + return xrpcResult('_account.changePassword', { + method: 'POST', + token, + body: { currentPassword, newPassword }, + }) + }, + + getPasswordStatus(token: AccessToken): Promise> { + return xrpcResult('_account.getPasswordStatus', { token }) + }, + + getServerConfig(): Promise> { + return xrpcResult('_server.getConfig') + }, + + getLegacyLoginPreference(token: AccessToken): Promise> { + return xrpcResult('_account.getLegacyLoginPreference', { token }) + }, + + updateLegacyLoginPreference( + token: AccessToken, + allowLegacyLogin: boolean + ): Promise> { + return xrpcResult('_account.updateLegacyLoginPreference', { + method: 'POST', + token, + body: { allowLegacyLogin }, + }) + }, + + getNotificationHistory(token: AccessToken): Promise> { + return xrpcResult('_account.getNotificationHistory', { token }) + }, + + updateNotificationPrefs( + token: AccessToken, + prefs: { + preferredChannel?: string + discordId?: string + telegramUsername?: string + signalNumber?: string + } + ): Promise> { + return xrpcResult('_account.updateNotificationPrefs', { + method: 'POST', + token, + body: prefs, + }) + }, + + revokeTrustedDevice(token: AccessToken, deviceId: string): Promise> { + return xrpcResult('_account.revokeTrustedDevice', { + method: 'POST', + token, + body: { deviceId }, + }) + }, + + updateTrustedDevice( + token: AccessToken, + deviceId: string, + friendlyName: string + ): Promise> { + return xrpcResult('_account.updateTrustedDevice', { + method: 'POST', + token, + body: { deviceId, friendlyName }, + }) + }, + + reauthPassword(token: AccessToken, password: string): Promise> { + return xrpcResult('_account.reauthPassword', { + method: 'POST', + token, + body: { password }, + }) + }, + + reauthTotp(token: AccessToken, code: string): Promise> { + return xrpcResult('_account.reauthTotp', { + method: 'POST', + token, + body: { code }, + }) + }, + + reauthPasskeyStart(token: AccessToken): Promise> { + return xrpcResult('_account.reauthPasskeyStart', { + method: 'POST', + token, + }) + }, + + reauthPasskeyFinish(token: AccessToken, credential: unknown): Promise> { + return xrpcResult('_account.reauthPasskeyFinish', { + method: 'POST', + token, + body: { credential }, + }) + }, + + confirmSignup(did: Did, verificationCode: string): Promise> { + return xrpcResult('com.atproto.server.confirmSignup', { + method: 'POST', + body: { did, verificationCode }, + }) + }, + + resendVerification(did: Did): Promise> { + return xrpcResult('com.atproto.server.resendVerification', { + method: 'POST', + body: { did }, + }) + }, + + requestEmailUpdate(token: AccessToken): Promise> { + return xrpcResult('com.atproto.server.requestEmailUpdate', { + method: 'POST', + token, + }) + }, + + updateEmail(token: AccessToken, email: string, emailToken?: string): Promise> { + return xrpcResult('com.atproto.server.updateEmail', { + method: 'POST', + token, + body: { email, token: emailToken }, + }) + }, + + requestAccountDelete(token: AccessToken): Promise> { + return xrpcResult('com.atproto.server.requestAccountDelete', { + method: 'POST', + token, + }) + }, + + deleteAccount(did: Did, password: string, deleteToken: string): Promise> { + return xrpcResult('com.atproto.server.deleteAccount', { + method: 'POST', + body: { did, password, token: deleteToken }, + }) + }, + + updateDidDocument( + token: AccessToken, + params: { + verificationMethods?: VerificationMethod[] + alsoKnownAs?: string[] + serviceEndpoint?: string + } + ): Promise> { + return xrpcResult('_account.updateDidDocument', { + method: 'POST', + token, + body: params, + }) + }, + + deactivateAccount(token: AccessToken, deleteAfter?: string): Promise> { + return xrpcResult('com.atproto.server.deactivateAccount', { + method: 'POST', + token, + body: { deleteAfter }, + }) + }, + + activateAccount(token: AccessToken): Promise> { + return xrpcResult('com.atproto.server.activateAccount', { + method: 'POST', + token, + }) + }, + + setBackupEnabled(token: AccessToken, enabled: boolean): Promise> { + return xrpcResult('_backup.setEnabled', { + method: 'POST', + token, + body: { enabled }, + }) + }, + + deleteBackup(token: AccessToken, id: string): Promise> { + return xrpcResult('_backup.deleteBackup', { + method: 'POST', + token, + params: { id }, + }) + }, + + createRecord( + token: AccessToken, + repo: Did, + collection: Nsid, + record: unknown, + rkey?: Rkey + ): Promise> { + return xrpcResult('com.atproto.repo.createRecord', { + method: 'POST', + token, + body: { repo, collection, record, rkey }, + }) + }, + + putRecord( + token: AccessToken, + repo: Did, + collection: Nsid, + rkey: Rkey, + record: unknown + ): Promise> { + return xrpcResult('com.atproto.repo.putRecord', { + method: 'POST', + token, + body: { repo, collection, rkey, record }, + }) + }, + + getInviteCodes( + token: AccessToken, + options?: { sort?: 'recent' | 'usage'; cursor?: string; limit?: number } + ): Promise> { + const params: Record = {} + if (options?.sort) params.sort = options.sort + if (options?.cursor) params.cursor = options.cursor + if (options?.limit) params.limit = String(options.limit) + return xrpcResult('com.atproto.admin.getInviteCodes', { token, params }) + }, + + disableAccountInvites(token: AccessToken, account: Did): Promise> { + return xrpcResult('com.atproto.admin.disableAccountInvites', { + method: 'POST', + token, + body: { account }, + }) + }, + + enableAccountInvites(token: AccessToken, account: Did): Promise> { + return xrpcResult('com.atproto.admin.enableAccountInvites', { + method: 'POST', + token, + body: { account }, + }) + }, + + adminDeleteAccount(token: AccessToken, did: Did): Promise> { + return xrpcResult('com.atproto.admin.deleteAccount', { + method: 'POST', + token, + body: { did }, + }) + }, + + startPasskeyRegistration( + token: AccessToken, + friendlyName?: string + ): Promise> { + return xrpcResult('com.atproto.server.startPasskeyRegistration', { + method: 'POST', + token, + body: { friendlyName }, + }) + }, + + finishPasskeyRegistration( + token: AccessToken, + credential: unknown, + friendlyName?: string + ): Promise> { + return xrpcResult('com.atproto.server.finishPasskeyRegistration', { + method: 'POST', + token, + body: { credential, friendlyName }, + }) + }, + + updatePasskey( + token: AccessToken, + id: string, + friendlyName: string + ): Promise> { + return xrpcResult('com.atproto.server.updatePasskey', { + method: 'POST', + token, + body: { id, friendlyName }, + }) + }, + + regenerateBackupCodes( + token: AccessToken, + password: string, + code: string + ): Promise> { + return xrpcResult('com.atproto.server.regenerateBackupCodes', { + method: 'POST', + token, + body: { password, code }, + }) + }, + + updateLocale(token: AccessToken, preferredLocale: string): Promise> { + return xrpcResult('_account.updateLocale', { + method: 'POST', + token, + body: { preferredLocale }, + }) + }, + + confirmChannelVerification( + token: AccessToken, + channel: string, + identifier: string, + code: string + ): Promise> { + return xrpcResult('_account.confirmChannelVerification', { + method: 'POST', + token, + body: { channel, identifier, code }, + }) + }, + + removePassword(token: AccessToken): Promise> { + return xrpcResult('_account.removePassword', { + method: 'POST', + token, + }) + }, +} diff --git a/frontend/src/lib/auth.svelte.ts b/frontend/src/lib/auth.svelte.ts index 2f1f4ae..455e75d 100644 --- a/frontend/src/lib/auth.svelte.ts +++ b/frontend/src/lib/auth.svelte.ts @@ -1,11 +1,23 @@ import { api, ApiError, + typedApi, type CreateAccountParams, type CreateAccountResult, - type Session, - setTokenRefreshCallback, } from "./api"; +import type { Session } from "./types/api"; +import { + type Did, + type Handle, + type AccessToken, + type RefreshToken, + unsafeAsDid, + unsafeAsHandle, + unsafeAsAccessToken, + unsafeAsRefreshToken, +} from "./types/branded"; +import { type Result, ok, err, isOk, isErr, map } from "./types/result"; +import { assertNever } from "./types/exhaustive"; import { checkForOAuthCallback, clearOAuthCallbackParams, @@ -15,122 +27,270 @@ import { } from "./oauth"; import { setLocale, type SupportedLocale } from "./i18n"; -function applyLocaleFromSession( - sessionInfo: { preferredLocale?: string | null }, -) { - if (sessionInfo.preferredLocale) { - setLocale(sessionInfo.preferredLocale as SupportedLocale); - } -} - const STORAGE_KEY = "tranquil_pds_session"; const ACCOUNTS_KEY = "tranquil_pds_accounts"; export interface SavedAccount { - did: string; - handle: string; - accessJwt: string; - refreshJwt: string; + readonly did: Did; + readonly handle: Handle; + readonly accessJwt: AccessToken; + readonly refreshJwt: RefreshToken; } -interface AuthState { - session: Session | null; - loading: boolean; - error: string | null; - savedAccounts: SavedAccount[]; +export type AuthError = + | { readonly type: "network"; readonly message: string } + | { readonly type: "unauthorized"; readonly message: string } + | { readonly type: "validation"; readonly message: string } + | { readonly type: "oauth"; readonly message: string } + | { readonly type: "unknown"; readonly message: string }; + +function toAuthError(e: unknown): AuthError { + if (e instanceof ApiError) { + if (e.status === 401) { + return { type: "unauthorized", message: e.message }; + } + return { type: "validation", message: e.message }; + } + if (e instanceof Error) { + if (e.message.includes("network") || e.message.includes("fetch")) { + return { type: "network", message: e.message }; + } + return { type: "unknown", message: e.message }; + } + return { type: "unknown", message: "An unknown error occurred" }; +} + +type AuthStateKind = "unauthenticated" | "loading" | "authenticated" | "error"; + +export type AuthState = + | { + readonly kind: "unauthenticated"; + readonly savedAccounts: readonly SavedAccount[]; + } + | { + readonly kind: "loading"; + readonly savedAccounts: readonly SavedAccount[]; + readonly previousSession: Session | null; + } + | { + readonly kind: "authenticated"; + readonly session: Session; + readonly savedAccounts: readonly SavedAccount[]; + } + | { + readonly kind: "error"; + readonly error: AuthError; + readonly savedAccounts: readonly SavedAccount[]; + }; + +function createUnauthenticated( + savedAccounts: readonly SavedAccount[], +): AuthState { + return { kind: "unauthenticated", savedAccounts }; +} + +function createLoading( + savedAccounts: readonly SavedAccount[], + previousSession: Session | null = null, +): AuthState { + return { kind: "loading", savedAccounts, previousSession }; +} + +function createAuthenticated( + session: Session, + savedAccounts: readonly SavedAccount[], +): AuthState { + return { kind: "authenticated", session, savedAccounts }; +} + +function createError( + error: AuthError, + savedAccounts: readonly SavedAccount[], +): AuthState { + return { kind: "error", error, savedAccounts }; } -const state = $state({ - session: null, - loading: true, - error: null, - savedAccounts: [], +const state = $state<{ current: AuthState }>({ + current: createLoading([]), }); -function saveSession(session: Session | null) { - if (session) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); - } else { - localStorage.removeItem(STORAGE_KEY); +function applyLocaleFromSession(sessionInfo: { + preferredLocale?: string | null; +}): void { + if (sessionInfo.preferredLocale) { + setLocale(sessionInfo.preferredLocale as SupportedLocale); } } -function loadSession(): Session | null { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - try { - return JSON.parse(stored); - } catch { - return null; +function sessionToSavedAccount(session: Session): SavedAccount { + return { + did: unsafeAsDid(session.did), + handle: unsafeAsHandle(session.handle), + accessJwt: unsafeAsAccessToken(session.accessJwt), + refreshJwt: unsafeAsRefreshToken(session.refreshJwt), + }; +} + +interface StoredSession { + readonly did: string; + readonly handle: string; + readonly accessJwt: string; + readonly refreshJwt: string; + readonly email?: string; + readonly emailConfirmed?: boolean; + readonly preferredChannel?: string; + readonly preferredChannelVerified?: boolean; + readonly preferredLocale?: string | null; +} + +function parseStoredSession(json: string): Result { + try { + const parsed = JSON.parse(json); + if ( + typeof parsed === "object" && + parsed !== null && + typeof parsed.did === "string" && + typeof parsed.handle === "string" && + typeof parsed.accessJwt === "string" && + typeof parsed.refreshJwt === "string" + ) { + return ok(parsed as StoredSession); } + return err(new Error("Invalid session format")); + } catch (e) { + return err(e instanceof Error ? e : new Error("Failed to parse session")); } - return null; } -function loadSavedAccounts(): SavedAccount[] { - const stored = localStorage.getItem(ACCOUNTS_KEY); - if (stored) { - try { - return JSON.parse(stored); - } catch { - return []; +function parseStoredAccounts(json: string): Result { + try { + const parsed = JSON.parse(json); + if (!Array.isArray(parsed)) { + return err(new Error("Invalid accounts format")); } + const accounts: SavedAccount[] = parsed + .filter( + (a): a is { did: string; handle: string; accessJwt: string; refreshJwt: string } => + typeof a === "object" && + a !== null && + typeof a.did === "string" && + typeof a.handle === "string" && + typeof a.accessJwt === "string" && + typeof a.refreshJwt === "string", + ) + .map((a) => ({ + did: unsafeAsDid(a.did), + handle: unsafeAsHandle(a.handle), + accessJwt: unsafeAsAccessToken(a.accessJwt), + refreshJwt: unsafeAsRefreshToken(a.refreshJwt), + })); + return ok(accounts); + } catch (e) { + return err(e instanceof Error ? e : new Error("Failed to parse accounts")); } - return []; } -function saveSavedAccounts(accounts: SavedAccount[]) { - localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); +function loadSessionFromStorage(): StoredSession | null { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return null; + const result = parseStoredSession(stored); + return isOk(result) ? result.value : null; } -function addOrUpdateSavedAccount(session: Session) { - const accounts = loadSavedAccounts(); - const existing = accounts.findIndex((a) => a.did === session.did); - const savedAccount: SavedAccount = { - did: session.did, - handle: session.handle, - accessJwt: session.accessJwt, - refreshJwt: session.refreshJwt, - }; - if (existing >= 0) { - accounts[existing] = savedAccount; +function loadSavedAccountsFromStorage(): readonly SavedAccount[] { + const stored = localStorage.getItem(ACCOUNTS_KEY); + if (!stored) return []; + const result = parseStoredAccounts(stored); + return isOk(result) ? result.value : []; +} + +function persistSession(session: Session | null): void { + if (session) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(session)); } else { - accounts.push(savedAccount); + localStorage.removeItem(STORAGE_KEY); } - saveSavedAccounts(accounts); - state.savedAccounts = accounts; } -function removeSavedAccount(did: string) { - const accounts = loadSavedAccounts().filter((a) => a.did !== did); - saveSavedAccounts(accounts); - state.savedAccounts = accounts; +function persistSavedAccounts(accounts: readonly SavedAccount[]): void { + localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(accounts)); +} + +function updateSavedAccounts( + accounts: readonly SavedAccount[], + session: Session, +): readonly SavedAccount[] { + const newAccount = sessionToSavedAccount(session); + const filtered = accounts.filter((a) => a.did !== newAccount.did); + return [...filtered, newAccount]; +} + +function removeSavedAccountByDid( + accounts: readonly SavedAccount[], + did: Did, +): readonly SavedAccount[] { + return accounts.filter((a) => a.did !== did); +} + +function findSavedAccount( + accounts: readonly SavedAccount[], + did: Did, +): SavedAccount | undefined { + return accounts.find((a) => a.did === did); +} + +function getSavedAccounts(): readonly SavedAccount[] { + return state.current.savedAccounts; +} + +function setState(newState: AuthState): void { + state.current = newState; +} + +function setAuthenticated(session: Session): void { + const accounts = updateSavedAccounts(getSavedAccounts(), session); + persistSession(session); + persistSavedAccounts(accounts); + setState(createAuthenticated(session, accounts)); +} + +function setUnauthenticated(): void { + persistSession(null); + setState(createUnauthenticated(getSavedAccounts())); +} + +function setError(error: AuthError): void { + setState(createError(error, getSavedAccounts())); +} + +function setLoading(previousSession: Session | null = null): void { + setState(createLoading(getSavedAccounts(), previousSession)); } async function tryRefreshToken(): Promise { - if (!state.session) return null; + if (state.current.kind !== "authenticated") return null; + const currentSession = state.current.session; try { - const tokens = await refreshOAuthToken(state.session.refreshJwt); + const tokens = await refreshOAuthToken(currentSession.refreshJwt); const sessionInfo = await api.getSession(tokens.access_token); const session: Session = { ...sessionInfo, accessJwt: tokens.access_token, - refreshJwt: tokens.refresh_token || state.session.refreshJwt, + refreshJwt: tokens.refresh_token || currentSession.refreshJwt, }; - state.session = session; - saveSession(session); - addOrUpdateSavedAccount(session); + setAuthenticated(session); return session.accessJwt; } catch { return null; } } +import { setTokenRefreshCallback } from "./api"; + export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { setTokenRefreshCallback(tryRefreshToken); - state.loading = true; - state.error = null; - state.savedAccounts = loadSavedAccounts(); + const savedAccounts = loadSavedAccountsFromStorage(); + setState(createLoading(savedAccounts)); const oauthCallback = checkForOAuthCallback(); if (oauthCallback) { @@ -146,29 +306,25 @@ export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { accessJwt: tokens.access_token, refreshJwt: tokens.refresh_token || "", }; - state.session = session; - saveSession(session); - addOrUpdateSavedAccount(session); + setAuthenticated(session); applyLocaleFromSession(sessionInfo); - state.loading = false; return { oauthLoginCompleted: true }; } catch (e) { - state.error = e instanceof Error ? e.message : "OAuth login failed"; - state.loading = false; + setError({ type: "oauth", message: e instanceof Error ? e.message : "OAuth login failed" }); return { oauthLoginCompleted: false }; } } - const stored = loadSession(); + const stored = loadSessionFromStorage(); if (stored) { try { const sessionInfo = await api.getSession(stored.accessJwt); - state.session = { + const session: Session = { ...sessionInfo, accessJwt: stored.accessJwt, refreshJwt: stored.refreshJwt, }; - addOrUpdateSavedAccount(state.session); + setAuthenticated(session); applyLocaleFromSession(sessionInfo); } catch (e) { if (e instanceof ApiError && e.status === 401) { @@ -180,85 +336,72 @@ export async function initAuth(): Promise<{ oauthLoginCompleted: boolean }> { accessJwt: tokens.access_token, refreshJwt: tokens.refresh_token || stored.refreshJwt, }; - state.session = session; - saveSession(session); - addOrUpdateSavedAccount(session); + setAuthenticated(session); applyLocaleFromSession(sessionInfo); } catch (refreshError) { console.error("Token refresh failed during init:", refreshError); - saveSession(null); - state.session = null; + setUnauthenticated(); } } else { console.error("Non-401 error during getSession:", e); - saveSession(null); - state.session = null; + setUnauthenticated(); } } + } else { + setState(createUnauthenticated(savedAccounts)); } - state.loading = false; + return { oauthLoginCompleted: false }; } export async function login( identifier: string, password: string, -): Promise { - state.loading = true; - state.error = null; - try { - const session = await api.createSession(identifier, password); - state.session = session; - saveSession(session); - addOrUpdateSavedAccount(session); - } catch (e) { - if (e instanceof ApiError) { - state.error = e.message; - } else { - state.error = "Login failed"; - } - throw e; - } finally { - state.loading = false; +): Promise> { + const currentState = state.current; + const previousSession = + currentState.kind === "authenticated" ? currentState.session : null; + setLoading(previousSession); + + const result = await typedApi.createSession(identifier, password); + if (isErr(result)) { + const error = toAuthError(result.error); + setError(error); + return err(error); } + + setAuthenticated(result.value); + return ok(result.value); } -export async function loginWithOAuth(): Promise { - state.loading = true; - state.error = null; +export async function loginWithOAuth(): Promise> { + setLoading(); try { await startOAuthLogin(); + return ok(undefined); } catch (e) { - state.loading = false; - state.error = e instanceof Error - ? e.message - : "Failed to start OAuth login"; - throw e; + const error = toAuthError(e); + setError(error); + return err(error); } } export async function register( params: CreateAccountParams, -): Promise { +): Promise> { try { const result = await api.createAccount(params); - return result; + return ok(result); } catch (e) { - if (e instanceof ApiError) { - state.error = e.message; - } else { - state.error = "Registration failed"; - } - throw e; + return err(toAuthError(e)); } } export async function confirmSignup( did: string, verificationCode: string, -): Promise { - state.loading = true; - state.error = null; +): Promise> { + setLoading(); try { const result = await api.confirmSignup(did, verificationCode); const session: Session = { @@ -271,160 +414,170 @@ export async function confirmSignup( preferredChannel: result.preferredChannel, preferredChannelVerified: result.preferredChannelVerified, }; - state.session = session; - saveSession(session); - addOrUpdateSavedAccount(session); + setAuthenticated(session); + return ok(session); } catch (e) { - if (e instanceof ApiError) { - state.error = e.message; - } else { - state.error = "Verification failed"; - } - throw e; - } finally { - state.loading = false; + const error = toAuthError(e); + setError(error); + return err(error); } } -export async function resendVerification(did: string): Promise { +export async function resendVerification( + did: string, +): Promise> { try { await api.resendVerification(did); + return ok(undefined); } catch (e) { - if (e instanceof ApiError) { - throw e; - } - throw new Error("Failed to resend verification code"); + return err(toAuthError(e)); } } -export function setSession( - session: { - did: string; - handle: string; - accessJwt: string; - refreshJwt: string; - }, -): void { +export function setSession(session: { + did: string; + handle: string; + accessJwt: string; + refreshJwt: string; +}): void { const newSession: Session = { did: session.did, handle: session.handle, accessJwt: session.accessJwt, refreshJwt: session.refreshJwt, }; - state.session = newSession; - saveSession(newSession); - addOrUpdateSavedAccount(newSession); + setAuthenticated(newSession); } -export async function logout(): Promise { - if (state.session) { - const did = state.session.did; - const refreshToken = state.session.refreshJwt; +export async function logout(): Promise> { + if (state.current.kind === "authenticated") { + const { session } = state.current; + const did = unsafeAsDid(session.did); try { await fetch("/oauth/revoke", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ token: refreshToken }), + body: new URLSearchParams({ token: session.refreshJwt }), }); } catch { - // Ignore errors on logout + // Ignore revocation errors } - removeSavedAccount(did); + const accounts = removeSavedAccountByDid(getSavedAccounts(), did); + persistSavedAccounts(accounts); + persistSession(null); + setState(createUnauthenticated(accounts)); + } else { + setUnauthenticated(); } - state.session = null; - saveSession(null); + return ok(undefined); } -export async function switchAccount(did: string): Promise { - const account = state.savedAccounts.find((a) => a.did === did); +export async function switchAccount( + did: Did, +): Promise> { + const account = findSavedAccount(getSavedAccounts(), did); if (!account) { - throw new Error("Account not found"); + return err({ type: "validation", message: "Account not found" }); } - state.loading = true; - state.error = null; + + setLoading(); + try { - const session = await api.getSession(account.accessJwt); - state.session = { - ...session, - accessJwt: account.accessJwt, - refreshJwt: account.refreshJwt, + const sessionInfo = await api.getSession(account.accessJwt as string); + const session: Session = { + ...sessionInfo, + accessJwt: account.accessJwt as string, + refreshJwt: account.refreshJwt as string, }; - saveSession(state.session); - addOrUpdateSavedAccount(state.session); + setAuthenticated(session); + return ok(session); } catch (e) { if (e instanceof ApiError && e.status === 401) { try { - const tokens = await refreshOAuthToken(account.refreshJwt); + const tokens = await refreshOAuthToken(account.refreshJwt as string); const sessionInfo = await api.getSession(tokens.access_token); const session: Session = { ...sessionInfo, accessJwt: tokens.access_token, - refreshJwt: tokens.refresh_token || account.refreshJwt, + refreshJwt: tokens.refresh_token || (account.refreshJwt as string), }; - state.session = session; - saveSession(session); - addOrUpdateSavedAccount(session); + setAuthenticated(session); + return ok(session); } catch { - removeSavedAccount(did); - state.error = "Session expired. Please log in again."; - throw new Error("Session expired"); + const accounts = removeSavedAccountByDid(getSavedAccounts(), did); + persistSavedAccounts(accounts); + const error: AuthError = { + type: "unauthorized", + message: "Session expired. Please log in again.", + }; + setState(createError(error, accounts)); + return err(error); } - } else { - state.error = "Failed to switch account"; - throw e; } - } finally { - state.loading = false; + const error = toAuthError(e); + setError(error); + return err(error); } } -export function forgetAccount(did: string): void { - removeSavedAccount(did); +export function forgetAccount(did: Did): void { + const accounts = removeSavedAccountByDid(getSavedAccounts(), did); + persistSavedAccounts(accounts); + setState({ + ...state.current, + savedAccounts: accounts, + } as AuthState); } -export function getAuthState() { - return state; +export function getAuthState(): AuthState { + return state.current; } -export async function refreshSession(): Promise { - if (!state.session) return; +export async function refreshSession(): Promise> { + if (state.current.kind !== "authenticated") { + return err({ type: "unauthorized", message: "Not authenticated" }); + } + const currentSession = state.current.session; try { - const sessionInfo = await api.getSession(state.session.accessJwt); - state.session = { + const sessionInfo = await api.getSession(currentSession.accessJwt); + const session: Session = { ...sessionInfo, - accessJwt: state.session.accessJwt, - refreshJwt: state.session.refreshJwt, + accessJwt: currentSession.accessJwt, + refreshJwt: currentSession.refreshJwt, }; - saveSession(state.session); - addOrUpdateSavedAccount(state.session); + setAuthenticated(session); + return ok(session); } catch (e) { console.error("Failed to refresh session:", e); + return err(toAuthError(e)); } } -export function getToken(): string | null { - return state.session?.accessJwt ?? null; +export function getToken(): AccessToken | null { + if (state.current.kind === "authenticated") { + return unsafeAsAccessToken(state.current.session.accessJwt); + } + return null; } -export async function getValidToken(): Promise { - if (!state.session) return null; +export async function getValidToken(): Promise { + if (state.current.kind !== "authenticated") return null; + const currentSession = state.current.session; try { - await api.getSession(state.session.accessJwt); - return state.session.accessJwt; + await api.getSession(currentSession.accessJwt); + return unsafeAsAccessToken(currentSession.accessJwt); } catch (e) { if (e instanceof ApiError && e.status === 401) { try { - const tokens = await refreshOAuthToken(state.session.refreshJwt); + const tokens = await refreshOAuthToken(currentSession.refreshJwt); const sessionInfo = await api.getSession(tokens.access_token); const session: Session = { ...sessionInfo, accessJwt: tokens.access_token, - refreshJwt: tokens.refresh_token || state.session.refreshJwt, + refreshJwt: tokens.refresh_token || currentSession.refreshJwt, }; - state.session = session; - saveSession(session); - addOrUpdateSavedAccount(session); - return session.accessJwt; + setAuthenticated(session); + return unsafeAsAccessToken(session.accessJwt); } catch { return null; } @@ -434,32 +587,68 @@ export async function getValidToken(): Promise { } export function isAuthenticated(): boolean { - return state.session !== null; + return state.current.kind === "authenticated"; +} + +export function isLoading(): boolean { + return state.current.kind === "loading"; +} + +export function getError(): AuthError | null { + return state.current.kind === "error" ? state.current.error : null; +} + +export function getSession(): Session | null { + return state.current.kind === "authenticated" ? state.current.session : null; +} + +export function matchAuthState(handlers: { + unauthenticated: (accounts: readonly SavedAccount[]) => T; + loading: (accounts: readonly SavedAccount[], previousSession: Session | null) => T; + authenticated: (session: Session, accounts: readonly SavedAccount[]) => T; + error: (error: AuthError, accounts: readonly SavedAccount[]) => T; +}): T { + const current = state.current; + switch (current.kind) { + case "unauthenticated": + return handlers.unauthenticated(current.savedAccounts); + case "loading": + return handlers.loading(current.savedAccounts, current.previousSession); + case "authenticated": + return handlers.authenticated(current.session, current.savedAccounts); + case "error": + return handlers.error(current.error, current.savedAccounts); + default: + return assertNever(current); + } } -export function _testSetState( - newState: { - session: Session | null; - loading: boolean; - error: string | null; - savedAccounts?: SavedAccount[]; - }, -) { - state.session = newState.session; - state.loading = newState.loading; - state.error = newState.error; - state.savedAccounts = newState.savedAccounts ?? []; +export function _testSetState(newState: { + session: Session | null; + loading: boolean; + error: string | null; + savedAccounts?: SavedAccount[]; +}): void { + const accounts = newState.savedAccounts ?? []; + if (newState.loading) { + setState(createLoading(accounts, newState.session)); + } else if (newState.error) { + setState(createError({ type: "unknown", message: newState.error }, accounts)); + } else if (newState.session) { + setState(createAuthenticated(newState.session, accounts)); + } else { + setState(createUnauthenticated(accounts)); + } } -export function _testResetState() { - state.session = null; - state.loading = true; - state.error = null; - state.savedAccounts = []; +export function _testResetState(): void { + setState(createLoading([])); } -export function _testReset() { +export function _testReset(): void { _testResetState(); localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(ACCOUNTS_KEY); } + +export { type Session }; diff --git a/frontend/src/lib/crypto.ts b/frontend/src/lib/crypto.ts index 3f2a9ec..9bc83e6 100644 --- a/frontend/src/lib/crypto.ts +++ b/frontend/src/lib/crypto.ts @@ -35,10 +35,7 @@ function base64UrlEncode(data: Uint8Array | string): string { const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data; - let binary = ""; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); } diff --git a/frontend/src/lib/migration/atproto-client.ts b/frontend/src/lib/migration/atproto-client.ts index b792616..50eb16e 100644 --- a/frontend/src/lib/migration/atproto-client.ts +++ b/frontend/src/lib/migration/atproto-client.ts @@ -600,10 +600,7 @@ export async function generatePKCE(): Promise<{ export function base64UrlEncode(buffer: Uint8Array | ArrayBuffer): string { const bytes = buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer; - let binary = ""; - for (let i = 0; i < bytes.length; i++) { - binary += String.fromCharCode(bytes[i]); - } + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( /=+$/, "", @@ -614,11 +611,7 @@ export function base64UrlDecode(base64url: string): Uint8Array { const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/"); const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); const binary = atob(padded); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) { - bytes[i] = binary.charCodeAt(i); - } - return bytes; + return Uint8Array.from(binary, (char) => char.charCodeAt(0)); } export function prepareWebAuthnCreationOptions( @@ -865,13 +858,12 @@ export async function resolvePdsUrl( ); if (dnsRes.ok) { const dnsData = await dnsRes.json(); - const txtRecords = dnsData.Answer ?? []; - for (const record of txtRecords) { - const txt = record.data?.replace(/"/g, "") ?? ""; - if (txt.startsWith("did=")) { - did = txt.slice(4); - break; - } + const txtRecords: Array<{ data?: string }> = dnsData.Answer ?? []; + const didRecord = txtRecords + .map((record) => record.data?.replace(/"/g, "") ?? "") + .find((txt) => txt.startsWith("did=")); + if (didRecord) { + did = didRecord.slice(4); } } diff --git a/frontend/src/lib/migration/blob-migration.ts b/frontend/src/lib/migration/blob-migration.ts index fc6e1d2..79375cb 100644 --- a/frontend/src/lib/migration/blob-migration.ts +++ b/frontend/src/lib/migration/blob-migration.ts @@ -36,9 +36,7 @@ export async function migrateBlobs( "blobs, cursor:", nextCursor, ); - for (const blob of blobs) { - missingBlobs.push(blob.cid); - } + missingBlobs.push(...blobs.map((blob) => blob.cid)); cursor = nextCursor; } while (cursor); diff --git a/frontend/src/lib/oauth.ts b/frontend/src/lib/oauth.ts index 52a6d6b..dcd6d5b 100644 --- a/frontend/src/lib/oauth.ts +++ b/frontend/src/lib/oauth.ts @@ -34,10 +34,7 @@ function sha256(plain: string): Promise { function base64UrlEncode(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); - let binary = ""; - for (const byte of bytes) { - binary += String.fromCharCode(byte); - } + const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('') return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( /=+$/, "", diff --git a/frontend/src/lib/registration/VerificationStep.svelte b/frontend/src/lib/registration/VerificationStep.svelte index 84981b3..a297e35 100644 --- a/frontend/src/lib/registration/VerificationStep.svelte +++ b/frontend/src/lib/registration/VerificationStep.svelte @@ -1,5 +1,6 @@ -{#if auth.session?.isAdmin} +{#if session?.isAdmin}
{$_('common.backToDashboard')} @@ -314,9 +321,6 @@ {#if loading}

{$_('admin.loading')}

{:else} - {#if error} -
{error}
- {/if}

{$_('admin.serverConfig')}

@@ -428,12 +432,6 @@
- {#if serverConfigError} -
{serverConfigError}
- {/if} - {#if serverConfigSuccess} -
{$_('admin.configSaved')}
- {/if} @@ -476,9 +474,6 @@ {usersLoading ? $_('admin.loading') : $_('admin.searchUsers')} - {#if usersError} -
{usersError}
- {/if} {#if showUsers}
{#if users.length === 0} @@ -528,9 +523,6 @@ {invitesLoading ? $_('admin.loading') : showInvites ? $_('admin.refresh') : $_('admin.loadInviteCodes')}
- {#if invitesError} -
{invitesError}
- {/if} {#if showInvites}
{#if invites.length === 0} diff --git a/frontend/src/routes/AppPasswords.svelte b/frontend/src/routes/AppPasswords.svelte index 684ceb7..acccd7d 100644 --- a/frontend/src/routes/AppPasswords.svelte +++ b/frontend/src/routes/AppPasswords.svelte @@ -1,13 +1,26 @@
- {$_('common.backToDashboard')} + {$_('common.backToDashboard')}

{$_('appPasswords.title')}

{$_('appPasswords.description')}

- {#if error} -
{error}
- {/if} {#if createdPassword}
@@ -162,7 +170,11 @@

{$_('appPasswords.yourPasswords')}

{#if loading} -

{$_('common.loading')}

+
    + {#each Array(2) as _} +
  • + {/each} +
{:else if passwords.length === 0}

{$_('appPasswords.noPasswords')}

{:else} @@ -459,4 +471,15 @@ text-align: center; padding: var(--space-7); } + + .skeleton-item { + height: 60px; + background: var(--bg-tertiary); + animation: skeleton-pulse 1.5s ease-in-out infinite; + } + + @keyframes skeleton-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } diff --git a/frontend/src/routes/Comms.svelte b/frontend/src/routes/Comms.svelte index cae57ee..57c4afc 100644 --- a/frontend/src/routes/Comms.svelte +++ b/frontend/src/routes/Comms.svelte @@ -1,14 +1,26 @@
- {$_('common.backToDashboard')} + {$_('common.backToDashboard')}

{$_('comms.title')}

{$_('comms.description')}

{#if loading} -

{$_('common.loading')}

+
+
+
+
{:else} - {#if error} -
{error}
- {/if} - {#if success} -
{success}
- {/if} -
@@ -331,12 +330,6 @@
- {#if verificationError} -
{verificationError}
- {/if} - {#if verificationSuccess} -
{verificationSuccess}
- {/if}
@@ -364,8 +357,6 @@
{/each}
- {:else if historyError} -
{historyError}
{:else if messages.length === 0}

{$_('comms.noMessages')}

{:else} @@ -790,4 +781,22 @@ color: var(--text-muted); margin-top: var(--space-2); } + + .skeleton-sections { + display: flex; + flex-direction: column; + gap: var(--space-6); + } + + .skeleton-section { + height: 180px; + background: var(--bg-secondary); + border-radius: var(--radius-xl); + animation: skeleton-pulse 1.5s ease-in-out infinite; + } + + @keyframes skeleton-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } + } diff --git a/frontend/src/routes/Controllers.svelte b/frontend/src/routes/Controllers.svelte index 21da8fa..2b2355d 100644 --- a/frontend/src/routes/Controllers.svelte +++ b/frontend/src/routes/Controllers.svelte @@ -1,8 +1,10 @@ -{#if auth.session} +{#if session}

{$_('dashboard.title')}

{/if} -
{/if}
- {#if auth.session.status === 'migrated'} + {#if session.status === 'migrated'}
{$_('dashboard.migratedTitle')} -

{$_('dashboard.migratedMessage', { values: { pds: auth.session.migratedToPds || 'another PDS' } })}

+

{$_('dashboard.migratedMessage', { values: { pds: session.migratedToPds || 'another PDS' } })}

- {:else if auth.session.status === 'deactivated' || auth.session.active === false} + {:else if session.status === 'deactivated' || session.active === false}
{$_('dashboard.deactivatedTitle')}

{$_('dashboard.deactivatedMessage')}

@@ -118,43 +136,43 @@
{$_('dashboard.handle')}
- @{auth.session.handle} - {#if auth.session.isAdmin} + @{session.handle} + {#if session.isAdmin} {$_('dashboard.admin')} {/if} - {#if auth.session.status === 'migrated'} + {#if session.status === 'migrated'} {$_('dashboard.migrated')} - {:else if auth.session.status === 'deactivated' || auth.session.active === false} + {:else if session.status === 'deactivated' || session.active === false} {$_('dashboard.deactivated')} {/if}
{$_('dashboard.did')}
-
{auth.session.did}
- {#if auth.session.preferredChannel} +
{session.did}
+ {#if session.preferredChannel}
{$_('dashboard.primaryContact')}
- {#if auth.session.preferredChannel === 'email'} - {auth.session.email || $_('register.email')} - {:else if auth.session.preferredChannel === 'discord'} + {#if session.preferredChannel === 'email'} + {session.email || $_('register.email')} + {:else if session.preferredChannel === 'discord'} {$_('register.discord')} - {:else if auth.session.preferredChannel === 'telegram'} + {:else if session.preferredChannel === 'telegram'} {$_('register.telegram')} - {:else if auth.session.preferredChannel === 'signal'} + {:else if session.preferredChannel === 'signal'} {$_('register.signal')} {:else} - {auth.session.preferredChannel} + {session.preferredChannel} {/if} - {#if auth.session.preferredChannelVerified} + {#if session.preferredChannelVerified} {$_('dashboard.verified')} {:else} {$_('dashboard.unverified')} {/if}
- {:else if auth.session.email} + {:else if session.email}
{$_('register.email')}
- {auth.session.email} - {#if auth.session.emailConfirmed} + {session.email} + {#if session.emailConfirmed} {$_('dashboard.verified')} {:else} {$_('dashboard.unverified')} @@ -165,74 +183,74 @@
-{:else if auth.loading} -
{$_('common.loading')}
+{:else if loading} +
+
+ +
{/if} diff --git a/frontend/src/routes/DidDocumentEditor.svelte b/frontend/src/routes/DidDocumentEditor.svelte index e7b4cbb..0d4d5aa 100644 --- a/frontend/src/routes/DidDocumentEditor.svelte +++ b/frontend/src/routes/DidDocumentEditor.svelte @@ -1,15 +1,27 @@
- {$_('common.backToDashboard')} + {$_('common.backToDashboard')}

{$_('inviteCodes.title')}

{$_('inviteCodes.description')}

- {#if error} -
{error}
- {/if} {#if createdCode}

{$_('inviteCodes.created')}

@@ -108,7 +115,7 @@
{/if} - {#if auth.session?.isAdmin} + {#if session?.isAdmin}
@@ -172,7 +210,7 @@