From fa6c4cc177193a2fd185c3b7a258c4435839680d Mon Sep 17 00:00:00 2001
From: lewis
Date: Sun, 21 Dec 2025 23:16:39 +0200
Subject: [PATCH] Passkey-only accounts
---
.config/nextest.toml | 13 +
...7ef762de989fb560247e2540f611e28362507.json | 15 +
...bbc6683442938b657e82da283bc07ceec4c80.json | 14 +
...dbb8827af340e3c04fec9a5c28aeff46e0c97.json | 2 +-
...580a02c92e579869f647cb08f65ac777854f5.json | 3 +-
...fdfa8a2e37aae3f171a2be73bee3057f86e08.json | 3 +-
...b3776851008324d64e0fdf04677105d0189d2.json | 22 +
...e92ffef44ef4b67ddc00327c6cd407b3436b8.json | 14 +
...4705adf34732730dab8346f814d8ef7599a74.json | 16 +
...2a1aeb1ea927d85e0d90392e25bfa440d364d.json | 15 +
...914ad3bf77d47dd393a0aae1c030b8ce98bcc.json | 3 +-
...e88b6949869e7d2cd3b40125597e29d7e0d20.json | 17 -
...61ac07a02ffa9a926f94508d7873c4ca07e65.json | 23 +
...2446a677d8ceb49ea00542f164dbb508f205f.json | 16 +
...200baef1f16427d87fb3afeb1e066c4084483.json | 30 +
...b2355892675be8cd589636d94f11d0f730bbc.json | 22 +
...c6f4c5a2a50155f6e8bdd14beef66dca97c1.json} | 19 +-
...966b4ac507646c442881d0a7aec58725622ed.json | 22 +
...b27a1dddea591429b4696dc68105b435b38f3.json | 23 +
...f80354bbe2f53f494e8e072ef92ef1418b034.json | 22 +
...55fe004e15020069a53019208deb2ba5be369.json | 16 +
...7e3f3ea071ffafbeeca821238bc062375953b.json | 40 +
...21c9e3eae1ce87c223b706ed81ebf973875f3.json | 28 +
...b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json | 14 +
...c3caae31316887a629866c6a90ddee373317.json} | 5 +-
...4bf34bd0b88971b0658d191ed57badbbfd979.json | 15 +
...3b4505d5442b12d00b9062ee0db5f58ae05b8.json | 41 +
...cccf1df977c95dba43937de548b56ccc8256a.json | 22 +
...f52074bfda921bbb300f23bdf1ccb096b5ea5.json | 17 -
...30004dc6b40b9495fa998caf6e4cdd26a43e4.json | 14 +
...89a2910508b9f7e6f60465dd4cb5c7a79d848.json | 40 +
...f55f4f811a0d666237a93035cfece07445590.json | 2 +-
...d58397bbf306c249bffd286457e471c00b745.json | 2 +-
...774681fb0fe9e88733fa4315e9aef799cd19f.json | 52 +
...1a7e3b62280bb697a81cbd39ff8a1207651a5.json | 15 +
...f3787606f9c74041deaceb7b011680f7b0a7.json} | 4 +-
...6c817a53bbf99998c5b1e428227d1b223b0f.json} | 24 +-
...1123f1c4e0cfa721449a3f4a495e6c3ce0289.json | 46 +
...f69e242cd9c16687fa9a72a618d57c8f0d9ba.json | 22 +
...635c06b4179b5858a7b388404c4b03fc92ab4.json | 3 +-
...a386c68f8539725e8558643889a4ef92067b4.json | 40 +
frontend/src/App.svelte | 15 +
frontend/src/components/ReauthModal.svelte | 430 ++++++
frontend/src/lib/api.ts | 144 +-
frontend/src/routes/Login.svelte | 2 +-
frontend/src/routes/OAuthConsent.svelte | 4 +-
frontend/src/routes/OAuthLogin.svelte | 19 +
frontend/src/routes/OAuthPasskey.svelte | 304 +++++
frontend/src/routes/OAuthTotp.svelte | 28 +-
frontend/src/routes/RecoverPasskey.svelte | 266 ++++
frontend/src/routes/Register.svelte | 3 +
frontend/src/routes/RegisterPasskey.svelte | 1011 ++++++++++++++
.../src/routes/RequestPasskeyRecovery.svelte | 205 +++
frontend/src/routes/ResetPassword.svelte | 14 +-
frontend/src/routes/Security.svelte | 182 +++
frontend/src/routes/Settings.svelte | 2 +-
frontend/src/routes/TrustedDevices.svelte | 409 ++++++
migrations/20251225_passwordless_accounts.sql | 5 +
migrations/20251226_trusted_devices.sql | 4 +
migrations/20251227_reauth_tracking.sql | 1 +
...251228_add_passkey_recovery_comms_type.sql | 1 +
src/api/identity/account.rs | 65 +-
src/api/identity/did.rs | 8 +-
src/api/server/account_status.rs | 6 +-
src/api/server/mod.rs | 27 +-
src/api/server/passkey_account.rs | 1209 +++++++++++++++++
src/api/server/passkeys.rs | 8 +-
src/api/server/password.rs | 131 +-
src/api/server/reauth.rs | 482 +++++++
src/api/server/session.rs | 62 +-
src/api/server/totp.rs | 16 +-
src/api/server/trusted_devices.rs | 246 ++++
src/api/validation.rs | 101 +-
src/comms/mod.rs | 4 +-
src/comms/service.rs | 25 +
src/comms/types.rs | 1 +
src/lib.rs | 68 +
src/oauth/endpoints/authorize.rs | 458 ++++++-
tests/admin_search.rs | 55 +-
tests/did_web.rs | 28 +-
tests/identity.rs | 61 +-
81 files changed, 6683 insertions(+), 203 deletions(-)
create mode 100644 .sqlx/query-0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507.json
create mode 100644 .sqlx/query-0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80.json
create mode 100644 .sqlx/query-2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2.json
create mode 100644 .sqlx/query-33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8.json
create mode 100644 .sqlx/query-3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74.json
create mode 100644 .sqlx/query-39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d.json
delete mode 100644 .sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json
create mode 100644 .sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json
create mode 100644 .sqlx/query-4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f.json
create mode 100644 .sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json
create mode 100644 .sqlx/query-5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc.json
rename .sqlx/{query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json => query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json} (50%)
create mode 100644 .sqlx/query-5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed.json
create mode 100644 .sqlx/query-63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3.json
create mode 100644 .sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json
create mode 100644 .sqlx/query-736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369.json
create mode 100644 .sqlx/query-73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b.json
create mode 100644 .sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json
create mode 100644 .sqlx/query-76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json
rename .sqlx/{query-d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4.json => query-7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317.json} (61%)
create mode 100644 .sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json
create mode 100644 .sqlx/query-8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8.json
create mode 100644 .sqlx/query-976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a.json
delete mode 100644 .sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json
create mode 100644 .sqlx/query-9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4.json
create mode 100644 .sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json
create mode 100644 .sqlx/query-d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f.json
create mode 100644 .sqlx/query-d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5.json
rename .sqlx/{query-d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b.json => query-e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7.json} (58%)
rename .sqlx/{query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json => query-eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f.json} (75%)
create mode 100644 .sqlx/query-f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289.json
create mode 100644 .sqlx/query-fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba.json
create mode 100644 .sqlx/query-fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4.json
create mode 100644 frontend/src/components/ReauthModal.svelte
create mode 100644 frontend/src/routes/OAuthPasskey.svelte
create mode 100644 frontend/src/routes/RecoverPasskey.svelte
create mode 100644 frontend/src/routes/RegisterPasskey.svelte
create mode 100644 frontend/src/routes/RequestPasskeyRecovery.svelte
create mode 100644 frontend/src/routes/TrustedDevices.svelte
create mode 100644 migrations/20251225_passwordless_accounts.sql
create mode 100644 migrations/20251226_trusted_devices.sql
create mode 100644 migrations/20251227_reauth_tracking.sql
create mode 100644 migrations/20251228_add_passkey_recovery_comms_type.sql
create mode 100644 src/api/server/passkey_account.rs
create mode 100644 src/api/server/reauth.rs
create mode 100644 src/api/server/trusted_devices.rs
diff --git a/.config/nextest.toml b/.config/nextest.toml
index 2cfd181..6aa4e79 100644
--- a/.config/nextest.toml
+++ b/.config/nextest.toml
@@ -1,10 +1,23 @@
[store]
dir = "target/nextest"
+
[profile.default]
retries = 0
fail-fast = true
test-threads = "num-cpus"
+
[profile.ci]
retries = 2
fail-fast = false
test-threads = "num-cpus"
+
+[test-groups]
+serial-env-tests = { max-threads = 1 }
+
+[[profile.default.overrides]]
+filter = "test(/import_with_verification/) | test(/plc_migration/)"
+test-group = "serial-env-tests"
+
+[[profile.ci.overrides]]
+filter = "test(/import_with_verification/) | test(/plc_migration/)"
+test-group = "serial-env-tests"
diff --git a/.sqlx/query-0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507.json b/.sqlx/query-0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507.json
new file mode 100644
index 0000000..521625c
--- /dev/null
+++ b/.sqlx/query-0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE oauth_device SET trusted_until = $1 WHERE id = $2 AND trusted_until IS NOT NULL",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Timestamptz",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "0236504c0d6096dcdab0d5143737ef762de989fb560247e2540f611e28362507"
+}
diff --git a/.sqlx/query-0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80.json b/.sqlx/query-0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80.json
new file mode 100644
index 0000000..0cac9aa
--- /dev/null
+++ b/.sqlx/query-0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE oauth_device SET trusted_at = NULL, trusted_until = NULL WHERE id = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "0819ec49e0f3b97eb7eff0a1ef0bbc6683442938b657e82da283bc07ceec4c80"
+}
diff --git a/.sqlx/query-08c08b0644d79d5de72f3500dd7dbb8827af340e3c04fec9a5c28aeff46e0c97.json b/.sqlx/query-08c08b0644d79d5de72f3500dd7dbb8827af340e3c04fec9a5c28aeff46e0c97.json
index 8b18439..c210e37 100644
--- a/.sqlx/query-08c08b0644d79d5de72f3500dd7dbb8827af340e3c04fec9a5c28aeff46e0c97.json
+++ b/.sqlx/query-08c08b0644d79d5de72f3500dd7dbb8827af340e3c04fec9a5c28aeff46e0c97.json
@@ -26,7 +26,7 @@
},
"nullable": [
false,
- false,
+ true,
false
]
},
diff --git a/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json b/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
index 4444b64..10b2ebf 100644
--- a/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
+++ b/.sqlx/query-17dfafc85b3434ed78041f48809580a02c92e579869f647cb08f65ac777854f5.json
@@ -38,7 +38,8 @@
"admin_email",
"plc_operation",
"two_factor_code",
- "channel_verification"
+ "channel_verification",
+ "passkey_recovery"
]
}
}
diff --git a/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json b/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
index 7004e69..22f63d9 100644
--- a/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
+++ b/.sqlx/query-20dd204aa552572ec9dc5b9950efdfa8a2e37aae3f171a2be73bee3057f86e08.json
@@ -46,7 +46,8 @@
"admin_email",
"plc_operation",
"two_factor_code",
- "channel_verification"
+ "channel_verification",
+ "passkey_recovery"
]
}
}
diff --git a/.sqlx/query-2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2.json b/.sqlx/query-2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2.json
new file mode 100644
index 0000000..60cc9ec
--- /dev/null
+++ b/.sqlx/query-2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT ap.password_hash FROM app_passwords ap\n JOIN users u ON ap.user_id = u.id\n WHERE u.did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "password_hash",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "2e1d13f0b6fb1dc5021740674fab3776851008324d64e0fdf04677105d0189d2"
+}
diff --git a/.sqlx/query-33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8.json b/.sqlx/query-33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8.json
new file mode 100644
index 0000000..a7770f3
--- /dev/null
+++ b/.sqlx/query-33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "33f1362cf0836f642ebf6fc053ee92ffef44ef4b67ddc00327c6cd407b3436b8"
+}
diff --git a/.sqlx/query-3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74.json b/.sqlx/query-3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74.json
new file mode 100644
index 0000000..a9ac379
--- /dev/null
+++ b/.sqlx/query-3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE oauth_device SET trusted_at = $1, trusted_until = $2 WHERE id = $3",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Timestamptz",
+ "Timestamptz",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "3519df39bff89306f6a8f38709d4705adf34732730dab8346f814d8ef7599a74"
+}
diff --git a/.sqlx/query-39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d.json b/.sqlx/query-39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d.json
new file mode 100644
index 0000000..04da0a9
--- /dev/null
+++ b/.sqlx/query-39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE oauth_device SET friendly_name = $1 WHERE id = $2",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "39fc4114472fd390ea5921c27622a1aeb1ea927d85e0d90392e25bfa440d364d"
+}
diff --git a/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json b/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
index d87a8a1..b1ef31f 100644
--- a/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
+++ b/.sqlx/query-3f9b3b06f54df7c1d20ea9ff94b914ad3bf77d47dd393a0aae1c030b8ce98bcc.json
@@ -38,7 +38,8 @@
"admin_email",
"plc_operation",
"two_factor_code",
- "channel_verification"
+ "channel_verification",
+ "passkey_recovery"
]
}
}
diff --git a/.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json b/.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json
deleted file mode 100644
index 53ad312..0000000
--- a/.sqlx/query-4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "\n INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at)\n VALUES ($1, 'email', $2, $3, $4)\n ON CONFLICT (user_id, channel) DO UPDATE\n SET code = $2, pending_identifier = $3, expires_at = $4, created_at = NOW()\n ",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Uuid",
- "Text",
- "Text",
- "Timestamptz"
- ]
- },
- "nullable": []
- },
- "hash": "4513affeac5d8f9b93be23bef92e88b6949869e7d2cd3b40125597e29d7e0d20"
-}
diff --git a/.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json b/.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json
new file mode 100644
index 0000000..6754294
--- /dev/null
+++ b/.sqlx/query-4d8189361d1da271e300041599561ac07a02ffa9a926f94508d7873c4ca07e65.json
@@ -0,0 +1,23 @@
+{
+ "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-4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f.json b/.sqlx/query-4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f.json
new file mode 100644
index 0000000..ba6da25
--- /dev/null
+++ b/.sqlx/query-4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "4f411f02bc10c6961a7134c7f1c2446a677d8ceb49ea00542f164dbb508f205f"
+}
diff --git a/.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json b/.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json
new file mode 100644
index 0000000..e3a0c80
--- /dev/null
+++ b/.sqlx/query-54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483.json
@@ -0,0 +1,30 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, $2::comms_channel, $3, $4, $5)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ {
+ "Custom": {
+ "name": "comms_channel",
+ "kind": {
+ "Enum": [
+ "email",
+ "discord",
+ "telegram",
+ "signal"
+ ]
+ }
+ }
+ },
+ "Text",
+ "Text",
+ "Timestamptz"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "54ec6149f129881362891151da8200baef1f16427d87fb3afeb1e066c4084483"
+}
diff --git a/.sqlx/query-5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc.json b/.sqlx/query-5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc.json
new file mode 100644
index 0000000..6cd55ff
--- /dev/null
+++ b/.sqlx/query-5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT available_uses > 0 AND NOT disabled FROM invite_codes WHERE code = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "?column?",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "5934c4b41c2334a08742ee80d91b2355892675be8cd589636d94f11d0f730bbc"
+}
diff --git a/.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json b/.sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json
similarity index 50%
rename from .sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json
rename to .sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json
index aaaa50b..f597186 100644
--- a/.sqlx/query-126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32.json
+++ b/.sqlx/query-5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = 'email'",
+ "query": "SELECT code, expires_at FROM channel_verifications WHERE user_id = $1 AND channel = $2::comms_channel",
"describe": {
"columns": [
{
@@ -16,7 +16,20 @@
],
"parameters": {
"Left": [
- "Uuid"
+ "Uuid",
+ {
+ "Custom": {
+ "name": "comms_channel",
+ "kind": {
+ "Enum": [
+ "email",
+ "discord",
+ "telegram",
+ "signal"
+ ]
+ }
+ }
+ }
]
},
"nullable": [
@@ -24,5 +37,5 @@
false
]
},
- "hash": "126519f77d91aa1877b2c933a876c0283f9dc49444d68eca4e87461b82f9be32"
+ "hash": "5a3f588a937a44a4e14570a6c13bc6f4c5a2a50155f6e8bdd14beef66dca97c1"
}
diff --git a/.sqlx/query-5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed.json b/.sqlx/query-5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed.json
new file mode 100644
index 0000000..d1e2982
--- /dev/null
+++ b/.sqlx/query-5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT password_hash IS NOT NULL as has_password FROM users WHERE did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "has_password",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "5a7b98295457e43facb537845ed966b4ac507646c442881d0a7aec58725622ed"
+}
diff --git a/.sqlx/query-63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3.json b/.sqlx/query-63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3.json
new file mode 100644
index 0000000..9258673
--- /dev/null
+++ b/.sqlx/query-63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT 1 as one FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE oad.did = $1 AND od.id = $2",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "one",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "63ccfb04db47b69abf176baedc7b27a1dddea591429b4696dc68105b435b38f3"
+}
diff --git a/.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json b/.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json
new file mode 100644
index 0000000..3b32eaa
--- /dev/null
+++ b/.sqlx/query-70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT 1 as one FROM app_passwords ap JOIN users u ON ap.user_id = u.id WHERE u.did = $1 LIMIT 1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "one",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "70be96c8f398a75e8d52e07c1d1f80354bbe2f53f494e8e072ef92ef1418b034"
+}
diff --git a/.sqlx/query-736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369.json b/.sqlx/query-736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369.json
new file mode 100644
index 0000000..1fb61d4
--- /dev/null
+++ b/.sqlx/query-736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Timestamptz",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "736bd3e5b03b98587e9b611304c55fe004e15020069a53019208deb2ba5be369"
+}
diff --git a/.sqlx/query-73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b.json b/.sqlx/query-73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b.json
new file mode 100644
index 0000000..bfaf2d4
--- /dev/null
+++ b/.sqlx/query-73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b.json
@@ -0,0 +1,40 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT handle, recovery_token, recovery_token_expires_at, password_required\n FROM users WHERE did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "handle",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 1,
+ "name": "recovery_token",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "recovery_token_expires_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 3,
+ "name": "password_required",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ true,
+ false
+ ]
+ },
+ "hash": "73c166c20b87f199d384d4a03fb7e3f3ea071ffafbeeca821238bc062375953b"
+}
diff --git a/.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json b/.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json
new file mode 100644
index 0000000..bedeb59
--- /dev/null
+++ b/.sqlx/query-76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT id, password_hash FROM users WHERE did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "password_hash",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ true
+ ]
+ },
+ "hash": "76c6ef1d5395105a0cdedb27ca321c9e3eae1ce87c223b706ed81ebf973875f3"
+}
diff --git a/.sqlx/query-76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json b/.sqlx/query-76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json
new file mode 100644
index 0000000..b99e974
--- /dev/null
+++ b/.sqlx/query-76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "DELETE FROM passkeys WHERE did = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "76ff03b78f9a5a7d9b28b9de208b225aeaa1a1ab1f000ab6ca16f5db1ec76180"
+}
diff --git a/.sqlx/query-d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4.json b/.sqlx/query-7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317.json
similarity index 61%
rename from .sqlx/query-d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4.json
rename to .sqlx/query-7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317.json
index 1cf5faf..e025c98 100644
--- a/.sqlx/query-d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4.json
+++ b/.sqlx/query-7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT id FROM users WHERE LOWER(email) = $1",
+ "query": "SELECT id FROM users WHERE LOWER(email) = $1 OR handle = $2",
"describe": {
"columns": [
{
@@ -11,6 +11,7 @@
],
"parameters": {
"Left": [
+ "Text",
"Text"
]
},
@@ -18,5 +19,5 @@
false
]
},
- "hash": "d2a6047b9f8039025b19028b8db7935ea60bfff1698488cbaacc8785c85c94b4"
+ "hash": "7ac7deac86fece536c0dcc0d3555c3caae31316887a629866c6a90ddee373317"
}
diff --git a/.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json b/.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json
new file mode 100644
index 0000000..e775ef3
--- /dev/null
+++ b/.sqlx/query-8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE session_tokens SET last_reauth_at = $1 WHERE did = $2",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Timestamptz",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "8290c8ec5798a827bab64a17c3d4bf34bd0b88971b0658d191ed57badbbfd979"
+}
diff --git a/.sqlx/query-8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8.json b/.sqlx/query-8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8.json
new file mode 100644
index 0000000..b1e7a0b
--- /dev/null
+++ b/.sqlx/query-8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8.json
@@ -0,0 +1,41 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "did",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "handle",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "password_required",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "8835e3653c1b65874ff2828a1993b4505d5442b12d00b9062ee0db5f58ae05b8"
+}
diff --git a/.sqlx/query-976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a.json b/.sqlx/query-976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a.json
new file mode 100644
index 0000000..93994b9
--- /dev/null
+++ b/.sqlx/query-976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT last_reauth_at FROM session_tokens WHERE did = $1 ORDER BY created_at DESC LIMIT 1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "last_reauth_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ true
+ ]
+ },
+ "hash": "976847b83e599effda5ad3c0059cccf1df977c95dba43937de548b56ccc8256a"
+}
diff --git a/.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json b/.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json
deleted file mode 100644
index 2ae0005..0000000
--- a/.sqlx/query-97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5.json
+++ /dev/null
@@ -1,17 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "INSERT INTO channel_verifications (user_id, channel, code, pending_identifier, expires_at) VALUES ($1, 'email', $2, $3, $4)",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Uuid",
- "Text",
- "Text",
- "Timestamptz"
- ]
- },
- "nullable": []
- },
- "hash": "97b7414f11c3d696afe2d7007dbf52074bfda921bbb300f23bdf1ccb096b5ea5"
-}
diff --git a/.sqlx/query-9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4.json b/.sqlx/query-9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4.json
new file mode 100644
index 0000000..c9788a4
--- /dev/null
+++ b/.sqlx/query-9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE users SET password_hash = NULL, password_required = FALSE WHERE id = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "9fc6b64243ef6a4906ea9eb1ae630004dc6b40b9495fa998caf6e4cdd26a43e4"
+}
diff --git a/.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json b/.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json
new file mode 100644
index 0000000..b9a0999
--- /dev/null
+++ b/.sqlx/query-a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848.json
@@ -0,0 +1,40 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "did",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "recovery_token",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "recovery_token_expires_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ true
+ ]
+ },
+ "hash": "a10a29aee170a54af2ddbd59cf989a2910508b9f7e6f60465dd4cb5c7a79d848"
+}
diff --git a/.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json b/.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json
index b290de5..a84c501 100644
--- a/.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json
+++ b/.sqlx/query-c60e77678da0c42399179015971f55f4f811a0d666237a93035cfece07445590.json
@@ -63,7 +63,7 @@
false,
false,
false,
- false,
+ true,
false,
false,
false,
diff --git a/.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json b/.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json
index ab84790..2e3fb27 100644
--- a/.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json
+++ b/.sqlx/query-cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745.json
@@ -15,7 +15,7 @@
]
},
"nullable": [
- false
+ true
]
},
"hash": "cbd7ee75bb7e318ba7327136094d58397bbf306c249bffd286457e471c00b745"
diff --git a/.sqlx/query-d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f.json b/.sqlx/query-d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f.json
new file mode 100644
index 0000000..03a3984
--- /dev/null
+++ b/.sqlx/query-d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f.json
@@ -0,0 +1,52 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT od.id, od.user_agent, od.friendly_name, od.trusted_at, od.trusted_until, od.last_seen_at\n FROM oauth_device od\n JOIN oauth_account_device oad ON od.id = oad.device_id\n WHERE oad.did = $1 AND od.trusted_until IS NOT NULL AND od.trusted_until > NOW()\n ORDER BY od.last_seen_at DESC",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 1,
+ "name": "user_agent",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "friendly_name",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "trusted_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 4,
+ "name": "trusted_until",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 5,
+ "name": "last_seen_at",
+ "type_info": "Timestamptz"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ true,
+ true,
+ true,
+ false
+ ]
+ },
+ "hash": "d77baba1d885d532a18a0376a95774681fb0fe9e88733fa4315e9aef799cd19f"
+}
diff --git a/.sqlx/query-d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5.json b/.sqlx/query-d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5.json
new file mode 100644
index 0000000..f2fa3a7
--- /dev/null
+++ b/.sqlx/query-d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "d9409c8faeef28bc048ab4462681a7e3b62280bb697a81cbd39ff8a1207651a5"
+}
diff --git a/.sqlx/query-d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b.json b/.sqlx/query-e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7.json
similarity index 58%
rename from .sqlx/query-d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b.json
rename to .sqlx/query-e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7.json
index 1f3560c..58a4fdd 100644
--- a/.sqlx/query-d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b.json
+++ b/.sqlx/query-e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL WHERE id = $2",
+ "query": "UPDATE users SET password_hash = $1, password_reset_code = NULL, password_reset_code_expires_at = NULL, password_required = TRUE WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
@@ -11,5 +11,5 @@
},
"nullable": []
},
- "hash": "d7259198aa28f202fbc5bb9466c8a16446b664532e1bc9eff6a783652265229b"
+ "hash": "e44b36de8d7822040dfaf7407b2ef3787606f9c74041deaceb7b011680f7b0a7"
}
diff --git a/.sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json b/.sqlx/query-eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f.json
similarity index 75%
rename from .sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json
rename to .sqlx/query-eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f.json
index 5f96327..484db6b 100644
--- a/.sqlx/query-f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0.json
+++ b/.sqlx/query-eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "\n SELECT id, did, email, password_hash, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ",
+ "query": "\n SELECT id, did, email, password_hash, password_required, two_factor_enabled,\n preferred_comms_channel as \"preferred_comms_channel: CommsChannel\",\n deactivated_at, takedown_ref,\n email_verified, discord_verified, telegram_verified, signal_verified\n FROM users\n WHERE handle = $1 OR email = $1\n ",
"describe": {
"columns": [
{
@@ -25,11 +25,16 @@
},
{
"ordinal": 4,
- "name": "two_factor_enabled",
+ "name": "password_required",
"type_info": "Bool"
},
{
"ordinal": 5,
+ "name": "two_factor_enabled",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 6,
"name": "preferred_comms_channel: CommsChannel",
"type_info": {
"Custom": {
@@ -46,32 +51,32 @@
}
},
{
- "ordinal": 6,
+ "ordinal": 7,
"name": "deactivated_at",
"type_info": "Timestamptz"
},
{
- "ordinal": 7,
+ "ordinal": 8,
"name": "takedown_ref",
"type_info": "Text"
},
{
- "ordinal": 8,
+ "ordinal": 9,
"name": "email_verified",
"type_info": "Bool"
},
{
- "ordinal": 9,
+ "ordinal": 10,
"name": "discord_verified",
"type_info": "Bool"
},
{
- "ordinal": 10,
+ "ordinal": 11,
"name": "telegram_verified",
"type_info": "Bool"
},
{
- "ordinal": 11,
+ "ordinal": 12,
"name": "signal_verified",
"type_info": "Bool"
}
@@ -85,6 +90,7 @@
false,
false,
true,
+ true,
false,
false,
false,
@@ -96,5 +102,5 @@
false
]
},
- "hash": "f6aede22ec69c30a653b573fed52310cc84faa056f230b0d7ea62a0b457534e0"
+ "hash": "eeaf29b5efeb08c4729dec89f1e76c817a53bbf99998c5b1e428227d1b223b0f"
}
diff --git a/.sqlx/query-f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289.json b/.sqlx/query-f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289.json
new file mode 100644
index 0000000..7dcbcbe
--- /dev/null
+++ b/.sqlx/query-f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289.json
@@ -0,0 +1,46 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT id, handle, recovery_token, recovery_token_expires_at, password_required\n FROM users WHERE did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "handle",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "recovery_token",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "recovery_token_expires_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 4,
+ "name": "password_required",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true,
+ true,
+ false
+ ]
+ },
+ "hash": "f6ece5d279114e72f575229979e1123f1c4e0cfa721449a3f4a495e6c3ce0289"
+}
diff --git a/.sqlx/query-fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba.json b/.sqlx/query-fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba.json
new file mode 100644
index 0000000..617adf8
--- /dev/null
+++ b/.sqlx/query-fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT password_hash IS NOT NULL as has_pw FROM users WHERE did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "has_pw",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "fcf8ca1f6261521bcbf4dbfdbfaf69e242cd9c16687fa9a72a618d57c8f0d9ba"
+}
diff --git a/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json b/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
index 9e183a2..19e35a1 100644
--- a/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
+++ b/.sqlx/query-fde01bb40898f8a5d45a6e8f89c635c06b4179b5858a7b388404c4b03fc92ab4.json
@@ -41,7 +41,8 @@
"admin_email",
"plc_operation",
"two_factor_code",
- "channel_verification"
+ "channel_verification",
+ "passkey_recovery"
]
}
}
diff --git a/.sqlx/query-fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4.json b/.sqlx/query-fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4.json
new file mode 100644
index 0000000..96c6668
--- /dev/null
+++ b/.sqlx/query-fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4.json
@@ -0,0 +1,40 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT id, preferred_comms_channel as \"preferred_comms_channel: CommsChannel\" FROM users WHERE did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "preferred_comms_channel: CommsChannel",
+ "type_info": {
+ "Custom": {
+ "name": "comms_channel",
+ "kind": {
+ "Enum": [
+ "email",
+ "discord",
+ "telegram",
+ "signal"
+ ]
+ }
+ }
+ }
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "fdff88b03b8fe4679e29b06b3cfa386c68f8539725e8558643889a4ef92067b4"
+}
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index bd291e9..0586495 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -3,8 +3,11 @@
import { initAuth, getAuthState } from './lib/auth.svelte'
import Login from './routes/Login.svelte'
import Register from './routes/Register.svelte'
+ import RegisterPasskey from './routes/RegisterPasskey.svelte'
import Verify from './routes/Verify.svelte'
import ResetPassword from './routes/ResetPassword.svelte'
+ import RecoverPasskey from './routes/RecoverPasskey.svelte'
+ import RequestPasskeyRecovery from './routes/RequestPasskeyRecovery.svelte'
import Dashboard from './routes/Dashboard.svelte'
import AppPasswords from './routes/AppPasswords.svelte'
import InviteCodes from './routes/InviteCodes.svelte'
@@ -18,8 +21,10 @@
import OAuthAccounts from './routes/OAuthAccounts.svelte'
import OAuth2FA from './routes/OAuth2FA.svelte'
import OAuthTotp from './routes/OAuthTotp.svelte'
+ import OAuthPasskey from './routes/OAuthPasskey.svelte'
import OAuthError from './routes/OAuthError.svelte'
import Security from './routes/Security.svelte'
+ import TrustedDevices from './routes/TrustedDevices.svelte'
const auth = getAuthState()
@@ -33,10 +38,16 @@
return Login
case '/register':
return Register
+ case '/register-passkey':
+ return RegisterPasskey
case '/verify':
return Verify
case '/reset-password':
return ResetPassword
+ case '/recover-passkey':
+ return RecoverPasskey
+ case '/request-passkey-recovery':
+ return RequestPasskeyRecovery
case '/dashboard':
return Dashboard
case '/app-passwords':
@@ -63,10 +74,14 @@
return OAuth2FA
case '/oauth/totp':
return OAuthTotp
+ case '/oauth/passkey':
+ return OAuthPasskey
case '/oauth/error':
return OAuthError
case '/security':
return Security
+ case '/trusted-devices':
+ return TrustedDevices
default:
return auth.session ? Dashboard : Login
}
diff --git a/frontend/src/components/ReauthModal.svelte b/frontend/src/components/ReauthModal.svelte
new file mode 100644
index 0000000..264a372
--- /dev/null
+++ b/frontend/src/components/ReauthModal.svelte
@@ -0,0 +1,430 @@
+
+
+{#if show}
+
+
e.stopPropagation()} role="dialog" aria-modal="true">
+
+
+
+ This action requires you to verify your identity.
+
+
+ {#if error}
+
{error}
+ {/if}
+
+ {#if availableMethods.length > 1}
+
+ {#if availableMethods.includes('password')}
+
+ {/if}
+ {#if availableMethods.includes('totp')}
+
+ {/if}
+ {#if availableMethods.includes('passkey')}
+
+ {/if}
+
+ {/if}
+
+
+ {#if activeMethod === 'password'}
+
+ {:else if activeMethod === 'totp'}
+
+ {:else if activeMethod === 'passkey'}
+
+
Click the button below to authenticate with your passkey.
+
+
+ {/if}
+
+
+
+
+
+{/if}
+
+
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index c918a1f..37f7363 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -2,10 +2,12 @@ const API_BASE = '/xrpc'
export class ApiError extends Error {
public did?: string
- constructor(public status: number, public error: string, message: string, did?: string) {
+ public reauthMethods?: string[]
+ constructor(public status: number, public error: string, message: string, did?: string, reauthMethods?: string[]) {
super(message)
this.name = 'ApiError'
this.did = did
+ this.reauthMethods = reauthMethods
}
}
@@ -35,7 +37,7 @@ async function xrpc(method: string, options?: {
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Unknown', message: res.statusText }))
- throw new ApiError(res.status, err.error, err.message, err.did)
+ throw new ApiError(res.status, err.error, err.message, err.did, err.reauth_methods)
}
return res.json()
}
@@ -208,10 +210,11 @@ export const api = {
})
},
- async requestEmailUpdate(token: string): Promise<{ tokenRequired: boolean }> {
+ async requestEmailUpdate(token: string, email: string): Promise<{ tokenRequired: boolean }> {
return xrpc('com.atproto.server.requestEmailUpdate', {
method: 'POST',
token,
+ body: { email },
})
},
@@ -317,6 +320,17 @@ export const api = {
})
},
+ async removePassword(token: string): Promise<{ success: boolean }> {
+ return xrpc('com.tranquil.account.removePassword', {
+ method: 'POST',
+ token,
+ })
+ },
+
+ async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
+ return xrpc('com.tranquil.account.getPasswordStatus', { token })
+ },
+
async listSessions(token: string): Promise<{
sessions: Array<{
id: string
@@ -569,4 +583,128 @@ export const api = {
body: { id, friendlyName },
})
},
+
+ async listTrustedDevices(token: string): Promise<{
+ devices: Array<{
+ id: string
+ userAgent: string | null
+ friendlyName: string | null
+ trustedAt: string | null
+ trustedUntil: string | null
+ lastSeenAt: string
+ }>
+ }> {
+ return xrpc('com.tranquil.account.listTrustedDevices', { token })
+ },
+
+ async revokeTrustedDevice(token: string, deviceId: string): Promise<{ success: boolean }> {
+ return xrpc('com.tranquil.account.revokeTrustedDevice', {
+ method: 'POST',
+ token,
+ body: { deviceId },
+ })
+ },
+
+ async updateTrustedDevice(token: string, deviceId: string, friendlyName: string): Promise<{ success: boolean }> {
+ return xrpc('com.tranquil.account.updateTrustedDevice', {
+ method: 'POST',
+ token,
+ body: { deviceId, friendlyName },
+ })
+ },
+
+ async getReauthStatus(token: string): Promise<{
+ requiresReauth: boolean
+ lastReauthAt: string | null
+ availableMethods: string[]
+ }> {
+ return xrpc('com.tranquil.account.getReauthStatus', { token })
+ },
+
+ async reauthPassword(token: string, password: string): Promise<{ success: boolean; reauthAt: string }> {
+ return xrpc('com.tranquil.account.reauthPassword', {
+ method: 'POST',
+ token,
+ body: { password },
+ })
+ },
+
+ async reauthTotp(token: string, code: string): Promise<{ success: boolean; reauthAt: string }> {
+ return xrpc('com.tranquil.account.reauthTotp', {
+ method: 'POST',
+ token,
+ body: { code },
+ })
+ },
+
+ async reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
+ return xrpc('com.tranquil.account.reauthPasskeyStart', {
+ method: 'POST',
+ token,
+ })
+ },
+
+ async reauthPasskeyFinish(token: string, credential: unknown): Promise<{ success: boolean; reauthAt: string }> {
+ return xrpc('com.tranquil.account.reauthPasskeyFinish', {
+ method: 'POST',
+ token,
+ body: { credential },
+ })
+ },
+
+ async createPasskeyAccount(params: {
+ handle: string
+ email?: string
+ inviteCode?: string
+ didType?: DidType
+ did?: string
+ signingKey?: string
+ verificationChannel?: VerificationChannel
+ discordId?: string
+ telegramUsername?: string
+ signalNumber?: string
+ }): Promise<{
+ did: string
+ handle: string
+ setupToken: string
+ setupExpiresAt: string
+ }> {
+ return xrpc('com.tranquil.account.createPasskeyAccount', {
+ method: 'POST',
+ body: params,
+ })
+ },
+
+ async startPasskeyRegistrationForSetup(did: string, setupToken: string, friendlyName?: string): Promise<{ options: unknown }> {
+ return xrpc('com.tranquil.account.startPasskeyRegistrationForSetup', {
+ method: 'POST',
+ body: { did, setupToken, friendlyName },
+ })
+ },
+
+ async completePasskeySetup(did: string, setupToken: string, passkeyCredential: unknown, passkeyFriendlyName?: string): Promise<{
+ did: string
+ handle: string
+ appPassword: string
+ appPasswordName: string
+ }> {
+ return xrpc('com.tranquil.account.completePasskeySetup', {
+ method: 'POST',
+ body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
+ })
+ },
+
+ async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
+ return xrpc('com.tranquil.account.requestPasskeyRecovery', {
+ method: 'POST',
+ body: { email },
+ })
+ },
+
+ async recoverPasskeyAccount(did: string, recoveryToken: string, newPassword: string): Promise<{ success: boolean }> {
+ return xrpc('com.tranquil.account.recoverPasskeyAccount', {
+ method: 'POST',
+ body: { did, recoveryToken, newPassword },
+ })
+ },
}
diff --git a/frontend/src/routes/Login.svelte b/frontend/src/routes/Login.svelte
index 127359e..d79e524 100644
--- a/frontend/src/routes/Login.svelte
+++ b/frontend/src/routes/Login.svelte
@@ -142,7 +142,7 @@
{submitting ? 'Redirecting...' : 'Sign In'}
- Forgot password?
+ Forgot password? · Lost passkey?
Don't have an account? Create one
diff --git a/frontend/src/routes/OAuthConsent.svelte b/frontend/src/routes/OAuthConsent.svelte
index 3cf35db..efb00a2 100644
--- a/frontend/src/routes/OAuthConsent.svelte
+++ b/frontend/src/routes/OAuthConsent.svelte
@@ -115,8 +115,8 @@
try {
const response = await fetch('/oauth/authorize/deny', {
method: 'POST',
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
- body: `request_uri=${encodeURIComponent(consentData.request_uri)}`
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ request_uri: consentData.request_uri })
})
if (response.redirected) {
diff --git a/frontend/src/routes/OAuthLogin.svelte b/frontend/src/routes/OAuthLogin.svelte
index 001815d..e6ccaf3 100644
--- a/frontend/src/routes/OAuthLogin.svelte
+++ b/frontend/src/routes/OAuthLogin.svelte
@@ -399,9 +399,28 @@
+
+
+ Forgot password? · Lost passkey?
+
diff --git a/frontend/src/routes/OAuthTotp.svelte b/frontend/src/routes/OAuthTotp.svelte
index dc5935a..14d8730 100644
--- a/frontend/src/routes/OAuthTotp.svelte
+++ b/frontend/src/routes/OAuthTotp.svelte
@@ -2,6 +2,7 @@
import { navigate } from '../lib/router.svelte'
let code = $state('')
+ let trustDevice = $state(false)
let submitting = $state(false)
let error = $state(null)
@@ -30,7 +31,8 @@
},
body: JSON.stringify({
request_uri: requestUri,
- code: code.trim().toUpperCase()
+ code: code.trim().toUpperCase(),
+ trust_device: trustDevice
})
})
@@ -104,6 +106,15 @@
+
+
diff --git a/frontend/src/routes/RequestPasskeyRecovery.svelte b/frontend/src/routes/RequestPasskeyRecovery.svelte
new file mode 100644
index 0000000..87ad3d0
--- /dev/null
+++ b/frontend/src/routes/RequestPasskeyRecovery.svelte
@@ -0,0 +1,205 @@
+
+
+
+ {#if success}
+
+
Recovery Link Sent
+
+ If your account exists and is a passkey-only account, you'll receive a recovery link
+ at your preferred notification channel.
+
+
+ The link will expire in 1 hour. Check your email, Discord, Telegram, or Signal
+ depending on your account settings.
+
+
navigate('/login')}>Back to Sign In
+
+ {:else}
+
Recover Passkey Account
+
+ Lost access to your passkey? Enter your handle or email and we'll send you a recovery link.
+
+
+ {#if error}
+
{error}
+ {/if}
+
+
+ {/if}
+
+
+ Back to Sign In
+
+
+
+
diff --git a/frontend/src/routes/ResetPassword.svelte b/frontend/src/routes/ResetPassword.svelte
index 95e972e..05394b8 100644
--- a/frontend/src/routes/ResetPassword.svelte
+++ b/frontend/src/routes/ResetPassword.svelte
@@ -25,7 +25,7 @@
try {
await api.requestPasswordReset(email)
tokenSent = true
- success = 'Password reset code sent to your email'
+ success = 'Password reset code sent! Check your preferred notification channel.'
} catch (e) {
error = e instanceof ApiError ? e.message : 'Failed to send reset code'
} finally {
@@ -66,7 +66,7 @@
{/if}
{#if tokenSent}
Reset Password
- Enter the code from your email and choose a new password.
+ Enter the code you received and choose a new password.