From 7985f76c0d5a865d0546f53c397653cec0805030 Mon Sep 17 00:00:00 2001 From: lewis Date: Sun, 11 Jan 2026 17:05:38 +0200 Subject: [PATCH] Streamplace oauth compatibility, mock plc dir in tests --- ...7cdf535cd70e09fde0a8a28249df0070eb2fc.json | 22 -- ...e2e5feaa9c59c38ec9175568abdacda167107.json | 15 -- ...af292977d35e0aed9bcc887b394f04468e2d7.json | 15 ++ ...445b65c8cc8c723baca221d85f5e4f2478b99.json | 22 -- ...9523f041b3faa3075cc61c850c1f31f7c4d78.json | 16 ++ ...fce2209635090252ac3692823450431d03dc6.json | 22 -- ...12815d4b7d73aacf3f3654628c93e5db8b791.json | 16 -- ...4abcf8bc8268a348d3be0da9840d1708d20b5.json | 14 -- ...8fd5a8d96120e92d791ff446b441f9222f00f.json | 18 -- ...79a3c411917144a011f50849b737130b24dbe.json | 54 ----- ...70b3360a3ac71e649b293efb88d92c3254068.json | 22 -- ...813f13f5892f653128469be727b686e6a0f0a.json | 28 --- ...294cd58f64b4fb431b54b5deda13d64525e88.json | 28 --- ...9580ff03b717586c4ca2d5343709e2dac86b6.json | 22 -- ...0a8d203ed52129b1fae65145df92d145d18de.json | 17 -- ...92881ba343c73a9a6e513e205c801c5943ec0.json | 28 --- ...b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json | 22 -- ...81d8ffee3806a3077281828f4bd2b8dfa7628.json | 17 ++ ...8b3141a67c64758823256b1da11fd9e9480b.json} | 4 +- ...d3ed26ffdd64f3fd0f26ecc28b6a0557bbe8f.json | 22 -- ...6f6fba9fe338e4a6a10636b5389d426df1631.json | 22 -- ...413743ba168d1e0d8b85566711e54d4048f81.json | 28 --- ...94246ead214ca794760d3fe77bb5cf4f27be5.json | 22 -- ...2ac0f84c73de8c244ba4560f004ee3f4b7002.json | 28 --- ...eadcaab7e06bdb79e0c89eb919b1bc1d6fabe.json | 108 ---------- ...d5171e70fa25e3b8393e142cebcbff752f0f5.json | 34 --- ...7b633cec7287c1cc177f0e1d47ec6571564d5.json | 22 -- ...26f8293851815a1b4666a093750fe249626b6.json | 17 ++ ...cae4825dec587b1387f0fee401458aea2a2e5.json | 60 ------ ...b29f7abfe18ee6f559bd94ab16274b1cfdfee.json | 22 -- ...ec5fd51a8ffaa2bac5942c115c99d1cbcafa3.json | 22 -- ...a3b40041b043bae57e95002d4bf5df46a4ab4.json | 14 -- ...80d8ede655ba1cc0fd4346d7e91d6de7d6cf3.json | 22 -- ...5a1a627809f444b0faff7e611d985f31b90e9.json | 22 -- ...89415a4cbdb0de95703bd064ea0f5d635980d.json | 15 -- ...bf8b34ca6a2246c20fc96f76f0e95530762a7.json | 22 -- ...fac26435b0feec65cf1c56f851d1c4d6b1814.json | 14 -- ...03699bb3a90f39bc9c4c0f738a37827e8f382.json | 28 --- Cargo.lock | 15 ++ Cargo.toml | 1 + crates/tranquil-pds/Cargo.toml | 1 + crates/tranquil-pds/src/api/proxy.rs | 2 +- crates/tranquil-pds/src/api/repo/import.rs | 77 ++++--- .../src/api/server/account_status.rs | 106 ++++----- .../src/api/server/app_password.rs | 10 +- crates/tranquil-pds/src/api/server/invite.rs | 152 ++++++------- .../tranquil-pds/src/api/server/migration.rs | 42 ++-- .../src/api/server/passkey_account.rs | 53 +++-- .../tranquil-pds/src/api/server/password.rs | 18 +- crates/tranquil-pds/src/api/server/session.rs | 138 +++++++----- crates/tranquil-pds/src/api/server/totp.rs | 89 ++++---- crates/tranquil-pds/src/oauth/db/token.rs | 6 +- .../src/oauth/endpoints/token/grants.rs | 15 +- crates/tranquil-pds/src/rate_limit.rs | 2 +- crates/tranquil-pds/src/state.rs | 2 +- crates/tranquil-pds/tests/common/mod.rs | 203 +++++++++++++++++- 56 files changed, 689 insertions(+), 1189 deletions(-) delete mode 100644 .sqlx/query-05fd99170e31e68fa5028c862417cdf535cd70e09fde0a8a28249df0070eb2fc.json delete mode 100644 .sqlx/query-0710b57fb9aa933525f617b15e6e2e5feaa9c59c38ec9175568abdacda167107.json create mode 100644 .sqlx/query-0c5ef3ffbd4d540dbd4ea993ea4af292977d35e0aed9bcc887b394f04468e2d7.json delete mode 100644 .sqlx/query-0ec60bb854a4991d0d7249a68f7445b65c8cc8c723baca221d85f5e4f2478b99.json create mode 100644 .sqlx/query-2232b75368a91a61256976ddb659523f041b3faa3075cc61c850c1f31f7c4d78.json delete mode 100644 .sqlx/query-24a7686c535e4f0332f45daa20cfce2209635090252ac3692823450431d03dc6.json delete mode 100644 .sqlx/query-297e5495004fa601f86b3ada9e512815d4b7d73aacf3f3654628c93e5db8b791.json delete mode 100644 .sqlx/query-29ef76852bb89af1ab9e679ceaa4abcf8bc8268a348d3be0da9840d1708d20b5.json delete mode 100644 .sqlx/query-2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f.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-59678fbb756d46bb5f51c9a52800a8d203ed52129b1fae65145df92d145d18de.json delete mode 100644 .sqlx/query-5a016f289caf75177731711e56e92881ba343c73a9a6e513e205c801c5943ec0.json delete mode 100644 .sqlx/query-5a036d95feedcbe6fb6396b10a7b4bd6a2eedeefda46a23e6a904cdbc3a65d45.json create mode 100644 .sqlx/query-6830cc85b246f5127419b0ed58f81d8ffee3806a3077281828f4bd2b8dfa7628.json rename .sqlx/{query-6a3a5d1d2cf871652a9d4d8ddb79cf26d24d9acb67e48123ca98423502eaac47.json => query-779f30b9db69294997c00bc446918b3141a67c64758823256b1da11fd9e9480b.json} (58%) 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 create mode 100644 .sqlx/query-ab5e6c5bc904ae54f8c559f6e1c26f8293851815a1b4666a093750fe249626b6.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-eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d.json delete mode 100644 .sqlx/query-f26c13023b47b908ec96da2e6b8bf8b34ca6a2246c20fc96f76f0e95530762a7.json delete mode 100644 .sqlx/query-f29da3bdfbbc547b339b4cdb059fac26435b0feec65cf1c56f851d1c4d6b1814.json delete mode 100644 .sqlx/query-f7af28963099aec12cf1d4f8a9a03699bb3a90f39bc9c4c0f738a37827e8f382.json 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-0c5ef3ffbd4d540dbd4ea993ea4af292977d35e0aed9bcc887b394f04468e2d7.json b/.sqlx/query-0c5ef3ffbd4d540dbd4ea993ea4af292977d35e0aed9bcc887b394f04468e2d7.json new file mode 100644 index 0000000..c7a6e3a --- /dev/null +++ b/.sqlx/query-0c5ef3ffbd4d540dbd4ea993ea4af292977d35e0aed9bcc887b394f04468e2d7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO backup_codes (did, code_hash, created_at)\n SELECT $1, hash, NOW() FROM UNNEST($2::text[]) AS t(hash)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "0c5ef3ffbd4d540dbd4ea993ea4af292977d35e0aed9bcc887b394f04468e2d7" +} 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-2232b75368a91a61256976ddb659523f041b3faa3075cc61c850c1f31f7c4d78.json b/.sqlx/query-2232b75368a91a61256976ddb659523f041b3faa3075cc61c850c1f31f7c4d78.json new file mode 100644 index 0000000..47d2603 --- /dev/null +++ b/.sqlx/query-2232b75368a91a61256976ddb659523f041b3faa3075cc61c850c1f31f7c4d78.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO record_blobs (repo_id, record_uri, blob_cid)\n SELECT $1, * FROM UNNEST($2::text[], $3::text[])\n ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "TextArray", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "2232b75368a91a61256976ddb659523f041b3faa3075cc61c850c1f31f7c4d78" +} 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-297e5495004fa601f86b3ada9e512815d4b7d73aacf3f3654628c93e5db8b791.json b/.sqlx/query-297e5495004fa601f86b3ada9e512815d4b7d73aacf3f3654628c93e5db8b791.json deleted file mode 100644 index ab54dcb..0000000 --- a/.sqlx/query-297e5495004fa601f86b3ada9e512815d4b7d73aacf3f3654628c93e5db8b791.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO record_blobs (repo_id, record_uri, blob_cid)\n VALUES ($1, $2, $3)\n ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Uuid", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "297e5495004fa601f86b3ada9e512815d4b7d73aacf3f3654628c93e5db8b791" -} 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-2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f.json b/.sqlx/query-2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f.json deleted file mode 100644 index 04c6889..0000000 --- a/.sqlx/query-2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE oauth_token\n SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(),\n previous_refresh_token = $5, rotated_at = NOW()\n WHERE id = $1\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int4", - "Text", - "Text", - "Timestamptz", - "Text" - ] - }, - "nullable": [] - }, - "hash": "2f5fb86d249903ea40240658b4f8fd5a8d96120e92d791ff446b441f9222f00f" -} 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-59678fbb756d46bb5f51c9a52800a8d203ed52129b1fae65145df92d145d18de.json b/.sqlx/query-59678fbb756d46bb5f51c9a52800a8d203ed52129b1fae65145df92d145d18de.json deleted file mode 100644 index d2cee94..0000000 --- a/.sqlx/query-59678fbb756d46bb5f51c9a52800a8d203ed52129b1fae65145df92d145d18de.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) VALUES ($1, $2, $3, $4)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Int4", - "Uuid", - "Text" - ] - }, - "nullable": [] - }, - "hash": "59678fbb756d46bb5f51c9a52800a8d203ed52129b1fae65145df92d145d18de" -} 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-6830cc85b246f5127419b0ed58f81d8ffee3806a3077281828f4bd2b8dfa7628.json b/.sqlx/query-6830cc85b246f5127419b0ed58f81d8ffee3806a3077281828f4bd2b8dfa7628.json new file mode 100644 index 0000000..bee0406 --- /dev/null +++ b/.sqlx/query-6830cc85b246f5127419b0ed58f81d8ffee3806a3077281828f4bd2b8dfa7628.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO invite_codes (code, available_uses, created_by_user, for_account)\n SELECT code, $2, $3, $4 FROM UNNEST($1::text[]) AS t(code)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "TextArray", + "Int4", + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "6830cc85b246f5127419b0ed58f81d8ffee3806a3077281828f4bd2b8dfa7628" +} diff --git a/.sqlx/query-6a3a5d1d2cf871652a9d4d8ddb79cf26d24d9acb67e48123ca98423502eaac47.json b/.sqlx/query-779f30b9db69294997c00bc446918b3141a67c64758823256b1da11fd9e9480b.json similarity index 58% rename from .sqlx/query-6a3a5d1d2cf871652a9d4d8ddb79cf26d24d9acb67e48123ca98423502eaac47.json rename to .sqlx/query-779f30b9db69294997c00bc446918b3141a67c64758823256b1da11fd9e9480b.json index 875b427..4c0f3e1 100644 --- a/.sqlx/query-6a3a5d1d2cf871652a9d4d8ddb79cf26d24d9acb67e48123ca98423502eaac47.json +++ b/.sqlx/query-779f30b9db69294997c00bc446918b3141a67c64758823256b1da11fd9e9480b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT u.did, u.handle, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = $1\n ORDER BY icu.used_at DESC\n ", + "query": "\n SELECT u.did, u.handle, icu.used_at\n FROM invite_code_uses icu\n JOIN users u ON icu.used_by_user = u.id\n WHERE icu.code = $1\n ORDER BY icu.used_at DESC\n ", "describe": { "columns": [ { @@ -30,5 +30,5 @@ false ] }, - "hash": "6a3a5d1d2cf871652a9d4d8ddb79cf26d24d9acb67e48123ca98423502eaac47" + "hash": "779f30b9db69294997c00bc446918b3141a67c64758823256b1da11fd9e9480b" } 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-ab5e6c5bc904ae54f8c559f6e1c26f8293851815a1b4666a093750fe249626b6.json b/.sqlx/query-ab5e6c5bc904ae54f8c559f6e1c26f8293851815a1b4666a093750fe249626b6.json new file mode 100644 index 0000000..4609929 --- /dev/null +++ b/.sqlx/query-ab5e6c5bc904ae54f8c559f6e1c26f8293851815a1b4666a093750fe249626b6.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth_token\n SET current_refresh_token = $2, expires_at = $3, updated_at = NOW(),\n previous_refresh_token = $4, rotated_at = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Text", + "Timestamptz", + "Text" + ] + }, + "nullable": [] + }, + "hash": "ab5e6c5bc904ae54f8c559f6e1c26f8293851815a1b4666a093750fe249626b6" +} 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-eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d.json b/.sqlx/query-eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d.json deleted file mode 100644 index 186dc84..0000000 --- a/.sqlx/query-eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO backup_codes (did, code_hash, created_at) VALUES ($1, $2, NOW())", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "eb5c82249de786f8245df805f0489415a4cbdb0de95703bd064ea0f5d635980d" -} 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/Cargo.lock b/Cargo.lock index ccdce4c..c5ec49e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -776,6 +776,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ "fastrand", + "gloo-timers", + "tokio", ] [[package]] @@ -2297,6 +2299,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gloo-utils" version = "0.2.0" @@ -6427,6 +6441,7 @@ dependencies = [ "aws-config", "aws-sdk-s3", "axum", + "backon", "base32", "base64 0.22.1", "bcrypt", diff --git a/Cargo.toml b/Cargo.toml index 1986026..019efc6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ tranquil-oauth = { path = "crates/tranquil-oauth" } tranquil-comms = { path = "crates/tranquil-comms" } aes-gcm = "0.10" +backon = "1" anyhow = "1.0" async-trait = "0.1" aws-config = "1.8" diff --git a/crates/tranquil-pds/Cargo.toml b/crates/tranquil-pds/Cargo.toml index 8200755..ded273b 100644 --- a/crates/tranquil-pds/Cargo.toml +++ b/crates/tranquil-pds/Cargo.toml @@ -17,6 +17,7 @@ tranquil-oauth = { workspace = true } tranquil-comms = { workspace = true } aes-gcm = { workspace = true } +backon = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } aws-config = { workspace = true } diff --git a/crates/tranquil-pds/src/api/proxy.rs b/crates/tranquil-pds/src/api/proxy.rs index b6b323d..78a978c 100644 --- a/crates/tranquil-pds/src/api/proxy.rs +++ b/crates/tranquil-pds/src/api/proxy.rs @@ -222,7 +222,7 @@ async fn proxy_handler( ) { let token = extracted.token; let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); - let http_uri = uri.to_string(); + let http_uri = crate::util::build_full_url(&uri.to_string()); match crate::auth::validate_token_with_dpop( &state.db, diff --git a/crates/tranquil-pds/src/api/repo/import.rs b/crates/tranquil-pds/src/api/repo/import.rs index 240b908..e53b5af 100644 --- a/crates/tranquil-pds/src/api/repo/import.rs +++ b/crates/tranquil-pds/src/api/repo/import.rs @@ -5,6 +5,7 @@ use crate::auth::BearerAuthAllowDeactivated; use crate::state::AppState; use crate::sync::import::{ImportError, apply_import, parse_car}; use crate::sync::verify::CarVerifier; +use crate::types::Did; use axum::{ body::Bytes, extract::State, @@ -196,35 +197,46 @@ pub async fn import_repo( import_result.records.len(), did ); - let mut blob_ref_count = 0; - for record in &import_result.records { - for blob_ref in &record.blob_refs { + let blob_refs: Vec<(String, String)> = import_result + .records + .iter() + .flat_map(|record| { let record_uri = format!("at://{}/{}/{}", did, record.collection, record.rkey); - if let Err(e) = sqlx::query!( - r#" - INSERT INTO record_blobs (repo_id, record_uri, blob_cid) - VALUES ($1, $2, $3) - ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING - "#, - user_id, - record_uri, - blob_ref.cid - ) - .execute(&state.db) - .await - { - warn!("Failed to insert record_blob for {}: {:?}", record_uri, e); - } else { - blob_ref_count += 1; + record + .blob_refs + .iter() + .map(move |blob_ref| (record_uri.clone(), blob_ref.cid.clone())) + }) + .collect(); + + if !blob_refs.is_empty() { + let (record_uris, blob_cids): (Vec, Vec) = + blob_refs.into_iter().unzip(); + + match sqlx::query!( + r#" + INSERT INTO record_blobs (repo_id, record_uri, blob_cid) + SELECT $1, * FROM UNNEST($2::text[], $3::text[]) + ON CONFLICT (repo_id, record_uri, blob_cid) DO NOTHING + "#, + user_id, + &record_uris, + &blob_cids + ) + .execute(&state.db) + .await + { + Ok(result) => { + info!( + "Recorded {} blob references for imported repo", + result.rows_affected() + ); + } + Err(e) => { + warn!("Failed to insert record_blobs: {:?}", e); } } } - if blob_ref_count > 0 { - info!( - "Recorded {} blob references for imported repo", - blob_ref_count - ); - } let key_row = match sqlx::query!( r#"SELECT uk.key_bytes, uk.encryption_version FROM user_keys uk @@ -383,7 +395,7 @@ pub async fn import_repo( async fn sequence_import_event( state: &AppState, - did: &str, + did: &Did, commit_cid: &str, ) -> Result<(), sqlx::Error> { let prev_cid: Option = None; @@ -391,13 +403,17 @@ async fn sequence_import_event( let ops = serde_json::json!([]); let blobs: Vec = vec![]; let blocks_cids: Vec = vec![]; + let did_str = did.as_str(); + + let mut tx = state.db.begin().await?; + let seq_row = sqlx::query!( r#" INSERT INTO repo_seq (did, event_type, commit_cid, prev_cid, prev_data_cid, ops, blobs, blocks_cids) VALUES ($1, 'commit', $2, $3, $4, $5, $6, $7) RETURNING seq "#, - did, + did_str, commit_cid, prev_cid, prev_data_cid, @@ -405,10 +421,13 @@ async fn sequence_import_event( &blobs, &blocks_cids ) - .fetch_one(&state.db) + .fetch_one(&mut *tx) .await?; + sqlx::query(&format!("NOTIFY repo_updates, '{}'", seq_row.seq)) - .execute(&state.db) + .execute(&mut *tx) .await?; + + tx.commit().await?; Ok(()) } diff --git a/crates/tranquil-pds/src/api/server/account_status.rs b/crates/tranquil-pds/src/api/server/account_status.rs index 3b5ee92..d8aa725 100644 --- a/crates/tranquil-pds/src/api/server/account_status.rs +++ b/crates/tranquil-pds/src/api/server/account_status.rs @@ -10,6 +10,7 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; +use backon::{ExponentialBuilder, Retryable}; use bcrypt::verify; use chrono::{Duration, Utc}; use cid::Cid; @@ -19,6 +20,7 @@ use k256::ecdsa::SigningKey; use serde::{Deserialize, Serialize}; use std::str::FromStr; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use tracing::{error, info, warn}; use uuid::Uuid; @@ -177,63 +179,69 @@ async fn assert_valid_did_document_for_service( let expected_endpoint = format!("https://{}", hostname); if did.starts_with("did:plc:") { - let plc_client = PlcClient::with_cache(None, Some(cache.clone())); - let max_attempts = if with_retry { 5 } else { 1 }; - let mut last_error = None; - let mut doc_data = None; - for attempt in 0..max_attempts { - if attempt > 0 { - let delay_ms = 500 * (1 << (attempt - 1)); - info!( - "Waiting {}ms before retry {} for DID document validation ({})", - delay_ms, attempt, did - ); - tokio::time::sleep(tokio::time::Duration::from_millis(delay_ms)).await; - } + let cache_for_retry = cache.clone(); + let did_owned = did.to_string(); + let expected_owned = expected_endpoint.clone(); + let attempt_counter = Arc::new(AtomicUsize::new(0)); - match plc_client.get_document_data(did).await { - Ok(data) => { - let pds_endpoint = data - .get("services") - .and_then(|s| s.get("atproto_pds").or_else(|| s.get("atprotoPds"))) - .and_then(|p| p.get("endpoint")) - .and_then(|e| e.as_str()); + let doc_data: serde_json::Value = (|| { + let cache_ref = cache_for_retry.clone(); + let did_ref = did_owned.clone(); + let expected_ref = expected_owned.clone(); + let counter = attempt_counter.clone(); + async move { + let attempt = counter.fetch_add(1, Ordering::SeqCst); + if attempt > 0 { + info!( + "Retry {} for DID document validation ({})", + attempt, did_ref + ); + } + let plc_client = PlcClient::with_cache(None, Some(cache_ref)); + match plc_client.get_document_data(&did_ref).await { + Ok(data) => { + let pds_endpoint = data + .get("services") + .and_then(|s: &serde_json::Value| s.get("atproto_pds").or_else(|| s.get("atprotoPds"))) + .and_then(|p: &serde_json::Value| p.get("endpoint")) + .and_then(|e: &serde_json::Value| e.as_str()); - if pds_endpoint == Some(&expected_endpoint) { - doc_data = Some(data); - break; - } else { - info!( - "Attempt {}: DID {} has endpoint {:?}, expected {} - retrying", + if pds_endpoint == Some(expected_ref.as_str()) { + Ok(data) + } else { + info!( + "Attempt {}: DID {} has endpoint {:?}, expected {}", + attempt + 1, + did_ref, + pds_endpoint, + expected_ref + ); + Err(format!( + "DID document endpoint {:?} does not match expected {}", + pds_endpoint, expected_ref + )) + } + } + Err(e) => { + warn!( + "Attempt {}: Failed to fetch PLC document for {}: {:?}", attempt + 1, - did, - pds_endpoint, - expected_endpoint + did_ref, + e ); - last_error = Some(format!( - "DID document endpoint {:?} does not match expected {}", - pds_endpoint, expected_endpoint - )); + Err(format!("Could not resolve DID document: {}", e)) } } - Err(e) => { - warn!( - "Attempt {}: Failed to fetch PLC document for {}: {:?}", - attempt + 1, - did, - e - ); - last_error = Some(format!("Could not resolve DID document: {}", e)); - } } - } - - let Some(doc_data) = doc_data else { - return Err(ApiError::InvalidRequest( - last_error.unwrap_or_else(|| "DID document validation failed".to_string()), - )); - }; + }) + .retry( + ExponentialBuilder::default() + .with_min_delay(std::time::Duration::from_millis(500)) + .with_max_times(max_attempts), + ) + .await + .map_err(ApiError::InvalidRequest)?; let server_rotation_key = std::env::var("PLC_ROTATION_KEY").ok(); if let Some(ref expected_rotation_key) = server_rotation_key { diff --git a/crates/tranquil-pds/src/api/server/app_password.rs b/crates/tranquil-pds/src/api/server/app_password.rs index af0810e..f1d883f 100644 --- a/crates/tranquil-pds/src/api/server/app_password.rs +++ b/crates/tranquil-pds/src/api/server/app_password.rs @@ -254,10 +254,14 @@ pub async fn revoke_app_password( error!("DB error revoking sessions for app password: {:?}", e); return ApiError::InternalError(None).into_response(); } - for jti in &sessions_to_invalidate { + futures::future::join_all(sessions_to_invalidate.iter().map(|jti| { let cache_key = format!("auth:session:{}:{}", &auth_user.did, jti); - let _ = state.cache.delete(&cache_key).await; - } + let cache = state.cache.clone(); + async move { + let _ = cache.delete(&cache_key).await; + } + })) + .await; if let Err(e) = sqlx::query!( "DELETE FROM app_passwords WHERE user_id = $1 AND name = $2", user_id, diff --git a/crates/tranquil-pds/src/api/server/invite.rs b/crates/tranquil-pds/src/api/server/invite.rs index 488e1c0..7a6160b 100644 --- a/crates/tranquil-pds/src/api/server/invite.rs +++ b/crates/tranquil-pds/src/api/server/invite.rs @@ -15,15 +15,12 @@ const BASE32_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567"; fn gen_random_token() -> String { let mut rng = rand::thread_rng(); - let mut token = String::with_capacity(11); - for i in 0..10 { - if i == 5 { - token.push('-'); - } - let idx = rng.gen_range(0..32); - token.push(BASE32_ALPHABET[idx] as char); - } - token + let gen_segment = |rng: &mut rand::rngs::ThreadRng, len: usize| -> String { + (0..len) + .map(|_| BASE32_ALPHABET[rng.gen_range(0..32)] as char) + .collect() + }; + format!("{}-{}", gen_segment(&mut rng, 5), gen_segment(&mut rng, 5)) } fn gen_invite_code() -> String { @@ -132,34 +129,38 @@ pub async fn create_invite_codes( } }; - let mut result_codes = Vec::new(); - - for account in for_accounts { - let mut codes = Vec::new(); - for _ in 0..code_count { - let code = gen_invite_code(); - if let Err(e) = sqlx::query!( - "INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) VALUES ($1, $2, $3, $4)", - code, - input.use_count, + let result = futures::future::try_join_all(for_accounts.into_iter().map(|account| { + let db = state.db.clone(); + let use_count = input.use_count; + async move { + let codes: Vec = (0..code_count).map(|_| gen_invite_code()).collect(); + sqlx::query!( + r#" + INSERT INTO invite_codes (code, available_uses, created_by_user, for_account) + SELECT code, $2, $3, $4 FROM UNNEST($1::text[]) AS t(code) + "#, + &codes[..], + use_count, admin_user_id, account ) - .execute(&state.db) + .execute(&db) .await - { - error!("DB error creating invite code: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - codes.push(code); + .map(|_| AccountCodes { account, codes }) } - result_codes.push(AccountCodes { account, codes }); - } + })) + .await; - Json(CreateInviteCodesOutput { - codes: result_codes, - }) - .into_response() + match result { + Ok(result_codes) => Json(CreateInviteCodesOutput { + codes: result_codes, + }) + .into_response(), + Err(e) => { + error!("DB error creating invite codes: {:?}", e); + ApiError::InternalError(None).into_response() + } + } } #[derive(Deserialize)] @@ -227,52 +228,53 @@ pub async fn get_account_invite_codes( } }; - let mut codes = Vec::new(); - for row in codes_rows { - let disabled = row.disabled.unwrap_or(false); - if disabled { - continue; - } - - let use_count = row.use_count; - if !include_used && use_count >= row.available_uses { - continue; - } - - let uses = sqlx::query!( - r#" - SELECT u.did, u.handle, icu.used_at - FROM invite_code_uses icu - JOIN users u ON icu.used_by_user = u.id - WHERE icu.code = $1 - ORDER BY icu.used_at DESC - "#, - row.code - ) - .fetch_all(&state.db) - .await - .map(|use_rows| { - use_rows - .iter() - .map(|u| InviteCodeUse { - used_by: u.did.clone(), - used_by_handle: Some(u.handle.clone()), - used_at: u.used_at.to_rfc3339(), - }) - .collect() + let filtered_rows: Vec<_> = codes_rows + .into_iter() + .filter(|row| { + let disabled = row.disabled.unwrap_or(false); + !disabled && (include_used || row.use_count < row.available_uses) }) - .unwrap_or_default(); + .collect(); - codes.push(InviteCode { - code: row.code, - available: row.available_uses, - disabled, - for_account: row.for_account, - created_by: "admin".to_string(), - created_at: row.created_at.to_rfc3339(), - uses, - }); - } + let codes = futures::future::join_all(filtered_rows.into_iter().map(|row| { + let db = state.db.clone(); + async move { + let uses = sqlx::query!( + r#" + SELECT u.did, u.handle, icu.used_at + FROM invite_code_uses icu + JOIN users u ON icu.used_by_user = u.id + WHERE icu.code = $1 + ORDER BY icu.used_at DESC + "#, + row.code + ) + .fetch_all(&db) + .await + .map(|use_rows| { + use_rows + .iter() + .map(|u| InviteCodeUse { + used_by: u.did.clone(), + used_by_handle: Some(u.handle.clone()), + used_at: u.used_at.to_rfc3339(), + }) + .collect() + }) + .unwrap_or_default(); + + InviteCode { + code: row.code, + available: row.available_uses, + disabled: false, + for_account: row.for_account, + created_by: "admin".to_string(), + created_at: row.created_at.to_rfc3339(), + uses, + } + } + })) + .await; Json(GetAccountInviteCodesOutput { codes }).into_response() } diff --git a/crates/tranquil-pds/src/api/server/migration.rs b/crates/tranquil-pds/src/api/server/migration.rs index 97be0fb..292c1db 100644 --- a/crates/tranquil-pds/src/api/server/migration.rs +++ b/crates/tranquil-pds/src/api/server/migration.rs @@ -97,38 +97,28 @@ pub async fn update_did_document( return ApiError::InvalidRequest("verification_methods cannot be empty".into()) .into_response(); } - for method in methods { + let validation_error = methods.iter().find_map(|method| { if method.id.is_empty() { - return ApiError::InvalidRequest("verification method id is required".into()) - .into_response(); - } - if method.method_type != "Multikey" { - return ApiError::InvalidRequest( - "verification method type must be 'Multikey'".into(), - ) - .into_response(); - } - if !method.public_key_multibase.starts_with('z') { - return ApiError::InvalidRequest( - "publicKeyMultibase must start with 'z' (base58btc)".into(), - ) - .into_response(); - } - if method.public_key_multibase.len() < 40 { - return ApiError::InvalidRequest( - "publicKeyMultibase appears too short for a valid key".into(), - ) - .into_response(); + Some("verification method id is required") + } else if method.method_type != "Multikey" { + Some("verification method type must be 'Multikey'") + } else if !method.public_key_multibase.starts_with('z') { + Some("publicKeyMultibase must start with 'z' (base58btc)") + } else if method.public_key_multibase.len() < 40 { + Some("publicKeyMultibase appears too short for a valid key") + } else { + None } + }); + if let Some(err) = validation_error { + return ApiError::InvalidRequest(err.into()).into_response(); } } if let Some(ref handles) = input.also_known_as { - for handle in handles { - if !handle.starts_with("at://") { - return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into()) - .into_response(); - } + if handles.iter().any(|h| !h.starts_with("at://")) { + return ApiError::InvalidRequest("alsoKnownAs entries must be at:// URIs".into()) + .into_response(); } } diff --git a/crates/tranquil-pds/src/api/server/passkey_account.rs b/crates/tranquil-pds/src/api/server/passkey_account.rs index c4d1676..d75f1d5 100644 --- a/crates/tranquil-pds/src/api/server/passkey_account.rs +++ b/crates/tranquil-pds/src/api/server/passkey_account.rs @@ -813,8 +813,6 @@ pub async fn complete_passkey_setup( return ApiError::InternalError(None).into_response(); } - let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await; - let app_password = generate_app_password(); let app_password_name = "bsky.app".to_string(); let password_hash = match hash(&app_password, DEFAULT_COST) { @@ -825,13 +823,21 @@ pub async fn complete_passkey_setup( } }; + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to begin transaction: {:?}", e); + return ApiError::InternalError(None).into_response(); + } + }; + if let Err(e) = sqlx::query!( "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)", user.id, app_password_name, password_hash ) - .execute(&state.db) + .execute(&mut *tx) .await { error!("Error creating app password: {:?}", e); @@ -842,12 +848,20 @@ pub async fn complete_passkey_setup( "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1", input.did.as_str() ) - .execute(&state.db) + .execute(&mut *tx) .await { error!("Error clearing setup token: {:?}", e); + return ApiError::InternalError(None).into_response(); } + if let Err(e) = tx.commit().await { + error!("Failed to commit setup transaction: {:?}", e); + return ApiError::InternalError(None).into_response(); + } + + let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await; + info!(did = %input.did, "Passkey-only account setup completed"); Json(CompletePasskeySetupResponse { @@ -1090,12 +1104,20 @@ pub async fn recover_passkey_account( } }; + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to begin transaction: {:?}", e); + 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.as_str() ) - .execute(&state.db) + .execute(&mut *tx) .await { error!("Error updating password: {:?}", e); @@ -1103,19 +1125,24 @@ pub async fn recover_passkey_account( } let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did.as_str()) - .execute(&state.db) + .execute(&mut *tx) .await; - match deleted { - Ok(result) => { - if result.rows_affected() > 0 { - info!(did = %input.did, count = result.rows_affected(), "Deleted lost passkeys during account recovery"); - } - } + let passkeys_deleted = match deleted { + Ok(result) => result.rows_affected(), Err(e) => { - warn!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e); + error!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e); + return ApiError::InternalError(None).into_response(); } + }; + + if let Err(e) = tx.commit().await { + error!("Failed to commit recovery transaction: {:?}", e); + return ApiError::InternalError(None).into_response(); } + if passkeys_deleted > 0 { + info!(did = %input.did, count = passkeys_deleted, "Deleted lost passkeys during account recovery"); + } info!(did = %input.did, "Passkey-only account recovered with temporary password"); SuccessResponse::ok().into_response() } diff --git a/crates/tranquil-pds/src/api/server/password.rs b/crates/tranquil-pds/src/api/server/password.rs index 3205283..4e49b03 100644 --- a/crates/tranquil-pds/src/api/server/password.rs +++ b/crates/tranquil-pds/src/api/server/password.rs @@ -239,15 +239,19 @@ pub async fn reset_password( error!("Failed to commit password reset transaction: {:?}", e); return ApiError::InternalError(None).into_response(); } - for jti in session_jtis { + futures::future::join_all(session_jtis.into_iter().map(|jti| { let cache_key = format!("auth:session:{}:{}", user_did, jti); - if let Err(e) = state.cache.delete(&cache_key).await { - warn!( - "Failed to invalidate session cache for {}: {:?}", - cache_key, e - ); + let cache = state.cache.clone(); + async move { + if let Err(e) = cache.delete(&cache_key).await { + warn!( + "Failed to invalidate session cache for {}: {:?}", + cache_key, e + ); + } } - } + })) + .await; info!("Password reset completed for user {}", user_id); EmptyResponse::ok().into_response() } diff --git a/crates/tranquil-pds/src/api/server/session.rs b/crates/tranquil-pds/src/api/server/session.rs index e4ad9da..47615f7 100644 --- a/crates/tranquil-pds/src/api/server/session.rs +++ b/crates/tranquil-pds/src/api/server/session.rs @@ -705,21 +705,6 @@ pub async fn confirm_signup( return ApiError::InternalError(None).into_response(); } }; - let verified_column = match row.channel { - crate::comms::CommsChannel::Email => "email_verified", - crate::comms::CommsChannel::Discord => "discord_verified", - crate::comms::CommsChannel::Telegram => "telegram_verified", - crate::comms::CommsChannel::Signal => "signal_verified", - }; - let update_query = format!("UPDATE users SET {} = TRUE WHERE did = $1", verified_column); - if let Err(e) = sqlx::query(&update_query) - .bind(input.did.as_str()) - .execute(&state.db) - .await - { - error!("Failed to update verification status: {:?}", e); - return ApiError::InternalError(None).into_response(); - } let access_meta = match crate::auth::create_access_token_with_metadata(&row.did, &key_bytes) { Ok(m) => m, @@ -735,6 +720,31 @@ pub async fn confirm_signup( 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 ApiError::InternalError(None).into_response(); + } + }; + + let verified_column = match row.channel { + crate::comms::CommsChannel::Email => "email_verified", + crate::comms::CommsChannel::Discord => "discord_verified", + crate::comms::CommsChannel::Telegram => "telegram_verified", + crate::comms::CommsChannel::Signal => "signal_verified", + }; + let update_query = format!("UPDATE users SET {} = TRUE WHERE did = $1", verified_column); + if let Err(e) = sqlx::query(&update_query) + .bind(input.did.as_str()) + .execute(&mut *tx) + .await + { + error!("Failed to update verification status: {:?}", e); + return ApiError::InternalError(None).into_response(); + } + let no_scope: Option = None; if let Err(e) = sqlx::query!( "INSERT INTO session_tokens (did, access_jti, refresh_jti, access_expires_at, refresh_expires_at, legacy_login, mfa_verified, scope) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", @@ -747,12 +757,18 @@ pub async fn confirm_signup( false, no_scope ) - .execute(&state.db) + .execute(&mut *tx) .await { error!("Failed to insert session: {:?}", e); return ApiError::InternalError(None).into_response(); } + + if let Err(e) = tx.commit().await { + error!("Failed to commit transaction: {:?}", e); + 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 { warn!("Failed to enqueue welcome notification: {:?}", e); @@ -878,9 +894,7 @@ pub async fn list_sessions( .and_then(|v| v.strip_prefix("Bearer ")) .and_then(|token| crate::auth::get_jti_from_token(token).ok()); - let mut sessions: Vec = Vec::new(); - - let jwt_result = sqlx::query_as::< + let jwt_rows = match sqlx::query_as::< _, ( i32, @@ -898,28 +912,16 @@ pub async fn list_sessions( ) .bind(&auth.0.did) .fetch_all(&state.db) - .await; - - match jwt_result { - Ok(rows) => { - for (id, access_jti, created_at, expires_at) in rows { - sessions.push(SessionInfo { - id: format!("jwt:{}", id), - session_type: "legacy".to_string(), - client_name: None, - created_at: created_at.to_rfc3339(), - expires_at: expires_at.to_rfc3339(), - is_current: current_jti.as_ref() == Some(&access_jti), - }); - } - } + .await + { + Ok(rows) => rows, Err(e) => { error!("DB error fetching JWT sessions: {:?}", e); return ApiError::InternalError(None).into_response(); } - } + }; - let oauth_result = sqlx::query_as::< + let oauth_rows = match sqlx::query_as::< _, ( i32, @@ -938,29 +940,44 @@ pub async fn list_sessions( ) .bind(&auth.0.did) .fetch_all(&state.db) - .await; + .await + { + Ok(rows) => rows, + Err(e) => { + error!("DB error fetching OAuth sessions: {:?}", e); + return ApiError::InternalError(None).into_response(); + } + }; - match oauth_result { - Ok(rows) => { - for (id, token_id, created_at, expires_at, client_id) in rows { + let jwt_sessions = jwt_rows.into_iter().map(|(id, access_jti, created_at, expires_at)| { + SessionInfo { + id: format!("jwt:{}", id), + session_type: "legacy".to_string(), + client_name: None, + created_at: created_at.to_rfc3339(), + expires_at: expires_at.to_rfc3339(), + is_current: current_jti.as_ref() == Some(&access_jti), + } + }); + + let is_oauth = auth.0.is_oauth; + let oauth_sessions = + oauth_rows + .into_iter() + .map(|(id, token_id, created_at, expires_at, client_id)| { let client_name = extract_client_name(&client_id); - let is_current_oauth = auth.0.is_oauth && current_jti.as_ref() == Some(&token_id); - sessions.push(SessionInfo { + let is_current_oauth = is_oauth && current_jti.as_ref() == Some(&token_id); + SessionInfo { id: format!("oauth:{}", id), session_type: "oauth".to_string(), client_name: Some(client_name), created_at: created_at.to_rfc3339(), expires_at: expires_at.to_rfc3339(), is_current: is_current_oauth, - }); - } - } - Err(e) => { - error!("DB error fetching OAuth sessions: {:?}", e); - return ApiError::InternalError(None).into_response(); - } - } + } + }); + let mut sessions: Vec = jwt_sessions.chain(oauth_sessions).collect(); sessions.sort_by(|a, b| b.created_at.cmp(&a.created_at)); (StatusCode::OK, Json(ListSessionsOutput { sessions })).into_response() @@ -1061,10 +1078,18 @@ pub async fn revoke_all_sessions( return ApiError::InvalidToken(None).into_response(); }; + let mut tx = match state.db.begin().await { + Ok(tx) => tx, + Err(e) => { + error!("Failed to begin transaction: {:?}", e); + return ApiError::InternalError(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) + .execute(&mut *tx) .await { error!("DB error revoking JWT sessions: {:?}", e); @@ -1073,7 +1098,7 @@ pub async fn revoke_all_sessions( 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) + .execute(&mut *tx) .await { error!("DB error revoking OAuth sessions: {:?}", e); @@ -1084,7 +1109,7 @@ pub async fn revoke_all_sessions( sqlx::query("DELETE FROM session_tokens WHERE did = $1 AND access_jti != $2") .bind(&auth.0.did) .bind(jti) - .execute(&state.db) + .execute(&mut *tx) .await { error!("DB error revoking JWT sessions: {:?}", e); @@ -1092,7 +1117,7 @@ pub async fn revoke_all_sessions( } if let Err(e) = sqlx::query("DELETE FROM oauth_token WHERE did = $1") .bind(&auth.0.did) - .execute(&state.db) + .execute(&mut *tx) .await { error!("DB error revoking OAuth sessions: {:?}", e); @@ -1100,6 +1125,11 @@ pub async fn revoke_all_sessions( } } + if let Err(e) = tx.commit().await { + error!("Failed to commit transaction: {:?}", e); + return ApiError::InternalError(None).into_response(); + } + info!(did = %&auth.0.did, "All other sessions revoked"); SuccessResponse::ok().into_response() } diff --git a/crates/tranquil-pds/src/api/server/totp.rs b/crates/tranquil-pds/src/api/server/totp.rs index 80b3120..a0c87c3 100644 --- a/crates/tranquil-pds/src/api/server/totp.rs +++ b/crates/tranquil-pds/src/api/server/totp.rs @@ -195,26 +195,28 @@ pub async fn enable_totp( return ApiError::InternalError(None).into_response(); } - for code in &backup_codes { - let hash = match hash_backup_code(code) { - Ok(h) => h, - Err(e) => { - error!("Failed to hash backup code: {:?}", e); - 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, - hash - ) - .execute(&mut *tx) - .await - { - error!("Failed to store backup code: {:?}", e); + let backup_hashes: Result, _> = backup_codes.iter().map(|c| hash_backup_code(c)).collect(); + let backup_hashes = match backup_hashes { + Ok(hashes) => hashes, + Err(e) => { + error!("Failed to hash backup code: {:?}", e); return ApiError::InternalError(None).into_response(); } + }; + + if let Err(e) = sqlx::query!( + r#" + INSERT INTO backup_codes (did, code_hash, created_at) + SELECT $1, hash, NOW() FROM UNNEST($2::text[]) AS t(hash) + "#, + &auth.0.did, + &backup_hashes[..] + ) + .execute(&mut *tx) + .await + { + error!("Failed to store backup codes: {:?}", e); + return ApiError::InternalError(None).into_response(); } if let Err(e) = tx.commit().await { @@ -482,26 +484,28 @@ pub async fn regenerate_backup_codes( return ApiError::InternalError(None).into_response(); } - for code in &backup_codes { - let hash = match hash_backup_code(code) { - Ok(h) => h, - Err(e) => { - error!("Failed to hash backup code: {:?}", e); - 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, - hash - ) - .execute(&mut *tx) - .await - { - error!("Failed to store backup code: {:?}", e); + let backup_hashes: Result, _> = backup_codes.iter().map(|c| hash_backup_code(c)).collect(); + let backup_hashes = match backup_hashes { + Ok(hashes) => hashes, + Err(e) => { + error!("Failed to hash backup code: {:?}", e); return ApiError::InternalError(None).into_response(); } + }; + + if let Err(e) = sqlx::query!( + r#" + INSERT INTO backup_codes (did, code_hash, created_at) + SELECT $1, hash, NOW() FROM UNNEST($2::text[]) AS t(hash) + "#, + &auth.0.did, + &backup_hashes[..] + ) + .execute(&mut *tx) + .await + { + error!("Failed to store backup codes: {:?}", e); + return ApiError::InternalError(None).into_response(); } if let Err(e) = tx.commit().await { @@ -532,8 +536,12 @@ async fn verify_backup_code_for_user(state: &AppState, did: &str, code: &str) -> } }; - for row in backup_codes { - if verify_backup_code(&code, &row.code_hash) { + let matched = backup_codes + .iter() + .find(|row| verify_backup_code(&code, &row.code_hash)); + + match matched { + Some(row) => { let _ = sqlx::query!( "UPDATE backup_codes SET used_at = $1 WHERE id = $2", Utc::now(), @@ -541,11 +549,10 @@ async fn verify_backup_code_for_user(state: &AppState, did: &str, code: &str) -> ) .execute(&state.db) .await; - return true; + true } + None => false, } - - false } pub async fn verify_totp_or_backup_for_user(state: &AppState, did: &str, code: &str) -> bool { diff --git a/crates/tranquil-pds/src/oauth/db/token.rs b/crates/tranquil-pds/src/oauth/db/token.rs index f50a45a..6b72a80 100644 --- a/crates/tranquil-pds/src/oauth/db/token.rs +++ b/crates/tranquil-pds/src/oauth/db/token.rs @@ -179,7 +179,6 @@ pub async fn get_token_by_refresh_token( pub async fn rotate_token( pool: &PgPool, old_db_id: i32, - new_token_id: &str, new_refresh_token: &str, new_expires_at: DateTime, ) -> Result<(), OAuthError> { @@ -207,12 +206,11 @@ pub async fn rotate_token( sqlx::query!( r#" UPDATE oauth_token - SET token_id = $2, current_refresh_token = $3, expires_at = $4, updated_at = NOW(), - previous_refresh_token = $5, rotated_at = NOW() + SET current_refresh_token = $2, expires_at = $3, updated_at = NOW(), + previous_refresh_token = $4, rotated_at = NOW() WHERE id = $1 "#, old_db_id, - new_token_id, new_refresh_token, new_expires_at, old_refresh diff --git a/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs b/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs index ba876ca..62a36c9 100644 --- a/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs +++ b/crates/tranquil-pds/src/oauth/endpoints/token/grants.rs @@ -24,6 +24,11 @@ pub async fn handle_authorization_code_grant( request: ValidatedTokenRequest, dpop_proof: Option, ) -> Result<(HeaderMap, Json), OAuthError> { + tracing::info!( + has_dpop = dpop_proof.is_some(), + client_id = ?request.client_auth.client_id, + "Authorization code grant requested" + ); let (code, code_verifier, redirect_uri) = match request.grant { TokenGrant::AuthorizationCode { code, @@ -178,6 +183,12 @@ pub async fn handle_authorization_code_grant( controller_did: controller_did.clone(), }; db::create_token(&state.db, &token_data).await?; + tracing::info!( + did = %did, + token_id = %token_id.0, + client_id = %auth_request.client_id, + "Authorization code grant completed, token created" + ); tokio::spawn({ let pool = state.db.clone(); let did_clone = did.clone(); @@ -316,7 +327,6 @@ pub async fn handle_refresh_token_grant( } else { None }; - let new_token_id = TokenId::generate(); let new_refresh_token = RefreshToken::generate(); let refresh_expiry_days = if matches!(token_data.client_auth, ClientAuth::None) { REFRESH_TOKEN_EXPIRY_DAYS_PUBLIC @@ -327,7 +337,6 @@ pub async fn handle_refresh_token_grant( db::rotate_token( &state.db, db_id, - &new_token_id.0, &new_refresh_token.0, new_expires_at, ) @@ -338,7 +347,7 @@ pub async fn handle_refresh_token_grant( "Refresh token rotated successfully" ); let access_token = create_access_token_with_delegation( - &new_token_id.0, + &token_data.token_id, &token_data.did, dpop_jkt.as_deref(), token_data.scope.as_deref(), diff --git a/crates/tranquil-pds/src/rate_limit.rs b/crates/tranquil-pds/src/rate_limit.rs index 429dd25..2c2855c 100644 --- a/crates/tranquil-pds/src/rate_limit.rs +++ b/crates/tranquil-pds/src/rate_limit.rs @@ -48,7 +48,7 @@ impl RateLimiters { NonZeroU32::new(10).unwrap(), ))), oauth_token: Arc::new(RateLimiter::keyed(Quota::per_minute( - NonZeroU32::new(30).unwrap(), + NonZeroU32::new(300).unwrap(), ))), oauth_authorize: Arc::new(RateLimiter::keyed(Quota::per_minute( NonZeroU32::new(10).unwrap(), diff --git a/crates/tranquil-pds/src/state.rs b/crates/tranquil-pds/src/state.rs index 2fafda7..103291b 100644 --- a/crates/tranquil-pds/src/state.rs +++ b/crates/tranquil-pds/src/state.rs @@ -71,7 +71,7 @@ impl RateLimitKind { Self::PasswordReset => (5, 3_600_000), Self::ResetPassword => (10, 60_000), Self::RefreshSession => (60, 60_000), - Self::OAuthToken => (30, 60_000), + Self::OAuthToken => (300, 60_000), Self::OAuthAuthorize => (10, 60_000), Self::OAuthPar => (30, 60_000), Self::OAuthIntrospect => (30, 60_000), diff --git a/crates/tranquil-pds/tests/common/mod.rs b/crates/tranquil-pds/tests/common/mod.rs index 42ef800..65dabab 100644 --- a/crates/tranquil-pds/tests/common/mod.rs +++ b/crates/tranquil-pds/tests/common/mod.rs @@ -5,19 +5,19 @@ use chrono::Utc; use reqwest::{Client, StatusCode, header}; use serde_json::{Value, json}; use sqlx::postgres::PgPoolOptions; -#[allow(unused_imports)] use std::collections::HashMap; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock, RwLock}; #[allow(unused_imports)] use std::time::Duration; use tokio::net::TcpListener; use tranquil_pds::state::AppState; use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; static SERVER_URL: OnceLock = OnceLock::new(); static APP_PORT: OnceLock = OnceLock::new(); static MOCK_APPVIEW: OnceLock = OnceLock::new(); +static MOCK_PLC: OnceLock = OnceLock::new(); static TEST_DB_POOL: OnceLock = OnceLock::new(); #[cfg(not(feature = "external-infra"))] @@ -117,6 +117,7 @@ async fn setup_with_external_infra() -> String { std::env::var("DATABASE_URL").expect("DATABASE_URL must be set when using external infra"); let s3_endpoint = std::env::var("S3_ENDPOINT").expect("S3_ENDPOINT must be set when using external infra"); + let plc_url = setup_mock_plc_directory().await; unsafe { std::env::set_var( "S3_BUCKET", @@ -137,6 +138,7 @@ async fn setup_with_external_infra() -> String { std::env::set_var("S3_ENDPOINT", &s3_endpoint); std::env::set_var("MAX_IMPORT_SIZE", "100000000"); std::env::set_var("SKIP_IMPORT_VERIFICATION", "true"); + std::env::set_var("PLC_DIRECTORY_URL", &plc_url); } let mock_server = MockServer::start().await; setup_mock_appview(&mock_server).await; @@ -164,6 +166,7 @@ async fn setup_with_testcontainers() -> String { .await .expect("Failed to get S3 port"); let s3_endpoint = format!("http://127.0.0.1:{}", s3_port); + let plc_url = setup_mock_plc_directory().await; unsafe { std::env::set_var("S3_BUCKET", "test-bucket"); std::env::set_var("AWS_ACCESS_KEY_ID", "minioadmin"); @@ -172,6 +175,7 @@ async fn setup_with_testcontainers() -> String { std::env::set_var("S3_ENDPOINT", &s3_endpoint); std::env::set_var("MAX_IMPORT_SIZE", "100000000"); std::env::set_var("SKIP_IMPORT_VERIFICATION", "true"); + std::env::set_var("PLC_DIRECTORY_URL", &plc_url); } let sdk_config = aws_config::defaults(BehaviorVersion::latest()) .region("us-east-1") @@ -239,6 +243,199 @@ async fn setup_mock_did_document(mock_server: &MockServer, did: &str, service_en async fn setup_mock_appview(_mock_server: &MockServer) {} +type PlcOperationStore = Arc>>; + +struct PlcPostResponder { + store: PlcOperationStore, +} + +impl Respond for PlcPostResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let path = request.url.path(); + let did = urlencoding::decode(path.trim_start_matches('/')) + .unwrap_or_default() + .to_string(); + + if let Ok(body) = serde_json::from_slice::(request.body.as_slice()) { + if let Ok(mut store) = self.store.write() { + store.insert(did, body); + } + } + ResponseTemplate::new(200) + } +} + +struct PlcGetResponder { + store: PlcOperationStore, +} + +impl Respond for PlcGetResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let path = request.url.path(); + let path_clean = path.trim_start_matches('/'); + + let (did, endpoint) = path_clean + .find("/log/") + .or_else(|| path_clean.find("/data")) + .map(|idx| { + let did = urlencoding::decode(&path_clean[..idx]) + .unwrap_or_default() + .to_string(); + let endpoint = &path_clean[idx..]; + (did, endpoint) + }) + .unwrap_or_else(|| { + ( + urlencoding::decode(path_clean) + .unwrap_or_default() + .to_string(), + "", + ) + }); + + let store = self.store.read().unwrap(); + let operation = store.get(&did); + + match endpoint { + "/log/last" => { + let response = operation + .cloned() + .unwrap_or_else(|| { + json!({ + "type": "plc_operation", + "rotationKeys": [], + "verificationMethods": {}, + "alsoKnownAs": [], + "services": {}, + "prev": null + }) + }); + ResponseTemplate::new(200).set_body_json(response) + } + "/log/audit" => ResponseTemplate::new(200).set_body_json(json!([])), + "/data" => { + let response = operation + .map(|op| { + json!({ + "rotationKeys": op.get("rotationKeys").cloned().unwrap_or(json!([])), + "verificationMethods": op.get("verificationMethods").cloned().unwrap_or(json!({})), + "alsoKnownAs": op.get("alsoKnownAs").cloned().unwrap_or(json!([])), + "services": op.get("services").cloned().unwrap_or(json!({})) + }) + }) + .unwrap_or_else(|| { + json!({ + "rotationKeys": [], + "verificationMethods": {}, + "alsoKnownAs": [], + "services": {} + }) + }); + ResponseTemplate::new(200).set_body_json(response) + } + _ => { + let did_doc = operation + .map(|op| operation_to_did_document(&did, op)) + .unwrap_or_else(|| { + json!({ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": did, + "alsoKnownAs": [], + "verificationMethod": [], + "service": [] + }) + }); + ResponseTemplate::new(200).set_body_json(did_doc) + } + } + } +} + +fn operation_to_did_document(did: &str, op: &Value) -> Value { + let also_known_as = op + .get("alsoKnownAs") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + + let verification_methods: Vec = op + .get("verificationMethods") + .and_then(|v| v.as_object()) + .map(|methods| { + methods + .iter() + .map(|(key, value)| { + let did_key = value.as_str().unwrap_or(""); + let multikey = did_key_to_multikey(did_key); + json!({ + "id": format!("{}#{}", did, key), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": multikey + }) + }) + .collect() + }) + .unwrap_or_default(); + + let services: Vec = op + .get("services") + .and_then(|v| v.as_object()) + .map(|svcs| { + svcs.iter() + .map(|(key, value)| { + json!({ + "id": format!("#{}", key), + "type": value.get("type").and_then(|t| t.as_str()).unwrap_or(""), + "serviceEndpoint": value.get("endpoint").and_then(|e| e.as_str()).unwrap_or("") + }) + }) + .collect() + }) + .unwrap_or_default(); + + json!({ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1" + ], + "id": did, + "alsoKnownAs": also_known_as, + "verificationMethod": verification_methods, + "service": services + }) +} + +fn did_key_to_multikey(did_key: &str) -> String { + if !did_key.starts_with("did:key:z") { + return String::new(); + } + did_key[8..].to_string() +} + +async fn setup_mock_plc_directory() -> String { + let mock_plc = MockServer::start().await; + let store: PlcOperationStore = Arc::new(RwLock::new(HashMap::new())); + + Mock::given(method("POST")) + .respond_with(PlcPostResponder { + store: store.clone(), + }) + .mount(&mock_plc) + .await; + + Mock::given(method("GET")) + .respond_with(PlcGetResponder { + store: store.clone(), + }) + .mount(&mock_plc) + .await; + + let plc_url = mock_plc.uri(); + MOCK_PLC.set(mock_plc).ok(); + plc_url +} + async fn spawn_app(database_url: String) -> String { use tranquil_pds::rate_limit::RateLimiters; let pool = PgPoolOptions::new()