Compare commits

..
51 Commits
Author SHA1 Message Date
Lewis 8d0e0c6810 feat(tranquil-store): whole test suite working
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:57:44 +03:00
Lewis 1720de8650 feat(tranquil-store): repository traits on MetastoreClient
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:57:43 +03:00
Lewis a5c68a3506 feat(tranquil-store): metastore
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:57:43 +03:00
Lewis 9ea33def13 feat(tranquil-store): eventlog
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:54:41 +03:00
Lewis a9e3ec63e1 feat(tranquil-store): blockstore
Lewis: May this revision serve well! <lu5a@proton.me>
2026-04-10 13:54:41 +03:00
HigherOrderLogicandTangled dbc3c23c95 fix(nix): allow fetch Git deps 2026-04-09 21:30:03 +00:00
nekomimi.petandTangled 531ca7c1c0 fix(lexicon): wildcard mime type handling and case insensitivity 2026-04-09 21:28:37 +00:00
nelindandTangled add1ff176b fix(auth): keep lxm optional and dont default to * when no lxm is given in getServiceAuth 2026-04-04 08:52:42 +00:00
nelind bdb200a30c fix(ci): dont run it at all for now. it doesnt work with the currently spindles 2026-04-01 23:45:03 +02:00
nelind e5faba3e73 fix(ci): define CI event run correctly so it actually runs 2026-04-01 23:38:19 +02:00
IslaandTangled 5dc810ceef Further harden systemd service 2026-03-31 22:25:11 +00:00
juneandTangled 6abc0eaca2 feat(plc): support AtprotoLabeler service 2026-03-28 20:54:43 +00:00
nelindandTangled 696940f732 fix(sync): dont keep websockets arround indefinetly if client abruptly closes connection 2026-03-25 16:47:38 +00:00
nelindandTangled f777c288da fix(pds): do service identifier resolution for proxying correctly 2026-03-23 03:00:57 +00:00
teqandTangled 9039648705 fix(nix): generate-lockfile for fix/signal-nix-build-deps 2026-03-23 03:00:23 +00:00
teqandTangled 770a16b043 fix(nix): add hashes for git deps 2026-03-23 03:00:23 +00:00
teqandTangled d37f38e496 fix(nix): add protobuf for spqr 2026-03-23 03:00:23 +00:00
Lewis 282f08f207 feat(rfc): outline tranquil-store idea for comment
Lewis: May this revision serve well! <lu5a@proton.me>
2026-03-22 18:54:51 +02:00
LewisandTangled f07bcb82f7 refactor(sync): extract MST parsing and serialization utilities 2026-03-22 12:51:44 +00:00
LewisandTangled e38343ce09 refactor(oauth): split authorize.rs into domain modules 2026-03-22 12:51:44 +00:00
LewisandTangled e454e99b8a refactor(api): migrate all endpoints to repos accessor pattern 2026-03-22 12:51:44 +00:00
LewisandTangled 19b0ea19c6 refactor(tranquil-pds): consolidate repos, rate limits, validation, and auth helpers 2026-03-22 12:51:44 +00:00
LewisandTangled 318818ad51 feat(signal): add admin UI, frontend, and build changes 2026-03-22 07:14:20 +00:00
LewisandTangled 7c55a5ceb9 feat(signal): add admin endpoints, config, and server wiring 2026-03-22 07:14:20 +00:00
LewisandTangled 9d31ee9ace refactor(comms): extract HTTP retry combinator and integrate signal sender 2026-03-22 07:14:20 +00:00
LewisandTangled e3efcb43ba test(signal): add protocol store integration tests 2026-03-22 07:14:20 +00:00
LewisandTangled eb46824220 feat(signal): add presage client, newtypes, and slot management 2026-03-22 07:14:20 +00:00
LewisandTangled e6c8a44a42 feat(signal): add postgres-backed signal protocol store 2026-03-22 07:14:19 +00:00
Lewis aac6f2818b fix(oauth): fail properly on non-expanded include scopes 2026-03-21 11:36:37 +02:00
Lewis 1a80a33e12 fix(ci): small tweaks 2026-03-21 10:06:31 +02:00
HigherOrderLogicandTangled 00d71fd3d6 feat(ci): publish image on commit 2026-03-21 02:10:23 +00:00
LewisandTangled c8bb4c9076 refactor(api): update delegation, notification prefs, email, meta, and age assurance endpoints 2026-03-20 13:39:11 +00:00
LewisandTangled d123168787 refactor(api): update password, reauth, verify, account_status, and totp endpoints 2026-03-20 13:39:11 +00:00
LewisandTangled b337d2b154 refactor(api): simplify passkey account creation and auth-adjacent server endpoints 2026-03-20 13:39:11 +00:00
LewisandTangled 1e07d674dd refactor(api): rework session login flow to use common credential verification 2026-03-20 13:39:11 +00:00
LewisandTangled a3f96b6367 refactor(api): centralize DID document building, update admin endpoints 2026-03-20 13:39:11 +00:00
LewisandTangled 7b7936d539 refactor(api): extract account provisioning helpers, simplify create_account flow 2026-03-20 13:39:11 +00:00
LewisandTangled 7e3dca4026 refactor(api): update repo batch/delete to use repo_ops, clean up remaining repo endpoints 2026-03-20 13:39:11 +00:00
LewisandTangled 7bc90d5e23 refactor(api): extract repo write lifecycle to repo_ops 2026-03-20 13:39:11 +00:00
LewisandTangled 4d86f026df refactor(api): extract common helpers module, extend API error types with auth methods 2026-03-20 13:39:11 +00:00
LewisandTangled 5c8894d531 refactor: update routes, backend verification tweaks, and restyle 2026-03-19 14:26:26 +00:00
LewisandTangled 81fc03c705 refactor(frontend): refactor migration and registration lib 2026-03-19 14:26:26 +00:00
LewisandTangled f3f55e239f refactor(frontend): refactor migration components 2026-03-19 14:26:26 +00:00
LewisandTangled cf77354cde refactor(frontend): rewrite SecurityContent 2026-03-19 14:26:26 +00:00
LewisandTangled 467be99da3 refactor(frontend): extract dashboard sections 2026-03-19 14:26:26 +00:00
LewisandTangled 20212a413f refactor(frontend): simplify API client and delete obsolete auth routes 2026-03-19 14:26:26 +00:00
LewisandTangled 6225d2bc1d refactor(frontend): delete RegisterPassword and UiTest routes 2026-03-19 14:26:26 +00:00
LewisandTangled b990c239e6 refactor(frontend): delete OAuthRegister and OAuthSsoRegister routes 2026-03-19 14:26:26 +00:00
LewisandTangled 5b503c3f7f refactor(frontend): delete utility libraries and validation 2026-03-19 14:26:26 +00:00
LewisandTangled 7f66419036 refactor(frontend): delete type system boilerplate 2026-03-19 14:26:26 +00:00
Lewis c2b8253abe fix(api): always give xrpc error messages 2026-03-18 17:53:01 +02:00
384 changed files with 75810 additions and 18964 deletions
+8
View File
@@ -29,6 +29,10 @@ test-group = "serial-env-tests"
filter = "binary(ripple_cluster)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "package(tranquil-signal)"
test-group = "serial-env-tests"
[[profile.default.overrides]]
filter = "binary(whole_story)"
test-group = "heavy-load-tests"
@@ -53,6 +57,10 @@ test-group = "serial-env-tests"
filter = "binary(ripple_cluster)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "package(tranquil-signal)"
test-group = "serial-env-tests"
[[profile.ci.overrides]]
filter = "binary(whole_story)"
test-group = "heavy-load-tests"
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_kyber_pre_keys",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "0295414146e4ddacad638486443fd1036609ab8443390820be8696f708be3812"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_sender_keys\n (address, device_id, identity, distribution_id, record)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (address, device_id, identity, distribution_id) DO UPDATE SET record = $5",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int4",
"Text",
"Uuid",
"Bytea"
]
},
"nullable": []
},
"hash": "0a3eaf432e99ef22aa6b3b43978740b085f6416a058ea2da6d639ea1cf276eda"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT cid FROM blocks ORDER BY created_at ASC LIMIT $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "cid",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false
]
},
"hash": "12f5864ebff622fc52643de7151a40e984082851741b22f63a170728e734763b"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_kv",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "166ee5a554e3ddc75496616d9fba75a15cc0c072f493f579b881a4f2300f38c8"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT t.cid FROM UNNEST($1::bytea[]) AS t(cid)\n WHERE NOT EXISTS (\n SELECT 1 FROM user_blocks WHERE block_cid = t.cid\n )\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "cid",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"ByteaArray"
]
},
"nullable": [
null
]
},
"hash": "18fa821e4bd00ccf5d1d8395ba728e4905d69f9fe527b4d4b49c69deff52cea8"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT record FROM signal_signed_pre_keys WHERE id = $1 AND identity = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "record",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": [
false
]
},
"hash": "1a061c261518573c29aa55c5d8e2181f1728034e723ed0bc836e928b4c69128f"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT record FROM signal_kyber_pre_keys WHERE id = $1 AND identity = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "record",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": [
false
]
},
"hash": "1aac5ab7dbc29f0d0937767ae174e8b47d236b3cdc973974cec413dc65198d64"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_kyber_pre_keys (id, identity, record, is_last_resort)\n VALUES ($1, $2, $3, FALSE)\n ON CONFLICT (id, identity) DO UPDATE SET record = $3, is_last_resort = FALSE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text",
"Bytea"
]
},
"nullable": []
},
"hash": "1d4459863eeddea9ccb5ce5c0eaffd39d56a763728ce5b1f76871265e704e241"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET is_admin = $1 WHERE did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "1e63d287c619a14e5c07d80e8e54193d2964c8b5e6a855256cb80c4d0cd2c6ea"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_sessions WHERE address = $1 AND identity = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "1f464d947e93274cbc974f8a6f31d8a47eb676df857fa83f08ed1937cf8a6d9d"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_base_keys_seen",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "222836500a964ee6fd238ddb128691215cd15d2ede9a1a19e7a580e4d6644ce8"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT t.token, t.expires_at\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n 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": "237c2d912e89b7e0e5baa83503a22f158ea1614b5157f6c9e2aba6017fef6b26"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_identities",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "252d42bb50ef918fe414d036979daba055dbadd3cd7e1bdcce4526867be79930"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_kv (key, value) VALUES ($1, $2)\n ON CONFLICT (key) DO UPDATE SET value = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Bytea"
]
},
"nullable": []
},
"hash": "29c834b7870cdbcdcc2d2c9259a04326e20433237ebe25e33dbd39952ae9f976"
}
@@ -0,0 +1,14 @@
{
"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"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_base_keys_seen\n (kyber_pre_key_id, signed_pre_key_id, identity, base_key)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Int4",
"Text",
"Bytea"
]
},
"nullable": []
},
"hash": "31edfe22cdd8e5f62322dad01a65165ec9546eaa92c9e372a5a4fce99d938d22"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_kv WHERE key = 'registration'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "374be0bd97ed927b15929e2f86d88913b2fec45ab67e4805fb9f1e39b422ee7f"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_pre_keys WHERE id = $1 AND identity = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "3a3eee11be6a9c5fe012d2a634c7ef514fb695ca6a4ba7631d1c5aba87a854a5"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_sender_keys",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "4011d789a6b619231f4e2b917753f0b4452f21243e1d95d47dbaa91cac019e64"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value FROM signal_kv WHERE key = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "4316de453ec1d5ba65a1dbee29599287d42bf87517936b3b61958c0675ce8115"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_sessions",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "43583f8246016d15d9875c4d33855317c41ae07af5676228c0533724590ec6ed"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO backlinks (uri, path, link_to, repo_id)\n SELECT unnest($1::text[]), unnest($2::text[]), unnest($3::text[]), $4\n ON CONFLICT (uri, path) DO NOTHING\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"TextArray",
"TextArray",
"Uuid"
]
},
"nullable": []
},
"hash": "47149c0577ad9e9b9b089820b0c93417769a4a37affe0e3972e324ec27ec532f"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT record FROM signal_identities WHERE address = $1 AND identity = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "record",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "4bcd896340fc9178e2d97ad572269e8f57561190ec5f94ccfa06ca93f4827e79"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_sessions (address, device_id, identity, record)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (address, device_id, identity) DO UPDATE SET record = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int4",
"Text",
"Bytea"
]
},
"nullable": []
},
"hash": "4d22ba21356bdc84ea549799c77cab8c34ca9a5f0815150254b590fd87509eb8"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT is_last_resort FROM signal_kyber_pre_keys WHERE id = $1 AND identity = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "is_last_resort",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": [
false
]
},
"hash": "513a0d3b907df1004f2178ebf14fc182f20fc13c5691cbeb7656d428cb6437f9"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_kv (key, value) VALUES ('master_key', $1)\n ON CONFLICT (key) DO UPDATE SET value = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bytea"
]
},
"nullable": []
},
"hash": "53814b5db4f051c30573d146007ddc487f4c12c726bb970efe17e325536629e4"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_kv (key, value) VALUES ('sender_certificate', $1)\n ON CONFLICT (key) DO UPDATE SET value = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bytea"
]
},
"nullable": []
},
"hash": "5cd556968671fc8ecf57dd7fd40f65e3a168f17bd389ce2cf7ddee27d810cf1d"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "code",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "5cee16f49a727d66b5231a8d07d7f4bcb6a1136fbf3e3d249fd33600772ac80f"
}
@@ -0,0 +1,34 @@
{
"db_name": "PostgreSQL",
"query": "SELECT token, did, expires_at FROM account_deletion_requests WHERE did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false,
false
]
},
"hash": "61f489b4fc42f5b0aaea287cde4415da6f5e96b3a0f36216bdc6dea924b09abd"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_identities (address, identity, record)\n VALUES ($1, $2, $3)\n ON CONFLICT (address, identity) DO UPDATE SET record = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Bytea"
]
},
"nullable": []
},
"hash": "62dc57cf7ad007a6458df06619fe7e0bf5ffeba195e55793f6bf6d170e053226"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) AS \"count!\" FROM signal_kyber_pre_keys\n WHERE identity = $1 AND is_last_resort = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Bool"
]
},
"nullable": [
null
]
},
"hash": "63ecf786e52a52d83232f6d4c8d93d5d7065f5dbfd3cd1e259d7990d02da1114"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT record FROM signal_kyber_pre_keys\n WHERE identity = $1 AND is_last_resort = TRUE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "record",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "6a91d97ed4f955ce0936eced6318c630f39ebebc52cee584f1b0dacefec984cd"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_kv WHERE key = 'master_key'",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "6cb39ddd462ae93fe97d930ce64425ed16271adc4ca066c4d51bb742beffc22b"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT record FROM signal_pre_keys WHERE id = $1 AND identity = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "record",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": [
false
]
},
"hash": "6cbfcd552a7b4eeb6a1e525f2f11e8f432405e3606e3af7d31b9eca32bc28607"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_kv (key, value) VALUES ('registration', $1)\n ON CONFLICT (key) DO UPDATE SET value = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bytea"
]
},
"nullable": []
},
"hash": "6f14e2d74ed0c1e0946c62da2a3ddd63344892bdd4022073123604ee17114ce0"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO user_totp (did, secret_encrypted, encryption_version, verified, created_at)\n VALUES ($1, $2, 1, TRUE, NOW())\n ON CONFLICT (did) DO UPDATE SET secret_encrypted = $2, verified = TRUE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Bytea"
]
},
"nullable": []
},
"hash": "7d3a9f0545943bc6a3a14fcd596aac5cc731c8177d74e504606d7e92c7d0c73f"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_pre_keys (id, identity, record)\n VALUES ($1, $2, $3)\n ON CONFLICT (id, identity) DO UPDATE SET record = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text",
"Bytea"
]
},
"nullable": []
},
"hash": "811af305fef56437151c1cfe27eaccf6312b8234f1217a81a4d36cd5758fd863"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE users SET two_factor_enabled = $1 WHERE did = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "84a1db51a98402323cb86bc19cd2b737f908222ea3426b8bf47d735aff5b6c75"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_profile_keys (uuid, key) VALUES ($1, $2)\n ON CONFLICT (uuid) DO UPDATE SET key = $2\n RETURNING (xmax = 0) AS \"inserted!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "inserted!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Bytea"
]
},
"nullable": [
null
]
},
"hash": "894169c5a40acade1000869bdde61d09984e17a686c1b487004d79897ecb4e4f"
}
@@ -0,0 +1,36 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM comms_queue WHERE user_id = $1 AND comms_type = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
{
"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",
"channel_verified"
]
}
}
}
]
},
"nullable": []
},
"hash": "89b0292d8d022fad8f9cda07b9a7870ca6a7ebe904b2d580956b0816b50bcdb7"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM blocks WHERE cid = ANY($1)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"ByteaArray"
]
},
"nullable": []
},
"hash": "8eecf8fef308716be88815eb59bb67ec7c534b3c821d55481b110e3e462ee366"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) as \"count!\"\n FROM plc_operation_tokens t\n JOIN users u ON t.user_id = u.id\n WHERE u.did = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "990bf50e60fc5566639c2c12cd968d154d7b0c6863ad69141653135f98fbc998"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT MAX(id) FROM signal_kyber_pre_keys\n WHERE identity = $1 AND is_last_resort = TRUE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "max",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "9da9a790efa51edd6abcb4b0991b307c3ba7f0a929e114a4e0cb2ffb88adfbbb"
}
@@ -0,0 +1,180 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n id, user_id,\n channel as \"channel: CommsChannel\",\n comms_type as \"comms_type: CommsType\",\n status as \"status: CommsStatus\",\n recipient, subject, body, metadata,\n attempts, max_attempts, last_error,\n created_at, updated_at, scheduled_for, processed_at\n FROM comms_queue\n WHERE user_id = $1 AND comms_type = $2\n ORDER BY created_at DESC\n LIMIT $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "user_id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "channel: CommsChannel",
"type_info": {
"Custom": {
"name": "comms_channel",
"kind": {
"Enum": [
"email",
"discord",
"telegram",
"signal"
]
}
}
}
},
{
"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",
"channel_verified"
]
}
}
}
},
{
"ordinal": 4,
"name": "status: CommsStatus",
"type_info": {
"Custom": {
"name": "comms_status",
"kind": {
"Enum": [
"pending",
"processing",
"sent",
"failed"
]
}
}
}
},
{
"ordinal": 5,
"name": "recipient",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "subject",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "body",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "metadata",
"type_info": "Jsonb"
},
{
"ordinal": 9,
"name": "attempts",
"type_info": "Int4"
},
{
"ordinal": 10,
"name": "max_attempts",
"type_info": "Int4"
},
{
"ordinal": 11,
"name": "last_error",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 13,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 14,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 15,
"name": "processed_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
{
"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",
"channel_verified"
]
}
}
},
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
true,
false,
true,
false,
false,
true,
false,
false,
false,
true
]
},
"hash": "9f3f2b36f11e9446915d3ca29ef81e4ada0c6a6d72764116dac4f99a4e09785e"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT device_id FROM signal_sessions\n WHERE address = $1 AND device_id != $2 AND identity = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "device_id",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text",
"Int4",
"Text"
]
},
"nullable": [
false
]
},
"hash": "9fdcff515573446f0b4340bf94421bb8a425cc65304fa8c3fd153945904136f0"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_profile_keys",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "a403c2982978159b120d4bfa6d6fdca5808b667e54b0765b5ada047ce6dac9d6"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE signal_kyber_pre_keys\n SET stale_at = $1\n WHERE identity = $2 AND is_last_resort = FALSE AND stale_at IS NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Timestamptz",
"Text"
]
},
"nullable": []
},
"hash": "adabc98c853ae77678ad6cf1747384706154334cc507eb7542a8b5490f5a1e2a"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_kyber_pre_keys (id, identity, record, is_last_resort)\n VALUES ($1, $2, $3, TRUE)\n ON CONFLICT (id, identity) DO UPDATE SET is_last_resort = TRUE, record = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text",
"Bytea"
]
},
"nullable": []
},
"hash": "b157b94e003376d827b92eab1ff55caa773ed5691e2e555ab1d1f23fc5adcf8e"
}
@@ -0,0 +1,44 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) as \"count!\" FROM comms_queue WHERE user_id = $1 AND comms_type = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
{
"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",
"channel_verified"
]
}
}
}
]
},
"nullable": [
null
]
},
"hash": "b364a2b202bab17c0cdc5f70d23b13841b4d9063d94cd0b09268c3dc41824fd2"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT MAX(id) FROM signal_pre_keys WHERE identity = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "max",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "b558892051ad793b4ae38a76d072e4ea440092663bd0d4ed9d48f5074788157a"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "SELECT record FROM signal_sender_keys\n WHERE address = $1 AND device_id = $2 AND identity = $3 AND distribution_id = $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "record",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Text",
"Int4",
"Text",
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "b583ea011f17bd703a1dde860d1cc595a18c33dd0e37b575a71fa78f9fddc96f"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_signed_pre_keys",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "c08770c1ca1cb553e9067c26dec9a47d40f4cd3e16461cf0edbbb49020695f18"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value FROM signal_kv WHERE key = 'registration'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Bytea"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "c130944c8791acb05ebf5f7b5b0f82b8c548ea9d516c0e9310fb46ffd3907977"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "WITH total AS (\n SELECT COUNT(*) AS cnt FROM signal_kyber_pre_keys\n WHERE identity = $1 AND is_last_resort = FALSE\n )\n DELETE FROM signal_kyber_pre_keys\n WHERE identity = $1 AND is_last_resort = FALSE\n AND stale_at IS NOT NULL AND stale_at < $2\n AND (SELECT cnt FROM total) > $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Timestamptz",
"Int8"
]
},
"nullable": []
},
"hash": "c30d8f2bb8a20154b565a93108d3871289a752fb8d0de35a2164619da2c03aa0"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) AS \"count!\" FROM signal_signed_pre_keys WHERE identity = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "c5310fca925ec4188e761f218e98c11182e5757c40a70a2aa50af0f2896d98e7"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT key FROM signal_profile_keys WHERE uuid = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "key",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "c67709e3fb975148fa86d9fd5ed5e1ef805b2f57a2ce5d955629d598e1ab2b80"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_kyber_pre_keys\n WHERE id = $1 AND identity = $2 AND is_last_resort = FALSE",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "c749d53e4e38dd53aa2ac59ba2b15b641ace056515c34ec977bf47d74dfd7072"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value FROM signal_kv WHERE key = 'master_key' LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Bytea"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "c86da12578b2e53e0bff374121b22244c4663820cbb6edfd8f0b8c9aa583cad2"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM backlinks WHERE uri = ANY($1::text[])",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray"
]
},
"nullable": []
},
"hash": "cffe4c37fe949fbdc3d5cd83ccec5655aae248a0a69dc260d1da9cf1d9ed2c49"
}
@@ -0,0 +1,14 @@
{
"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"
}
@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value FROM signal_kv WHERE key = 'sender_certificate' LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Bytea"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "dded08cb4fd0fc9bca55b3cf0c8d22f4e279bc8aedb72000f4c4bc093f61c750"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO signal_signed_pre_keys (id, identity, record)\n VALUES ($1, $2, $3)\n ON CONFLICT (id, identity) DO UPDATE SET record = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text",
"Bytea"
]
},
"nullable": []
},
"hash": "def40c1cfac196a066d81fb0efa0fa7bdf5f242bd13c8b70e47a512c5e9df5b1"
}
@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_pre_keys",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "e2ed0b4bfe13a99be85311fcc6e3c2dbdb789b9a44c0122f736ead48f4713a5b"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_kyber_pre_keys WHERE id = $1 AND identity = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "eb837730e87ff990c4441d7bdab001d7542d1803702c634a6099365a9740e45a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT MAX(id) FROM signal_kyber_pre_keys WHERE identity = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "max",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "eea566212ffce269028a8623a12b553c048ef1ea433aa5567e72b2b639f69b0d"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM signal_sessions WHERE address = $1 AND device_id = $2 AND identity = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "efcf513bba65213ed4b71259a7016ffd7b1b8ff85206416a86beba19323a3be7"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT record FROM signal_sessions\n WHERE address = $1 AND device_id = $2 AND identity = $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "record",
"type_info": "Bytea"
}
],
"parameters": {
"Left": [
"Text",
"Int4",
"Text"
]
},
"nullable": [
false
]
},
"hash": "f332e618e59a298573c4b6b6c7a12252b461a6676f2d797c5dae5a0baee016a7"
}
@@ -0,0 +1,52 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, did, public_key_did_key, private_key_bytes, expires_at, used_at\n FROM reserved_signing_keys WHERE public_key_did_key = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "did",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "public_key_did_key",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "private_key_bytes",
"type_info": "Bytea"
},
{
"ordinal": 4,
"name": "expires_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
true,
false,
false,
false,
true
]
},
"hash": "f3b07f153284b6dd1f22c098af8628d85dcdf20dd6273443ff98d02b6f5ecbf1"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT MAX(id) FROM signal_signed_pre_keys WHERE identity = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "max",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "f41749b35c081fc9d0881c7e2fc988e7c4d164ab5996f18e9f3a29fe4d183db9"
}
@@ -0,0 +1,28 @@
{
"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"
}
+24
View File
@@ -0,0 +1,24 @@
when:
- event: []
branch: []
engine: nixery
dependencies:
nixpkgs:
- podman
steps:
- name: Create podman config
command: |
mkdir -p ~/.config/containers
echo "unqualified-search-registries = [\"docker.io\"]" >> ~/.config/containers/registries.conf
- name: Build image
command: |
podman build . -t tranquil-pds:latest -t "tranquil-pds:$TANGLED_COMMIT_SHA"
- name: Publish image
command: |
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" tranquil-pds:latest "atcr.io/tranquil.farm/tranquil-pds:latest"
podman push --creds "$ATCR_USERNAME:$ATCR_PASSWORD" "tranquil-pds:$TANGLED_COMMIT_SHA" "atcr.io/tranquil.farm/tranquil-pds:$TANGLED_COMMIT_SHA"
Generated
+2296 -585
View File
File diff suppressed because it is too large Load Diff
+16 -1
View File
@@ -21,10 +21,12 @@ members = [
"crates/tranquil-oauth-server",
"crates/tranquil-api",
"crates/tranquil-lexicon",
"crates/tranquil-signal",
"crates/tranquil-store",
]
[workspace.package]
version = "0.4.4"
version = "0.5.0"
edition = "2024"
license = "AGPL-3.0-or-later"
@@ -49,6 +51,10 @@ tranquil-server = { path = "crates/tranquil-server" }
tranquil-sync = { path = "crates/tranquil-sync" }
tranquil-oauth-server = { path = "crates/tranquil-oauth-server" }
tranquil-api = { path = "crates/tranquil-api" }
tranquil-signal = { path = "crates/tranquil-signal", features = ["fjall-store"] }
tranquil-store = { path = "crates/tranquil-store" }
presage = { git = "https://github.com/whisperfish/presage", rev = "fe3ed54c4844ae51c3a9fa49cf80a7816a31a425", default-features = false }
unicode-segmentation = "1"
@@ -81,6 +87,7 @@ hkdf = "0.12"
hmac = "0.12"
http = "1.4"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp"] }
qrcodegen = "1.8"
infer = "0.19"
ipld-core = "0.4"
iroh-car = "0.5"
@@ -129,7 +136,15 @@ testcontainers = "0.26"
testcontainers-modules = { version = "0.14", features = ["postgres"] }
wiremock = "0.6"
[patch.crates-io]
curve25519-dalek = { git = "https://github.com/signalapp/curve25519-dalek", tag = "signal-curve25519-4.1.3" }
[profile.release]
lto = "fat"
strip = true
codegen-units = 1
panic = "abort"
[profile.bench]
debug = 1
strip = false
+5 -13
View File
@@ -4,7 +4,7 @@ COPY frontend/ ./
RUN deno task build
FROM rust:1.92-alpine AS builder
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang
RUN apk add --no-cache ca-certificates musl-dev pkgconfig openssl-dev openssl-libs-static mold clang protoc
ENV RUSTFLAGS="-C linker=clang -C link-arg=-fuse-ld=mold"
WORKDIR /app
ARG SLIM="false"
@@ -29,6 +29,8 @@ COPY crates/tranquil-pds ./crates/tranquil-pds
COPY crates/tranquil-sync ./crates/tranquil-sync
COPY crates/tranquil-api ./crates/tranquil-api
COPY crates/tranquil-oauth-server ./crates/tranquil-oauth-server
COPY crates/tranquil-store ./crates/tranquil-store
COPY crates/tranquil-signal ./crates/tranquil-signal
COPY crates/tranquil-server ./crates/tranquil-server
COPY migrations ./crates/tranquil-pds/migrations
RUN --mount=type=cache,target=/usr/local/cargo/registry \
@@ -40,23 +42,13 @@ RUN --mount=type=cache,target=/usr/local/cargo/registry \
fi && \
cp target/release/tranquil-server /tmp/tranquil-pds
FROM alpine:3.23 AS signal-cli
RUN apk add --no-cache curl tar
ARG SIGNAL_CLI_VERSION=0.13.24
RUN curl -fsSL "https://github.com/AsamK/signal-cli/releases/download/v${SIGNAL_CLI_VERSION}/signal-cli-${SIGNAL_CLI_VERSION}-Linux-native.tar.gz" \
| tar xz -C /usr/local/bin
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y --no-install-recommends msmtp ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
FROM alpine:3.23
RUN apk add --no-cache msmtp ca-certificates \
&& ln -sf /usr/bin/msmtp /usr/sbin/sendmail
COPY --from=signal-cli /usr/local/bin/signal-cli /usr/local/bin/signal-cli
VOLUME /var/lib/signal-cli
COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds
COPY --from=frontend /app/dist /var/lib/tranquil-pds/frontend
COPY migrations /app/migrations
WORKDIR /app
ENV SIGNAL_CLI_CONFIG=/var/lib/signal-cli
ENV SERVER_HOST=0.0.0.0
ENV SERVER_PORT=3000
EXPOSE 3000
+169
View File
@@ -0,0 +1,169 @@
tranquil-store: embedded storage engine for Tranquil PDS
RFC draft, 2026-03-22
By Lewis!
-- TLDR --
Add an embedded storage engine as an alternative to postgres (and leapfrog SQLite-per-actor)
that treats Tranquil's 3 types of storage workloads as 3 separate problems:
- BlockStore: bitcask-esque append log for immutable CID-keyed blocks [4]
- MetaStore: Fjall LSM keyspaces for mutable metadata [5]
- EventLog: segmented append log for the firehose
Group commit across users, content dedup, sub-ms firehose delivery.
We will use deterministic simulation testing [18][19].
Postgres will of course stay as the existing alternative backend.
-- Intro --
The ref PDS hits structural limits around 300k accounts [2].
SQLite-per-actor means no cross-user write batching.
tranquil-store is an embedded rust library. It lives in-process, no external deps.
Postgres remains supported; we plan to enable a storage transition path
such that users can seamlessly snapshot-n-switch between the backends.
The BlockStore is a bitcask-style append log [4] with a Fjall key index [5] that
maps each CID to a (file, offset, length) tuple. We use key-value separation
as per WiscKey [6]. Because blocks are immutable and keyed by CID, the value log
never needs compaction. An LRU hot tier keeps frequently-accessed blocks in
mem, and hint files allow fast index reconstruction on restart [4].
The main throughput enabler is group commit [7]. The ref PDS fsyncs once per user
per mutation [1], but BlockStore batches all concurrent commits into a single
write-and-sync cycle.
Content dedup occurs naturally: identical MST subtrees across users share
one CID-keyed block instead of N copies [3].
MetaStore uses Fjall [5] keyspaces for all mutable data. We chose Fjall over
redb and LMDB because both of those are single-writer [8][9]. Each keyspace
compacts independently.
For cross-store atomicity we use an intent log. Each mutation writes a single
intent record containing the BlockStore refcount updates, MetaStore changes,
and the serialized EventLog payload, fsynced via the group commit. After fsync,
the changes are applied to MetaStore and the event is appended to the EventLog,
then the intent is marked committed. Recovery replays any incomplete intents,
re-applying both metadata changes and event appends. This gives us crash-atomic
mutations across all three stores without full MVCC, since mutations
are already serialized per-user [3].
EventLog stores the firehose as segmented append-only files. Live subscribers
receive events via tokio broadcast, and consumers that are catching up will
read from mmap'ed segments [10]. Each event receives a monotonic u64 sequence number.
Segment headers store the base sequence number; a per-segment index maps sequence
ranges to byte offsets. This decouples consumer cursors from physical layout,
allowing transparent addition of per-segment zstd compression per the loom-v2
spec [11] without invalidating checkpoints. Retention is just deleting old
segments! :P
GC uses refcounted key index entries. GC is epoch-gated by the group commit
cycle: a block is only eligible for collection if its refcount reached zero in
a prior completed commit cycle. This prevents races between concurrent dedup
(which skips the block write but increments the refcount in the same batch) and
collection. Blocks past the epoch gate are collected by rewriting any data
files that fall below a liveness threshold.
For serialization we use postcard on disk and rkyv [12] for in-mem caches
only. All data files carry a version tag.
Memory is divided into fixed slices from a configurable total budget: Fjall
block cache, BlockStore hot tier, and CID index each receive a configured
percentage. Actual usage per component is exposed as metrics. The EventLog's
mmap pages live in the OS page cache and are excluded from the budget.
Backup acquires the group commit lock, which quiesces all writes at the next
commit boundary. Under the lock, the system notes the EventLog position, the
BlockStore file list, and takes a Fjall snapshot, then releases the lock.
Sealed data files and segments are immutable and can be copied without
co-ordination after the snapshot. The quiesce window is bounded by one commit
cycle. Point-in-time recovery replays the EventLog against a prior snapshot.
For continuous replication, a background process tails the EventLog and copies
sealed files to remote storage.
-- Runtime --
The storage core runs on tokio. It is synchronous internally, accessed through
dedicated handler threads that communicate via async channels [13]. Requests
are dispatched by hashing the DID, which gives us per-user write serialization
without locks. Global operations use round-robin. All disk IO goes through
pread/pwrite directly [13].
We rejected io_uring for three reasons: it creates orphan kernel operations
when futures are cancelled [22], it is blocked by default in both Docker [16]
and Podman [17] seccomp profiles, and it accounts for 60% of Google's kernel
vulnerability rewards [15].
We also rejected thread-per-core runtimes (glommio, etc.) because they are
incompatible with the tokio ecosystem. DID-sharded handler threads give us
the same shared-nothing property without a runtime split.
-- Testing --
We use deterministic simulation testing, following FoundationDB [18] and
TigerBeetle's VOPR [19]. All IO sits behind a StorageIO trait, and tests use an
in-memory implementation that injects faults: partial writes, bit flips, sync
failures, and misdirected writes. A single seed controls the entire fault
schedule, so any failure reproduces exactly [20][21].
-- Why these choices --
Bitcask for blocks:
Key-val separation [6] using Bitcask [4] for immutable CID blocks:
O(1) writes, O(1) reads, zero write amplification, & no compaction!
Fjall for metadata:
Only pure-Rust embedded engine with concurrent writers [5].
Otherwise we'd write our own.
Segmented log for events:
Write once -> scan forward -> delete by age.
Quite straightforward!
Postcard on disk:
rkyv is apparently faster [12] but couples on-disk format to library version.
Tokio & handler threads:
spawn_blocking & pread matches io_uring without security/compat costs [13][14][15][16].
Deterministic simulation:
Catches bug classes conventional testing can't reach [18][19].
StorageIO trait is needed anyway; but being harness-first is a one-time cost [20][21].
-- References --
[1] Bluesky PDS SQLite migration. github.com/bluesky-social/atproto/pull/1705
[2] G. Orosz. Building Bluesky: a Distributed Social Network. Pragmatic Engineer, April 2024.
newsletter.pragmaticengineer.com/p/bluesky
K. Suder. Introduction to AT Protocol. August 2025. mackuba.eu/2025/08/20/introduction-to-atproto
Bluesky PDS "Going to Production" guide. atproto.com/guides/going-to-production
[3] AT Protocol repository spec. atproto.com/specs/repository
[4] Bitcask: A Log-Structured Hash Table for Fast KV Data. Riak, 2010. riak.com/assets/bitcask-intro.pdf
[5] Fjall: LSM-based embedded storage engine. github.com/fjall-rs/fjall
[6] Lu et al. WiscKey: Separating Keys from Values in SSD-Conscious Storage. USENIX FAST 2016.
usenix.org/conference/fast16/technical-sessions/presentation/lu
[7] Phil Eaton. A Write-Ahead Log Is Not a Universal Part of Durability. July 2024.
notes.eatonphil.com/2024-07-01-a-write-ahead-log-is-not-a-universal-part-of-durability.html
[8] redb design document. github.com/cberner/redb/blob/master/docs/design.md
[9] LMDB source repository. github.com/LMDB/lmdb
[10] Crotty et al. Are You Sure You Want to Use MMAP in Your DBMS? CIDR 2022.
cs.brown.edu/people/acrotty/pubs/p13-crotty.pdf
[11] ybzeek. RFC: com.atproto.sync.getZstdStream (zstd-compressed relay streams).
github.com/bluesky-social/atproto/discussions/4582
[12] rkyv: zero-copy deserialization framework for Rust. rkyv.org
[13] Tonbo. Exploring Better Async Rust Disk IO. tonbo.io/blog/exploring-better-async-rust-disk-io
[14] Iroh. Async Rust Challenges in Iroh. iroh.computer/blog/async-rust-challenges-in-iroh
[15] Google restricting io_uring. phoronix.com/news/Google-Restricting-IO_uring
[16] Docker 4.42.0 and io_uring. forums.docker.com/t/4-42-0-and-io-uring/148620
[17] Podman io_uring discussion. github.com/containers/podman/discussions/27772
[18] FoundationDB simulation testing. apple.github.io/foundationdb/testing.html
[19] TigerBeetle VOPR. tigerbeetle.com/blog/2023-07-06-simulation-testing-for-liveness
[20] DST in Rust (S2). s2.dev/blog/dst
[21] Phil Eaton. What's the big deal about Deterministic Simulation Testing? August 2024.
notes.eatonphil.com/2024-08-20-deterministic-simulation-testing.html
[22] Tonbo. Async Rust Is Not Safe with io_uring. tonbo.io/blog/async-rust-is-not-safe-with-io-uring
Thank you for reading! Let's do some great work together.
+3
View File
@@ -12,12 +12,15 @@ tranquil-db = { workspace = true }
tranquil-db-traits = { workspace = true }
tranquil-lexicon = { workspace = true, features = ["resolve"] }
tranquil-scopes = { workspace = true }
tranquil-signal = { workspace = true }
anyhow = { workspace = true }
axum = { workspace = true }
backon = { workspace = true }
base32 = { workspace = true }
base64 = { workspace = true }
image = { workspace = true }
qrcodegen = { workspace = true }
bcrypt = { workspace = true }
bs58 = { workspace = true }
bytes = { workspace = true }
+8 -7
View File
@@ -34,13 +34,13 @@ pub struct GetPreferencesOutput {
}
pub async fn get_preferences(State(state): State<AppState>, auth: Auth<Permissive>) -> Response {
let has_full_access = auth.permissions().has_full_access();
let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&auth.did).await {
let user_id: uuid::Uuid = match state.repos.user.get_id_by_did(&auth.did).await {
Ok(Some(id)) => id,
_ => {
return ApiError::InternalError(Some("User not found".into())).into_response();
}
};
let prefs = match state.infra_repo.get_account_preferences(user_id).await {
let prefs = match state.repos.infra.get_account_preferences(user_id).await {
Ok(rows) => rows,
Err(_) => {
return ApiError::InternalError(Some("Failed to fetch preferences".into()))
@@ -69,7 +69,7 @@ pub async fn get_preferences(State(state): State<AppState>, auth: Auth<Permissiv
if let Some(age) = personal_details_pref
.as_ref()
.and_then(|pref| pref.get("birthDate"))
.and_then(|v| v.as_str())
.and_then(Value::as_str)
.and_then(get_age_from_datestring)
{
let declared_age_pref = serde_json::json!({
@@ -93,7 +93,7 @@ pub async fn put_preferences(
Json(input): Json<PutPreferencesInput>,
) -> Response {
let has_full_access = auth.permissions().has_full_access();
let user_id: uuid::Uuid = match state.user_repo.get_id_by_did(&auth.did).await {
let user_id: uuid::Uuid = match state.repos.user.get_id_by_did(&auth.did).await {
Ok(Some(id)) => id,
_ => {
return ApiError::InternalError(Some("User not found".into())).into_response();
@@ -122,7 +122,7 @@ pub async fn put_preferences(
if pref_str.len() > MAX_PREFERENCE_SIZE {
return PrefValidation::TooLarge(pref_str.len());
}
let pref_type = match pref.get("$type").and_then(|t| t.as_str()) {
let pref_type = match pref.get("$type").and_then(Value::as_str) {
Some(t) => t,
None => return PrefValidation::MissingType,
};
@@ -179,7 +179,7 @@ pub async fn put_preferences(
.preferences
.into_iter()
.filter_map(|pref| {
let pref_type = pref.get("$type").and_then(|t| t.as_str())?;
let pref_type = pref.get("$type").and_then(Value::as_str)?;
if pref_type == DECLARED_AGE_PREF {
return None;
}
@@ -188,7 +188,8 @@ pub async fn put_preferences(
.collect();
if state
.infra_repo
.repos
.infra
.replace_namespace_preferences(user_id, APP_BSKY_NAMESPACE, prefs_to_save)
.await
.is_err()
@@ -1,8 +1,4 @@
use axum::{
Json,
extract::State,
response::{IntoResponse, Response},
};
use axum::{Json, extract::State};
use serde::Deserialize;
use tracing::warn;
use tranquil_pds::api::EmptyResponse;
@@ -20,10 +16,11 @@ pub async fn delete_account(
State(state): State<AppState>,
_auth: Auth<Admin>,
Json(input): Json<DeleteAccountInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<EmptyResponse>, ApiError> {
let did = &input.did;
let (user_id, handle) = state
.user_repo
.repos
.user
.get_id_and_handle_by_did(did)
.await
.log_db_err("in delete_account")?
@@ -31,7 +28,8 @@ pub async fn delete_account(
.map(|row| (row.id, row.handle))?;
state
.user_repo
.repos
.user
.admin_delete_account_complete(user_id, did)
.await
.log_db_err("deleting account")?;
@@ -52,5 +50,5 @@ pub async fn delete_account(
.cache
.delete(&tranquil_pds::cache_keys::handle_key(&handle))
.await;
Ok(EmptyResponse::ok().into_response())
Ok(Json(EmptyResponse {}))
}
+8 -11
View File
@@ -1,9 +1,4 @@
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use axum::{Json, extract::State};
use serde::{Deserialize, Serialize};
use tracing::warn;
use tranquil_pds::api::error::{ApiError, DbResultExt};
@@ -30,13 +25,14 @@ pub async fn send_email(
State(state): State<AppState>,
_auth: Auth<Admin>,
Json(input): Json<SendEmailInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<SendEmailOutput>, ApiError> {
let content = input.content.trim();
if content.is_empty() {
return Err(ApiError::InvalidRequest("content is required".into()));
}
let user = state
.user_repo
.repos
.user
.get_by_did(&input.recipient_did)
.await
.log_db_err("in send_email")?
@@ -50,7 +46,8 @@ pub async fn send_email(
.clone()
.unwrap_or_else(|| format!("Message from {}", hostname));
let result = state
.infra_repo
.repos
.infra
.enqueue_comms(
Some(user_id),
tranquil_db_traits::CommsChannel::Email,
@@ -68,11 +65,11 @@ pub async fn send_email(
handle,
input.recipient_did
);
Ok((StatusCode::OK, Json(SendEmailOutput { sent: true })).into_response())
Ok(Json(SendEmailOutput { sent: true }))
}
Err(e) => {
warn!("Failed to enqueue admin email: {:?}", e);
Ok((StatusCode::OK, Json(SendEmailOutput { sent: false })).into_response())
Ok(Json(SendEmailOutput { sent: false }))
}
}
}
+47 -57
View File
@@ -1,8 +1,7 @@
use crate::common;
use axum::{
Json,
extract::{Query, RawQuery, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -68,9 +67,10 @@ pub async fn get_account_info(
State(state): State<AppState>,
_auth: Auth<Admin>,
Query(params): Query<GetAccountInfoParams>,
) -> Result<Response, ApiError> {
) -> Result<Json<AccountInfo>, ApiError> {
let account = state
.infra_repo
.repos
.infra
.get_admin_account_info_by_did(&params.did)
.await
.log_db_err("in get_account_info")?
@@ -79,31 +79,28 @@ pub async fn get_account_info(
let invited_by = get_invited_by(&state, account.id).await;
let invites = get_invites_for_user(&state, account.id).await;
Ok((
StatusCode::OK,
Json(AccountInfo {
did: account.did,
handle: account.handle,
email: account.email,
indexed_at: account.created_at.to_rfc3339(),
invite_note: None,
invites_disabled: account.invites_disabled,
email_confirmed_at: if account.email_verified {
Some(account.created_at.to_rfc3339())
} else {
None
},
deactivated_at: account.deactivated_at.map(|dt| dt.to_rfc3339()),
invited_by,
invites,
}),
)
.into_response())
Ok(Json(AccountInfo {
did: account.did,
handle: account.handle,
email: account.email,
indexed_at: account.created_at.to_rfc3339(),
invite_note: None,
invites_disabled: account.invites_disabled,
email_confirmed_at: if account.email_verified {
Some(account.created_at.to_rfc3339())
} else {
None
},
deactivated_at: account.deactivated_at.map(|dt| dt.to_rfc3339()),
invited_by,
invites,
}))
}
async fn get_invited_by(state: &AppState, user_id: uuid::Uuid) -> Option<InviteCodeInfo> {
let code = state
.infra_repo
.repos
.infra
.get_invite_code_used_by_user(user_id)
.await
.ok()??;
@@ -116,7 +113,8 @@ async fn get_invites_for_user(
user_id: uuid::Uuid,
) -> Option<Vec<InviteCodeInfo>> {
let invite_codes = state
.infra_repo
.repos
.infra
.get_invites_created_by_user(user_id)
.await
.ok()?;
@@ -128,21 +126,16 @@ async fn get_invites_for_user(
let code_strings: Vec<String> = invite_codes.iter().map(|ic| ic.code.clone()).collect();
let uses = state
.infra_repo
.repos
.infra
.get_invite_code_uses_batch(&code_strings)
.await
.ok()?;
let uses_by_code: HashMap<String, Vec<InviteCodeUseInfo>> =
uses.into_iter().fold(HashMap::new(), |mut acc, u| {
acc.entry(u.code.clone())
.or_default()
.push(InviteCodeUseInfo {
used_by: u.used_by_did,
used_at: u.used_at.to_rfc3339(),
});
acc
});
let uses_by_code = common::group_invite_uses_by_code(uses, |u| InviteCodeUseInfo {
used_by: u.used_by_did,
used_at: u.used_at.to_rfc3339(),
});
let invites: Vec<InviteCodeInfo> = invite_codes
.into_iter()
@@ -165,10 +158,11 @@ async fn get_invites_for_user(
}
async fn get_invite_code_info(state: &AppState, code: &str) -> Option<InviteCodeInfo> {
let info = state.infra_repo.get_invite_code_info(code).await.ok()??;
let info = state.repos.infra.get_invite_code_info(code).await.ok()??;
let uses = state
.infra_repo
.repos
.infra
.get_invite_code_uses(code)
.await
.ok()
@@ -195,7 +189,7 @@ pub async fn get_account_infos(
State(state): State<AppState>,
_auth: Auth<Admin>,
RawQuery(raw_query): RawQuery,
) -> Result<Response, ApiError> {
) -> Result<Json<GetAccountInfosOutput>, ApiError> {
let dids: Vec<String> =
tranquil_pds::util::parse_repeated_query_param(raw_query.as_deref(), "dids")
.into_iter()
@@ -208,7 +202,8 @@ pub async fn get_account_infos(
let dids_typed: Vec<Did> = dids.iter().filter_map(|d| d.parse().ok()).collect();
let accounts = state
.infra_repo
.repos
.infra
.get_admin_account_infos_by_dids(&dids_typed)
.await
.log_db_err("fetching account infos")?;
@@ -216,7 +211,8 @@ pub async fn get_account_infos(
let user_ids: Vec<uuid::Uuid> = accounts.iter().map(|u| u.id).collect();
let all_invite_codes = state
.infra_repo
.repos
.infra
.get_invite_codes_by_users(&user_ids)
.await
.unwrap_or_default();
@@ -228,7 +224,8 @@ pub async fn get_account_infos(
let all_invite_uses = if !all_codes.is_empty() {
state
.infra_repo
.repos
.infra
.get_invite_code_uses_batch(&all_codes)
.await
.unwrap_or_default()
@@ -237,25 +234,18 @@ pub async fn get_account_infos(
};
let invited_by_map: HashMap<uuid::Uuid, String> = state
.infra_repo
.repos
.infra
.get_invite_code_uses_by_users(&user_ids)
.await
.unwrap_or_default()
.into_iter()
.collect();
let uses_by_code: HashMap<String, Vec<InviteCodeUseInfo>> =
all_invite_uses
.into_iter()
.fold(HashMap::new(), |mut acc, u| {
acc.entry(u.code.clone())
.or_default()
.push(InviteCodeUseInfo {
used_by: u.used_by_did,
used_at: u.used_at.to_rfc3339(),
});
acc
});
let uses_by_code = common::group_invite_uses_by_code(all_invite_uses, |u| InviteCodeUseInfo {
used_by: u.used_by_did,
used_at: u.used_at.to_rfc3339(),
});
let (codes_by_user, code_info_map): (
HashMap<uuid::Uuid, Vec<InviteCodeInfo>>,
@@ -304,5 +294,5 @@ pub async fn get_account_infos(
})
.collect();
Ok((StatusCode::OK, Json(GetAccountInfosOutput { infos })).into_response())
Ok(Json(GetAccountInfosOutput { infos }))
}
@@ -1,8 +1,6 @@
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use tranquil_pds::api::error::{ApiError, DbResultExt};
@@ -51,17 +49,16 @@ pub async fn search_accounts(
State(state): State<AppState>,
_auth: Auth<Admin>,
Query(params): Query<SearchAccountsParams>,
) -> Result<Response, ApiError> {
) -> Result<Json<SearchAccountsOutput>, ApiError> {
let limit = params.limit.clamp(1, 100);
let email_filter = params.email.as_deref().map(|e| format!("%{}%", e));
let handle_filter = params.handle.as_deref().map(|h| format!("%{}%", h));
let cursor_did: Option<Did> = params.cursor.as_ref().and_then(|c| c.parse().ok());
let rows = state
.user_repo
.repos
.user
.search_accounts(
cursor_did.as_ref(),
email_filter.as_deref(),
handle_filter.as_deref(),
params.email.as_deref(),
params.handle.as_deref(),
limit + 1,
)
.await
@@ -91,12 +88,8 @@ pub async fn search_accounts(
} else {
None
};
Ok((
StatusCode::OK,
Json(SearchAccountsOutput {
cursor: next_cursor,
accounts,
}),
)
.into_response())
Ok(Json(SearchAccountsOutput {
cursor: next_cursor,
accounts,
}))
}
+19 -21
View File
@@ -1,8 +1,4 @@
use axum::{
Json,
extract::State,
response::{IntoResponse, Response},
};
use axum::{Json, extract::State};
use serde::Deserialize;
use tracing::{error, warn};
use tranquil_pds::api::EmptyResponse;
@@ -21,7 +17,7 @@ pub async fn update_account_email(
State(state): State<AppState>,
_auth: Auth<Admin>,
Json(input): Json<UpdateAccountEmailInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<EmptyResponse>, ApiError> {
let account = input.account.trim();
let email = input.email.trim();
if account.is_empty() || email.is_empty() {
@@ -34,12 +30,13 @@ pub async fn update_account_email(
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
match state
.user_repo
.repos
.user
.admin_update_email(&account_did, email)
.await
{
Ok(0) => Err(ApiError::AccountNotFound),
Ok(_) => Ok(EmptyResponse::ok().into_response()),
Ok(_) => Ok(Json(EmptyResponse {})),
Err(e) => {
error!("DB error updating email: {:?}", e);
Err(ApiError::InternalError(None))
@@ -57,7 +54,7 @@ pub async fn update_account_handle(
State(state): State<AppState>,
_auth: Auth<Admin>,
Json(input): Json<UpdateAccountHandleInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<EmptyResponse>, ApiError> {
let did = &input.did;
let input_handle = input.handle.trim();
if input_handle.is_empty() {
@@ -75,9 +72,10 @@ pub async fn update_account_handle(
} else {
input_handle.to_string()
};
let old_handle = state.user_repo.get_handle_by_did(did).await.ok().flatten();
let old_handle = state.repos.user.get_handle_by_did(did).await.ok().flatten();
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(did)
.await
.ok()
@@ -85,14 +83,16 @@ pub async fn update_account_handle(
.ok_or(ApiError::AccountNotFound)?;
let handle_for_check: Handle = handle.parse().map_err(|_| ApiError::InvalidHandle(None))?;
if let Ok(true) = state
.user_repo
.repos
.user
.check_handle_exists(&handle_for_check, user_id)
.await
{
return Err(ApiError::HandleTaken);
}
match state
.user_repo
.repos
.user
.admin_update_handle(did, &handle_for_check)
.await
{
@@ -125,7 +125,7 @@ pub async fn update_account_handle(
{
warn!("Failed to update PLC handle for admin handle update: {}", e);
}
Ok(EmptyResponse::ok().into_response())
Ok(Json(EmptyResponse {}))
}
Err(e) => {
error!("DB error updating handle: {:?}", e);
@@ -144,24 +144,22 @@ pub async fn update_account_password(
State(state): State<AppState>,
_auth: Auth<Admin>,
Json(input): Json<UpdateAccountPasswordInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<EmptyResponse>, ApiError> {
let did = &input.did;
let password = input.password.trim();
if password.is_empty() {
return Err(ApiError::InvalidRequest("password is required".into()));
}
let password_hash = bcrypt::hash(password, bcrypt::DEFAULT_COST).map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
})?;
let password_hash = crate::common::hash_or_internal_error(password)?;
match state
.user_repo
.repos
.user
.admin_update_password(did, &password_hash)
.await
{
Ok(0) => Err(ApiError::AccountNotFound),
Ok(_) => Ok(EmptyResponse::ok().into_response()),
Ok(_) => Ok(Json(EmptyResponse {})),
Err(e) => {
error!("DB error updating password: {:?}", e);
Err(ApiError::InternalError(None))
+38 -22
View File
@@ -8,7 +8,7 @@ use tranquil_types::CidLink;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerConfigResponse {
pub struct ServerConfigOutput {
pub server_name: String,
pub primary_color: Option<String>,
pub primary_color_dark: Option<String>,
@@ -29,7 +29,7 @@ pub struct UpdateServerConfigRequest {
}
#[derive(Serialize)]
pub struct UpdateServerConfigResponse {
pub struct UpdateServerConfigOutput {
pub success: bool,
}
@@ -42,7 +42,7 @@ fn is_valid_hex_color(s: &str) -> bool {
pub async fn get_server_config(
State(state): State<AppState>,
) -> Result<Json<ServerConfigResponse>, ApiError> {
) -> Result<Json<ServerConfigOutput>, ApiError> {
let keys = &[
"server_name",
"primary_color",
@@ -53,14 +53,15 @@ pub async fn get_server_config(
];
let rows = state
.infra_repo
.repos
.infra
.get_server_configs(keys)
.await
.log_db_err("fetching server config")?;
let config_map: std::collections::HashMap<String, String> = rows.into_iter().collect();
Ok(Json(ServerConfigResponse {
Ok(Json(ServerConfigOutput {
server_name: config_map
.get("server_name")
.cloned()
@@ -77,7 +78,7 @@ pub async fn update_server_config(
State(state): State<AppState>,
_auth: Auth<Admin>,
Json(req): Json<UpdateServerConfigRequest>,
) -> Result<Json<UpdateServerConfigResponse>, ApiError> {
) -> Result<Json<UpdateServerConfigOutput>, ApiError> {
if let Some(server_name) = req.server_name {
let trimmed = server_name.trim();
if trimmed.is_empty() || trimmed.len() > 100 {
@@ -86,7 +87,8 @@ pub async fn update_server_config(
));
}
state
.infra_repo
.repos
.infra
.upsert_server_config("server_name", trimmed)
.await
.log_db_err("upserting server_name")?;
@@ -95,13 +97,15 @@ pub async fn update_server_config(
if let Some(ref color) = req.primary_color {
if color.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("primary_color")
.await
.log_db_err("deleting primary_color")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.repos
.infra
.upsert_server_config("primary_color", color)
.await
.log_db_err("upserting primary_color")?;
@@ -115,13 +119,15 @@ pub async fn update_server_config(
if let Some(ref color) = req.primary_color_dark {
if color.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("primary_color_dark")
.await
.log_db_err("deleting primary_color_dark")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.repos
.infra
.upsert_server_config("primary_color_dark", color)
.await
.log_db_err("upserting primary_color_dark")?;
@@ -135,13 +141,15 @@ pub async fn update_server_config(
if let Some(ref color) = req.secondary_color {
if color.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("secondary_color")
.await
.log_db_err("deleting secondary_color")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.repos
.infra
.upsert_server_config("secondary_color", color)
.await
.log_db_err("upserting secondary_color")?;
@@ -155,13 +163,15 @@ pub async fn update_server_config(
if let Some(ref color) = req.secondary_color_dark {
if color.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("secondary_color_dark")
.await
.log_db_err("deleting secondary_color_dark")?;
} else if is_valid_hex_color(color) {
state
.infra_repo
.repos
.infra
.upsert_server_config("secondary_color_dark", color)
.await
.log_db_err("upserting secondary_color_dark")?;
@@ -174,7 +184,8 @@ pub async fn update_server_config(
if let Some(ref logo_cid) = req.logo_cid {
let old_logo_cid = state
.infra_repo
.repos
.infra
.get_server_config("logo_cid")
.await
.ok()
@@ -189,13 +200,16 @@ pub async fn update_server_config(
if let Some(old_cid_str) = should_delete_old {
match CidLink::new(old_cid_str) {
Ok(old_cid) => {
if let Ok(Some(storage_key)) =
state.infra_repo.get_blob_storage_key_by_cid(&old_cid).await
if let Ok(Some(storage_key)) = state
.repos
.infra
.get_blob_storage_key_by_cid(&old_cid)
.await
{
if let Err(e) = state.blob_store.delete(&storage_key).await {
error!("Failed to delete old logo blob from storage: {:?}", e);
}
if let Err(e) = state.infra_repo.delete_blob_by_cid(&old_cid).await {
if let Err(e) = state.repos.infra.delete_blob_by_cid(&old_cid).await {
error!("Failed to delete old logo blob record: {:?}", e);
}
}
@@ -211,18 +225,20 @@ pub async fn update_server_config(
if logo_cid.is_empty() {
state
.infra_repo
.repos
.infra
.delete_server_config("logo_cid")
.await
.log_db_err("deleting logo_cid")?;
} else {
state
.infra_repo
.repos
.infra
.upsert_server_config("logo_cid", logo_cid)
.await
.log_db_err("upserting logo_cid")?;
}
}
Ok(Json(UpdateServerConfigResponse { success: true }))
Ok(Json(UpdateServerConfigOutput { success: true }))
}
+36 -40
View File
@@ -1,8 +1,7 @@
use crate::common;
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use tracing::error;
@@ -23,9 +22,9 @@ pub async fn disable_invite_codes(
State(state): State<AppState>,
_auth: Auth<Admin>,
Json(input): Json<DisableInviteCodesInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<EmptyResponse>, ApiError> {
if let Some(codes) = &input.codes
&& let Err(e) = state.infra_repo.disable_invite_codes_by_code(codes).await
&& let Err(e) = state.repos.infra.disable_invite_codes_by_code(codes).await
{
error!("DB error disabling invite codes: {:?}", e);
}
@@ -33,14 +32,15 @@ pub async fn disable_invite_codes(
let accounts_typed: Vec<tranquil_types::Did> =
accounts.iter().filter_map(|a| a.parse().ok()).collect();
if let Err(e) = state
.infra_repo
.repos
.infra
.disable_invite_codes_by_account(&accounts_typed)
.await
{
error!("DB error disabling invite codes by account: {:?}", e);
}
}
Ok(EmptyResponse::ok().into_response())
Ok(Json(EmptyResponse {}))
}
#[derive(Deserialize)]
@@ -80,7 +80,7 @@ pub async fn get_invite_codes(
State(state): State<AppState>,
_auth: Auth<Admin>,
Query(params): Query<GetInviteCodesParams>,
) -> Result<Response, ApiError> {
) -> Result<Json<GetInviteCodesOutput>, ApiError> {
let limit = params.limit.unwrap_or(100).clamp(1, 500);
let sort_order = match params.sort.as_deref() {
Some("usage") => InviteCodeSortOrder::Usage,
@@ -88,7 +88,8 @@ pub async fn get_invite_codes(
};
let codes_rows = state
.infra_repo
.repos
.infra
.list_invite_codes(params.cursor.as_deref(), limit, sort_order)
.await
.log_db_err("fetching invite codes")?;
@@ -97,33 +98,30 @@ pub async fn get_invite_codes(
let code_strings: Vec<String> = codes_rows.iter().map(|r| r.code.clone()).collect();
let creator_dids: std::collections::HashMap<uuid::Uuid, tranquil_types::Did> = state
.infra_repo
.repos
.infra
.get_user_dids_by_ids(&user_ids)
.await
.unwrap_or_default()
.into_iter()
.collect();
let uses_by_code: std::collections::HashMap<String, Vec<InviteCodeUseInfo>> =
if code_strings.is_empty() {
std::collections::HashMap::new()
} else {
let uses_by_code = if code_strings.is_empty() {
std::collections::HashMap::new()
} else {
common::group_invite_uses_by_code(
state
.infra_repo
.repos
.infra
.get_invite_code_uses_batch(&code_strings)
.await
.unwrap_or_default()
.into_iter()
.fold(std::collections::HashMap::new(), |mut acc, u| {
acc.entry(u.code.clone())
.or_default()
.push(InviteCodeUseInfo {
used_by: u.used_by_did.to_string(),
used_at: u.used_at.to_rfc3339(),
});
acc
})
};
.unwrap_or_default(),
|u| InviteCodeUseInfo {
used_by: u.used_by_did.to_string(),
used_at: u.used_at.to_rfc3339(),
},
)
};
let codes: Vec<InviteCodeInfo> = codes_rows
.iter()
@@ -149,14 +147,10 @@ pub async fn get_invite_codes(
} else {
None
};
Ok((
StatusCode::OK,
Json(GetInviteCodesOutput {
cursor: next_cursor,
codes,
}),
)
.into_response())
Ok(Json(GetInviteCodesOutput {
cursor: next_cursor,
codes,
}))
}
#[derive(Deserialize)]
@@ -168,7 +162,7 @@ pub async fn disable_account_invites(
State(state): State<AppState>,
_auth: Auth<Admin>,
Json(input): Json<DisableAccountInvitesInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<EmptyResponse>, ApiError> {
let account = input.account.trim();
if account.is_empty() {
return Err(ApiError::InvalidRequest("account is required".into()));
@@ -178,11 +172,12 @@ pub async fn disable_account_invites(
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
match state
.user_repo
.repos
.user
.set_invites_disabled(&account_did, true)
.await
{
Ok(true) => Ok(EmptyResponse::ok().into_response()),
Ok(true) => Ok(Json(EmptyResponse {})),
Ok(false) => Err(ApiError::AccountNotFound),
Err(e) => {
error!("DB error disabling account invites: {:?}", e);
@@ -200,7 +195,7 @@ pub async fn enable_account_invites(
State(state): State<AppState>,
_auth: Auth<Admin>,
Json(input): Json<EnableAccountInvitesInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<EmptyResponse>, ApiError> {
let account = input.account.trim();
if account.is_empty() {
return Err(ApiError::InvalidRequest("account is required".into()));
@@ -210,11 +205,12 @@ pub async fn enable_account_invites(
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
match state
.user_repo
.repos
.user
.set_invites_disabled(&account_did, false)
.await
{
Ok(true) => Ok(EmptyResponse::ok().into_response()),
Ok(true) => Ok(Json(EmptyResponse {})),
Ok(false) => Err(ApiError::AccountNotFound),
Err(e) => {
error!("DB error enabling account invites: {:?}", e);
+2
View File
@@ -2,6 +2,7 @@ pub mod account;
pub mod config;
pub mod invite;
pub mod server_stats;
pub mod signal;
pub mod status;
pub use account::{
@@ -13,4 +14,5 @@ pub use invite::{
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
};
pub use server_stats::get_server_stats;
pub use signal::{get_signal_status, link_signal_device, unlink_signal_device};
pub use status::{get_subject_status, update_subject_status};
+9 -14
View File
@@ -1,8 +1,4 @@
use axum::{
Json,
extract::State,
response::{IntoResponse, Response},
};
use axum::{Json, extract::State};
use serde::Serialize;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Admin, Auth};
@@ -10,7 +6,7 @@ use tranquil_pds::state::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerStatsResponse {
pub struct ServerStatsOutput {
pub user_count: i64,
pub repo_count: i64,
pub record_count: i64,
@@ -20,17 +16,16 @@ pub struct ServerStatsResponse {
pub async fn get_server_stats(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Response, ApiError> {
let user_count = state.user_repo.count_users().await.unwrap_or(0);
let repo_count = state.repo_repo.count_repos().await.unwrap_or(0);
let record_count = state.repo_repo.count_all_records().await.unwrap_or(0);
let blob_storage_bytes = state.blob_repo.sum_blob_storage().await.unwrap_or(0);
) -> Result<Json<ServerStatsOutput>, ApiError> {
let user_count = state.repos.user.count_users().await.unwrap_or(0);
let repo_count = state.repos.repo.count_repos().await.unwrap_or(0);
let record_count = state.repos.repo.count_all_records().await.unwrap_or(0);
let blob_storage_bytes = state.repos.blob.sum_blob_storage().await.unwrap_or(0);
Ok(Json(ServerStatsResponse {
Ok(Json(ServerStatsOutput {
user_count,
repo_count,
record_count,
blob_storage_bytes,
})
.into_response())
}))
}
+171
View File
@@ -0,0 +1,171 @@
use axum::{Json, extract::State};
use base64::{Engine, engine::general_purpose::STANDARD};
use image::{ImageBuffer, Luma};
use serde::Serialize;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Admin, Auth};
use tranquil_pds::state::AppState;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalStatusOutput {
pub enabled: bool,
pub linked: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalLinkOutput {
pub qr_base64: String,
}
pub async fn get_signal_status(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<SignalStatusOutput>, ApiError> {
let enabled = tranquil_config::get().signal.enabled;
let linked = match &state.signal_sender {
Some(slot) => slot.is_linked().await,
None => false,
};
Ok(Json(SignalStatusOutput { enabled, linked }))
}
pub async fn link_signal_device(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<SignalLinkOutput>, ApiError> {
let slot = state
.signal_sender
.as_ref()
.ok_or_else(|| ApiError::InvalidRequest("Signal is not enabled".into()))?;
if slot.is_linked().await {
return Err(ApiError::InvalidRequest(
"Signal device already linked".into(),
));
}
let (generation, link_cancel) = slot.begin_link().await;
let device_name = tranquil_signal::DeviceName::new("tranquil-pds".to_string())
.map_err(|e| ApiError::InternalError(Some(format!("invalid device name: {e}"))))?;
let signal_store = state
.signal_store_provider
.as_ref()
.ok_or_else(|| ApiError::InternalError(Some("Signal store not configured".into())))?;
let link_result = signal_store
.link_signal_device(
device_name,
state.shutdown.clone(),
link_cancel,
slot.linking_flag(),
)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Signal linking failed: {e}"))))?;
let qr_base64 = url_to_qr_png_base64(link_result.url.as_str())
.map_err(|e| ApiError::InternalError(Some(format!("QR generation failed: {e}"))))?;
let slot_for_task = slot.clone();
let shutdown = state.shutdown.clone();
tokio::spawn(async move {
let result = tokio::select! {
biased;
_ = shutdown.cancelled() => {
tracing::info!("Signal linking aborted due to server shutdown");
return;
}
r = link_result.completion => r,
};
match result {
Ok(Ok(client)) => {
if slot_for_task.complete_link(generation, client).await {
tracing::info!("Signal device linked successfully");
} else {
tracing::warn!(
"Signal link completed but generation mismatch or already linked; discarding"
);
}
}
Ok(Err(e)) => {
tracing::error!(error = %e, "Signal device linking failed");
}
Err(_) => {
tracing::error!("Signal linking task dropped without completing");
}
}
});
Ok(Json(SignalLinkOutput { qr_base64 }))
}
pub async fn unlink_signal_device(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<serde_json::Value>, ApiError> {
let slot = state
.signal_sender
.as_ref()
.ok_or_else(|| ApiError::InvalidRequest("Signal is not enabled".into()))?;
let signal_store = state
.signal_store_provider
.as_ref()
.ok_or_else(|| ApiError::InternalError(Some("Signal store not configured".into())))?;
signal_store
.clear_signal_data()
.await
.map_err(|e| ApiError::InternalError(Some(format!("Failed to clear signal data: {e}"))))?;
slot.unlink().await;
Ok(Json(serde_json::json!({})))
}
const QR_MODULE_SCALE: u32 = 8;
const QR_QUIET_ZONE_MODULES: u32 = 4;
fn url_to_qr_png_base64(url: &str) -> Result<String, String> {
let qr = qrcodegen::QrCode::encode_text(url, qrcodegen::QrCodeEcc::Medium)
.map_err(|e| format!("QR encode failed: {e:?}"))?;
let size = u32::try_from(qr.size()).map_err(|_| "QR size is negative".to_string())?;
let img_size = size
.checked_add(
QR_QUIET_ZONE_MODULES
.checked_mul(2)
.ok_or("border overflow")?,
)
.ok_or("image size overflow")?
.checked_mul(QR_MODULE_SCALE)
.ok_or("scaled size overflow")?;
let img: ImageBuffer<Luma<u8>, Vec<u8>> = ImageBuffer::from_fn(img_size, img_size, |x, y| {
let module_x = x / QR_MODULE_SCALE;
let module_y = y / QR_MODULE_SCALE;
match (
module_x.checked_sub(QR_QUIET_ZONE_MODULES),
module_y.checked_sub(QR_QUIET_ZONE_MODULES),
) {
(Some(mx), Some(my)) if mx < size && my < size => {
if qr.get_module(mx as i32, my as i32) {
Luma([0u8])
} else {
Luma([255u8])
}
}
_ => Luma([255u8]),
}
});
let mut png_bytes = Vec::new();
let mut cursor = std::io::Cursor::new(&mut png_bytes);
img.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|e| format!("PNG encode failed: {e}"))?;
Ok(STANDARD.encode(&png_bytes))
}
+83 -96
View File
@@ -1,11 +1,9 @@
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use serde_json::{Value, json};
use tracing::{error, warn};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Admin, Auth};
@@ -37,7 +35,7 @@ pub async fn get_subject_status(
State(state): State<AppState>,
_auth: Auth<Admin>,
Query(params): Query<GetSubjectStatusParams>,
) -> Result<Response, ApiError> {
) -> Result<Json<SubjectStatus>, ApiError> {
if params.did.is_none() && params.uri.is_none() && params.blob.is_none() {
return Err(ApiError::InvalidRequest(
"Must provide did, uri, or blob".into(),
@@ -47,7 +45,7 @@ pub async fn get_subject_status(
let did: Did = did_str
.parse()
.map_err(|_| ApiError::InvalidDid("Invalid DID format".into()))?;
match state.user_repo.get_status_by_did(&did).await {
match state.repos.user.get_status_by_did(&did).await {
Ok(Some(status)) => {
let deactivated = status.deactivated_at.map(|_| StatusAttr {
applied: true,
@@ -57,18 +55,14 @@ pub async fn get_subject_status(
applied: true,
r#ref: Some(r.clone()),
});
return Ok((
StatusCode::OK,
Json(SubjectStatus {
subject: json!({
"$type": "com.atproto.admin.defs#repoRef",
"did": did_str
}),
takedown,
deactivated,
return Ok(Json(SubjectStatus {
subject: json!({
"$type": "com.atproto.admin.defs#repoRef",
"did": did_str
}),
)
.into_response());
takedown,
deactivated,
}));
}
Ok(None) => {
return Err(ApiError::SubjectNotFound);
@@ -83,25 +77,21 @@ pub async fn get_subject_status(
let cid: CidLink = uri_str
.parse()
.map_err(|_| ApiError::InvalidRequest("Invalid CID format".into()))?;
match state.repo_repo.get_record_by_cid(&cid).await {
match state.repos.repo.get_record_by_cid(&cid).await {
Ok(Some(record)) => {
let takedown = record.takedown_ref.as_ref().map(|r| StatusAttr {
applied: true,
r#ref: Some(r.clone()),
});
return Ok((
StatusCode::OK,
Json(SubjectStatus {
subject: json!({
"$type": "com.atproto.repo.strongRef",
"uri": uri_str,
"cid": uri_str
}),
takedown,
deactivated: None,
return Ok(Json(SubjectStatus {
subject: json!({
"$type": "com.atproto.repo.strongRef",
"uri": uri_str,
"cid": uri_str
}),
)
.into_response());
takedown,
deactivated: None,
}));
}
Ok(None) => {
return Err(ApiError::RecordNotFound);
@@ -119,25 +109,21 @@ pub async fn get_subject_status(
let did = params.did.as_ref().ok_or_else(|| {
ApiError::InvalidRequest("Must provide a did to request blob state".into())
})?;
match state.blob_repo.get_blob_with_takedown(&blob_cid).await {
match state.repos.blob.get_blob_with_takedown(&blob_cid).await {
Ok(Some(blob)) => {
let takedown = blob.takedown_ref.as_ref().map(|r| StatusAttr {
applied: true,
r#ref: Some(r.clone()),
});
return Ok((
StatusCode::OK,
Json(SubjectStatus {
subject: json!({
"$type": "com.atproto.admin.defs#repoBlobRef",
"did": did,
"cid": blob.cid
}),
takedown,
deactivated: None,
return Ok(Json(SubjectStatus {
subject: json!({
"$type": "com.atproto.admin.defs#repoBlobRef",
"did": did,
"cid": blob.cid
}),
)
.into_response());
takedown,
deactivated: None,
}));
}
Ok(None) => {
return Err(ApiError::BlobNotFound(None));
@@ -169,11 +155,11 @@ pub async fn update_subject_status(
State(state): State<AppState>,
_auth: Auth<Admin>,
Json(input): Json<UpdateSubjectStatusInput>,
) -> Result<Response, ApiError> {
let subject_type = input.subject.get("$type").and_then(|t| t.as_str());
) -> Result<Json<serde_json::Value>, ApiError> {
let subject_type = input.subject.get("$type").and_then(Value::as_str);
match subject_type {
Some("com.atproto.admin.defs#repoRef") => {
let did_str = input.subject.get("did").and_then(|d| d.as_str());
let did_str = input.subject.get("did").and_then(Value::as_str);
if let Some(did_str) = did_str {
let did: Did = match did_str.parse() {
Ok(d) => d,
@@ -186,7 +172,8 @@ pub async fn update_subject_status(
None
};
state
.user_repo
.repos
.user
.set_user_takedown(&did, takedown_ref)
.await
.map_err(|e| {
@@ -196,9 +183,9 @@ pub async fn update_subject_status(
}
if let Some(deactivated) = &input.deactivated {
let result = if deactivated.applied {
state.user_repo.deactivate_account(&did, None).await
state.repos.user.deactivate_account(&did, None).await
} else {
state.user_repo.activate_account(&did).await
state.repos.user.activate_account(&did).await
};
result.map_err(|e| {
error!(
@@ -208,11 +195,22 @@ pub async fn update_subject_status(
ApiError::InternalError(Some("Failed to update deactivation status".into()))
})?;
}
let takedown_update = input.takedown.as_ref().map(|t| t.applied);
let takedown_ref = input.takedown.as_ref().and_then(|t| t.r#ref.as_deref());
let deactivated_update = input.deactivated.as_ref().map(|d| d.applied);
if (takedown_update.is_some() || deactivated_update.is_some())
&& let Err(e) = state
.repos
.repo
.update_repo_status(&did, takedown_update, takedown_ref, deactivated_update)
.await
{
warn!("failed to sync status to repo backend: {e:?}");
}
if let Some(takedown) = &input.takedown {
let status = if takedown.applied {
tranquil_db_traits::AccountStatus::Takendown
} else {
tranquil_db_traits::AccountStatus::Active
let status = match takedown.applied {
true => tranquil_db_traits::AccountStatus::Takendown,
false => tranquil_db_traits::AccountStatus::Active,
};
if let Err(e) =
tranquil_pds::repo_ops::sequence_account_event(&state, &did, status).await
@@ -221,10 +219,9 @@ pub async fn update_subject_status(
}
}
if let Some(deactivated) = &input.deactivated {
let status = if deactivated.applied {
tranquil_db_traits::AccountStatus::Deactivated
} else {
tranquil_db_traits::AccountStatus::Active
let status = match deactivated.applied {
true => tranquil_db_traits::AccountStatus::Deactivated,
false => tranquil_db_traits::AccountStatus::Active,
};
if let Err(e) =
tranquil_pds::repo_ops::sequence_account_event(&state, &did, status).await
@@ -232,30 +229,26 @@ pub async fn update_subject_status(
warn!("Failed to sequence account event for deactivation: {}", e);
}
}
if let Ok(Some(handle)) = state.user_repo.get_handle_by_did(&did).await {
if let Ok(Some(handle)) = state.repos.user.get_handle_by_did(&did).await {
let _ = state
.cache
.delete(&tranquil_pds::cache_keys::handle_key(&handle))
.await;
}
return Ok((
StatusCode::OK,
Json(json!({
"subject": input.subject,
"takedown": input.takedown.as_ref().map(|t| json!({
"applied": t.applied,
"ref": t.r#ref
})),
"deactivated": input.deactivated.as_ref().map(|d| json!({
"applied": d.applied
}))
return Ok(Json(json!({
"subject": input.subject,
"takedown": input.takedown.as_ref().map(|t| json!({
"applied": t.applied,
"ref": t.r#ref
})),
)
.into_response());
"deactivated": input.deactivated.as_ref().map(|d| json!({
"applied": d.applied
}))
})));
}
}
Some("com.atproto.repo.strongRef") => {
let uri_str = input.subject.get("uri").and_then(|u| u.as_str());
let uri_str = input.subject.get("uri").and_then(Value::as_str);
if let Some(uri_str) = uri_str {
let cid: CidLink = uri_str
.parse()
@@ -267,7 +260,8 @@ pub async fn update_subject_status(
None
};
state
.repo_repo
.repos
.repo
.set_record_takedown(&cid, takedown_ref)
.await
.map_err(|e| {
@@ -278,21 +272,17 @@ pub async fn update_subject_status(
ApiError::InternalError(Some("Failed to update takedown status".into()))
})?;
}
return Ok((
StatusCode::OK,
Json(json!({
"subject": input.subject,
"takedown": input.takedown.as_ref().map(|t| json!({
"applied": t.applied,
"ref": t.r#ref
}))
})),
)
.into_response());
return Ok(Json(json!({
"subject": input.subject,
"takedown": input.takedown.as_ref().map(|t| json!({
"applied": t.applied,
"ref": t.r#ref
}))
})));
}
}
Some("com.atproto.admin.defs#repoBlobRef") => {
let cid_str = input.subject.get("cid").and_then(|c| c.as_str());
let cid_str = input.subject.get("cid").and_then(Value::as_str);
if let Some(cid_str) = cid_str {
let cid: CidLink = cid_str
.parse()
@@ -304,7 +294,8 @@ pub async fn update_subject_status(
None
};
state
.blob_repo
.repos
.blob
.update_blob_takedown(&cid, takedown_ref)
.await
.map_err(|e| {
@@ -315,17 +306,13 @@ pub async fn update_subject_status(
ApiError::InternalError(Some("Failed to update takedown status".into()))
})?;
}
return Ok((
StatusCode::OK,
Json(json!({
"subject": input.subject,
"takedown": input.takedown.as_ref().map(|t| json!({
"applied": t.applied,
"ref": t.r#ref
}))
})),
)
.into_response());
return Ok(Json(json!({
"subject": input.subject,
"takedown": input.takedown.as_ref().map(|t| json!({
"applied": t.applied,
"ref": t.r#ref
}))
})));
}
}
_ => {}
+46 -23
View File
@@ -1,37 +1,60 @@
use axum::{
Json,
extract::State,
http::{HeaderMap, Method, StatusCode},
response::{IntoResponse, Response},
http::{HeaderMap, Method},
};
use serde_json::json;
use serde::Serialize;
use tranquil_pds::auth::{
AccountRequirement, extract_auth_token_from_header, validate_token_with_dpop,
};
use tranquil_pds::state::AppState;
pub async fn get_state(State(state): State<AppState>, headers: HeaderMap) -> Response {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgeAssuranceState {
pub status: &'static str,
pub access: &'static str,
pub last_initiated_at: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgeAssuranceMetadata {
pub account_created_at: Option<String>,
}
#[derive(Serialize)]
pub struct GetAgeAssuranceOutput {
pub state: AgeAssuranceState,
pub metadata: AgeAssuranceMetadata,
}
#[derive(Serialize)]
pub struct AgeAssuranceStatusOutput {
pub status: &'static str,
}
pub async fn get_state(
State(state): State<AppState>,
headers: HeaderMap,
) -> Json<GetAgeAssuranceOutput> {
let created_at = get_account_created_at(&state, &headers).await;
let now = chrono::Utc::now().to_rfc3339();
(
StatusCode::OK,
Json(json!({
"state": {
"status": "assured",
"access": "full",
"lastInitiatedAt": now
},
"metadata": {
"accountCreatedAt": created_at
}
})),
)
.into_response()
Json(GetAgeAssuranceOutput {
state: AgeAssuranceState {
status: "assured",
access: "full",
last_initiated_at: now,
},
metadata: AgeAssuranceMetadata {
account_created_at: created_at,
},
})
}
pub async fn get_age_assurance_state() -> Response {
(StatusCode::OK, Json(json!({"status": "assured"}))).into_response()
pub async fn get_age_assurance_state() -> Json<AgeAssuranceStatusOutput> {
Json(AgeAssuranceStatusOutput { status: "assured" })
}
async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option<String> {
@@ -45,8 +68,8 @@ async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option
let http_uri = "/";
let auth_user = match validate_token_with_dpop(
state.user_repo.as_ref(),
state.oauth_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.oauth.as_ref(),
&extracted.token,
extracted.scheme,
dpop_proof,
@@ -66,7 +89,7 @@ async fn get_account_created_at(state: &AppState, headers: &HeaderMap) -> Option
}
};
match state.user_repo.get_by_did(&auth_user.did).await {
match state.repos.user.get_by_did(&auth_user.did).await {
Ok(Some(user)) => {
tracing::debug!(created_at = ?user.created_at, "age assurance: got user");
Some(user.created_at.to_rfc3339())
+276
View File
@@ -0,0 +1,276 @@
use bcrypt::{DEFAULT_COST, hash};
use chrono::{DateTime, Utc};
use std::collections::HashMap;
use tracing::error;
use tranquil_db_traits::{CommsChannel, DidWebOverrides, SessionRepository, UserRepository};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::api::error::DbResultExt;
use tranquil_pds::types::{AtIdentifier, Did, Handle};
pub struct ResolvedRepo {
pub user_id: uuid::Uuid,
pub did: Did,
pub handle: Handle,
}
fn qualify_handle(handle: &Handle) -> Result<Handle, ApiError> {
let raw = handle.as_str();
let qualified = match raw.contains('.') {
true => return Ok(handle.clone()),
false => format!(
"{}.{}",
raw,
tranquil_config::get().server.hostname_without_port()
),
};
qualified
.parse()
.map_err(|_| ApiError::InvalidRequest("Invalid handle format".into()))
}
pub async fn resolve_repo(
user_repo: &dyn UserRepository,
repo: &AtIdentifier,
) -> Result<ResolvedRepo, ApiError> {
let row = match repo {
AtIdentifier::Did(did) => user_repo
.get_by_did(did)
.await
.log_db_err("resolving repo by DID")?,
AtIdentifier::Handle(handle) => {
let qualified = qualify_handle(handle)?;
user_repo
.get_by_handle(&qualified)
.await
.log_db_err("resolving repo by handle")?
}
};
row.map(|r| ResolvedRepo {
user_id: r.id,
did: r.did,
handle: r.handle,
})
.ok_or(ApiError::RepoNotFound(Some("Repo not found".into())))
}
pub async fn resolve_repo_user_id(
user_repo: &dyn UserRepository,
repo: &AtIdentifier,
) -> Result<uuid::Uuid, ApiError> {
let id = match repo {
AtIdentifier::Did(did) => user_repo
.get_id_by_did(did)
.await
.log_db_err("resolving repo user ID by DID")?,
AtIdentifier::Handle(handle) => {
let qualified = qualify_handle(handle)?;
user_repo
.get_id_by_handle(&qualified)
.await
.log_db_err("resolving repo user ID by handle")?
}
};
id.ok_or(ApiError::RepoNotFound(Some("Repo not found".into())))
}
pub fn group_invite_uses_by_code<U, F>(
uses: Vec<tranquil_db_traits::InviteCodeUse>,
map_use: F,
) -> HashMap<String, Vec<U>>
where
F: Fn(tranquil_db_traits::InviteCodeUse) -> U,
{
uses.into_iter().fold(HashMap::new(), |mut acc, u| {
let code = u.code.clone();
acc.entry(code).or_default().push(map_use(u));
acc
})
}
pub fn resolve_also_known_as(
overrides: Option<&DidWebOverrides>,
current_handle: &str,
) -> Vec<String> {
overrides
.filter(|ovr| !ovr.also_known_as.is_empty())
.map(|ovr| ovr.also_known_as.clone())
.unwrap_or_else(|| vec![format!("at://{}", current_handle)])
}
pub fn build_did_document(
did: &str,
also_known_as: Vec<String>,
verification_methods: Vec<serde_json::Value>,
service_endpoint: &str,
) -> serde_json::Value {
serde_json::json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": did,
"alsoKnownAs": also_known_as,
"verificationMethod": verification_methods,
"service": [{
"id": "#atproto_pds",
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": service_endpoint
}]
})
}
pub async fn set_channel_verified_flag(
user_repo: &dyn UserRepository,
user_id: uuid::Uuid,
channel: CommsChannel,
) -> Result<(), ApiError> {
match channel {
CommsChannel::Email => user_repo
.set_email_verified_flag(user_id)
.await
.log_db_err("updating email verified status")?,
CommsChannel::Discord => user_repo
.set_discord_verified_flag(user_id)
.await
.log_db_err("updating discord verified status")?,
CommsChannel::Telegram => user_repo
.set_telegram_verified_flag(user_id)
.await
.log_db_err("updating telegram verified status")?,
CommsChannel::Signal => user_repo
.set_signal_verified_flag(user_id)
.await
.log_db_err("updating signal verified status")?,
};
Ok(())
}
pub struct ChannelInput<'a> {
pub email: Option<&'a str>,
pub discord_username: Option<&'a str>,
pub telegram_username: Option<&'a str>,
pub signal_username: Option<&'a str>,
}
pub fn extract_verification_recipient(
channel: CommsChannel,
input: &ChannelInput<'_>,
) -> Result<String, ApiError> {
match channel {
CommsChannel::Email => match input.email {
Some(e) if !e.trim().is_empty() => Ok(e.trim().to_string()),
_ => Err(ApiError::MissingEmail),
},
CommsChannel::Discord => match input.discord_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().to_lowercase();
if !tranquil_pds::api::validation::is_valid_discord_username(&clean) {
return Err(ApiError::InvalidRequest(
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
));
}
Ok(clean)
}
_ => Err(ApiError::MissingDiscordId),
},
CommsChannel::Telegram => match input.telegram_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().trim_start_matches('@');
if !tranquil_pds::api::validation::is_valid_telegram_username(clean) {
return Err(ApiError::InvalidRequest(
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
));
}
Ok(clean.to_string())
}
_ => Err(ApiError::MissingTelegramUsername),
},
CommsChannel::Signal => match input.signal_username {
Some(username) if !username.trim().is_empty() => {
Ok(username.trim().trim_start_matches('@').to_lowercase())
}
_ => Err(ApiError::MissingSignalNumber),
},
}
}
pub fn create_self_hosted_did_web(handle: &str) -> Result<String, ApiError> {
if !tranquil_pds::util::is_self_hosted_did_web_enabled() {
return Err(ApiError::SelfHostedDidWebDisabled);
}
let encoded_handle = handle.replace(':', "%3A");
Ok(format!("did:web:{}", encoded_handle))
}
pub enum CredentialMatch {
MainPassword,
AppPassword {
name: String,
scopes: Option<String>,
controller_did: Option<Did>,
},
}
pub async fn verify_credential(
session_repo: &dyn SessionRepository,
user_id: uuid::Uuid,
password: &str,
password_hash: Option<&str>,
) -> Option<CredentialMatch> {
let main_valid = password_hash
.map(|h| bcrypt::verify(password, h).unwrap_or(false))
.unwrap_or(false);
if main_valid {
return Some(CredentialMatch::MainPassword);
}
let app_passwords = session_repo
.get_app_passwords_for_login(user_id)
.await
.unwrap_or_default();
app_passwords
.into_iter()
.find(|app| bcrypt::verify(password, &app.password_hash).unwrap_or(false))
.map(|app| CredentialMatch::AppPassword {
name: app.name,
scopes: app.scopes,
controller_did: app.created_by_controller_did,
})
}
pub fn hash_or_internal_error(value: &str) -> Result<String, ApiError> {
bcrypt::hash(value, DEFAULT_COST).map_err(|e| {
error!("Bcrypt hash error: {:?}", e);
ApiError::InternalError(None)
})
}
pub async fn hash_password_async(password: &str) -> Result<String, ApiError> {
let password = password.to_string();
tokio::task::spawn_blocking(move || hash(password, DEFAULT_COST))
.await
.map_err(|e| {
error!("Failed to spawn blocking task: {:?}", e);
ApiError::InternalError(None)
})?
.map_err(|e| {
error!("Failed to hash password: {:?}", e);
ApiError::InternalError(None)
})
}
pub fn validate_token_hash(
expires_at: Option<DateTime<Utc>>,
stored_hash: &str,
input_token: &str,
expired_err: ApiError,
invalid_err: ApiError,
) -> Result<(), ApiError> {
match expires_at {
Some(exp) if exp < Utc::now() => Err(expired_err),
_ => match bcrypt::verify(input_token, stored_hash).unwrap_or(false) {
true => Ok(()),
false => Err(invalid_err),
},
}
}
+113 -156
View File
@@ -2,13 +2,14 @@ use crate::identity::provision::{create_plc_did, init_genesis_repo};
use axum::{
Json,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, info, warn};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::api::{
AccountsOutput, AuditLogOutput, ControllersOutput, PresetsOutput, SuccessResponse,
};
use tranquil_pds::auth::{Active, Auth};
use tranquil_pds::delegation::{
DelegationActionType, SCOPE_PRESETS, ValidatedDelegationScope, verify_can_add_controllers,
@@ -21,30 +22,27 @@ use tranquil_pds::types::{Did, Handle};
pub async fn list_controllers(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
let controllers = match state
.delegation_repo
) -> Result<Json<ControllersOutput<Vec<tranquil_db_traits::ControllerInfo>>>, ApiError> {
let controllers = state
.repos
.delegation
.get_delegations_for_account(&auth.did)
.await
{
Ok(c) => c,
Err(e) => {
.map_err(|e| {
tracing::error!("Failed to list controllers: {:?}", e);
return Ok(
ApiError::InternalError(Some("Failed to list controllers".into())).into_response(),
);
}
};
ApiError::InternalError(Some("Failed to list controllers".into()))
})?;
let resolve_futures = controllers.into_iter().map(|mut c| {
let did_resolver = state.did_resolver.clone();
async move {
if c.handle.is_none() {
c.handle = did_resolver
.resolve_did_document(c.did.as_str())
.fetch_did_document(c.did.as_str())
.await
.ok()
.and_then(|doc| tranquil_types::did_doc::extract_handle(&doc))
.map(|h| h.into());
.map(Into::into);
}
c
}
@@ -52,7 +50,7 @@ pub async fn list_controllers(
let controllers = futures::future::join_all(resolve_futures).await;
Ok(Json(serde_json::json!({ "controllers": controllers })).into_response())
Ok(Json(ControllersOutput { controllers }))
}
#[derive(Debug, Deserialize)]
@@ -65,18 +63,18 @@ pub async fn add_controller(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<AddControllerInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<SuccessResponse>, ApiError> {
let resolved = tranquil_pds::delegation::resolve_identity(&state, &input.controller_did)
.await
.ok_or(ApiError::ControllerNotFound)?;
.map_err(|_| ApiError::ControllerNotFound)?;
if !resolved.is_local
&& let Some(ref pds_url) = resolved.pds_url
{
if !pds_url.starts_with("https://") {
return Ok(
ApiError::InvalidDelegation("Controller PDS must use HTTPS".into()).into_response(),
);
return Err(ApiError::InvalidDelegation(
"Controller PDS must use HTTPS".into(),
));
}
match state
.cross_pds_oauth
@@ -84,10 +82,9 @@ pub async fn add_controller(
.await
{
Some(true) => {
return Ok(ApiError::InvalidDelegation(
return Err(ApiError::InvalidDelegation(
"Cannot add a delegated account from another PDS as a controller".into(),
)
.into_response());
));
}
Some(false) => {}
None => {
@@ -100,26 +97,24 @@ pub async fn add_controller(
}
}
let can_add = match verify_can_add_controllers(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
let can_add = verify_can_add_controllers(&state, &auth).await?;
if resolved.is_local
&& state
.delegation_repo
.repos
.delegation
.is_delegated_account(&input.controller_did)
.await
.unwrap_or(false)
{
return Ok(ApiError::InvalidDelegation(
return Err(ApiError::InvalidDelegation(
"Cannot add a controlled account as a controller".into(),
)
.into_response());
));
}
match state
.delegation_repo
.repos
.delegation
.create_delegation(
can_add.did(),
&input.controller_did,
@@ -130,13 +125,14 @@ pub async fn add_controller(
{
Ok(_) => {
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
can_add.did(),
can_add.did(),
Some(&input.controller_did),
DelegationActionType::GrantCreated,
Some(serde_json::json!({
Some(json!({
"granted_scopes": input.granted_scopes.as_str(),
"is_local": resolved.is_local
})),
@@ -145,17 +141,13 @@ pub async fn add_controller(
)
.await;
Ok((
StatusCode::OK,
Json(serde_json::json!({
"success": true
})),
)
.into_response())
Ok(Json(SuccessResponse { success: true }))
}
Err(e) => {
tracing::error!("Failed to add controller: {:?}", e);
Ok(ApiError::InternalError(Some("Failed to add controller".into())).into_response())
Err(ApiError::InternalError(Some(
"Failed to add controller".into(),
)))
}
}
}
@@ -169,15 +161,17 @@ pub async fn remove_controller(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<RemoveControllerInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<SuccessResponse>, ApiError> {
match state
.delegation_repo
.repos
.delegation
.revoke_delegation(&auth.did, &input.controller_did, &auth.did)
.await
{
Ok(true) => {
let revoked_app_passwords = state
.session_repo
.repos
.session
.delete_app_passwords_by_controller(&auth.did, &input.controller_did)
.await
.unwrap_or(0)
@@ -185,19 +179,21 @@ pub async fn remove_controller(
.unwrap_or(0usize);
let revoked_oauth_tokens = state
.oauth_repo
.repos
.oauth
.revoke_tokens_for_controller(&auth.did, &input.controller_did)
.await
.unwrap_or(0);
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&auth.did,
&auth.did,
Some(&input.controller_did),
DelegationActionType::GrantRevoked,
Some(serde_json::json!({
Some(json!({
"revoked_app_passwords": revoked_app_passwords,
"revoked_oauth_tokens": revoked_oauth_tokens
})),
@@ -206,18 +202,14 @@ pub async fn remove_controller(
)
.await;
Ok((
StatusCode::OK,
Json(serde_json::json!({
"success": true
})),
)
.into_response())
Ok(Json(SuccessResponse { success: true }))
}
Ok(false) => Ok(ApiError::DelegationNotFound.into_response()),
Ok(false) => Err(ApiError::DelegationNotFound),
Err(e) => {
tracing::error!("Failed to remove controller: {:?}", e);
Ok(ApiError::InternalError(Some("Failed to remove controller".into())).into_response())
Err(ApiError::InternalError(Some(
"Failed to remove controller".into(),
)))
}
}
}
@@ -232,21 +224,23 @@ pub async fn update_controller_scopes(
State(state): State<AppState>,
auth: Auth<Active>,
Json(input): Json<UpdateControllerScopesInput>,
) -> Result<Response, ApiError> {
) -> Result<Json<SuccessResponse>, ApiError> {
match state
.delegation_repo
.repos
.delegation
.update_delegation_scopes(&auth.did, &input.controller_did, &input.granted_scopes)
.await
{
Ok(true) => {
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&auth.did,
&auth.did,
Some(&input.controller_did),
DelegationActionType::ScopesModified,
Some(serde_json::json!({
Some(json!({
"new_scopes": input.granted_scopes.as_str()
})),
None,
@@ -254,21 +248,14 @@ pub async fn update_controller_scopes(
)
.await;
Ok((
StatusCode::OK,
Json(serde_json::json!({
"success": true
})),
)
.into_response())
Ok(Json(SuccessResponse { success: true }))
}
Ok(false) => Ok(ApiError::DelegationNotFound.into_response()),
Ok(false) => Err(ApiError::DelegationNotFound),
Err(e) => {
tracing::error!("Failed to update controller scopes: {:?}", e);
Ok(
ApiError::InternalError(Some("Failed to update controller scopes".into()))
.into_response(),
)
Err(ApiError::InternalError(Some(
"Failed to update controller scopes".into(),
)))
}
}
}
@@ -276,23 +263,18 @@ pub async fn update_controller_scopes(
pub async fn list_controlled_accounts(
State(state): State<AppState>,
auth: Auth<Active>,
) -> Result<Response, ApiError> {
let accounts = match state
.delegation_repo
) -> Result<Json<AccountsOutput<Vec<tranquil_db_traits::DelegatedAccountInfo>>>, ApiError> {
let accounts = state
.repos
.delegation
.get_accounts_controlled_by(&auth.did)
.await
{
Ok(a) => a,
Err(e) => {
.map_err(|e| {
tracing::error!("Failed to list controlled accounts: {:?}", e);
return Ok(
ApiError::InternalError(Some("Failed to list controlled accounts".into()))
.into_response(),
);
}
};
ApiError::InternalError(Some("Failed to list controlled accounts".into()))
})?;
Ok(Json(serde_json::json!({ "accounts": accounts })).into_response())
Ok(Json(AccountsOutput { accounts }))
}
#[derive(Debug, Deserialize)]
@@ -311,35 +293,35 @@ pub async fn get_audit_log(
State(state): State<AppState>,
auth: Auth<Active>,
Query(params): Query<AuditLogParams>,
) -> Result<Response, ApiError> {
) -> Result<Json<AuditLogOutput<Vec<tranquil_db_traits::AuditLogEntry>>>, ApiError> {
let limit = params.limit.clamp(1, 100);
let offset = params.offset.max(0);
let entries = match state
.delegation_repo
let entries = state
.repos
.delegation
.get_audit_log_for_account(&auth.did, limit, offset)
.await
{
Ok(e) => e,
Err(e) => {
.map_err(|e| {
tracing::error!("Failed to get audit log: {:?}", e);
return Ok(
ApiError::InternalError(Some("Failed to get audit log".into())).into_response(),
);
}
};
ApiError::InternalError(Some("Failed to get audit log".into()))
})?;
let total = state
.delegation_repo
.repos
.delegation
.count_audit_log_entries(&auth.did)
.await
.unwrap_or_default();
Ok(Json(serde_json::json!({ "entries": entries, "total": total })).into_response())
Ok(Json(AuditLogOutput { entries, total }))
}
pub async fn get_scope_presets() -> Response {
Json(serde_json::json!({ "presets": SCOPE_PRESETS })).into_response()
pub async fn get_scope_presets()
-> Json<PresetsOutput<&'static [tranquil_pds::delegation::ScopePreset]>> {
Json(PresetsOutput {
presets: SCOPE_PRESETS,
})
}
#[derive(Debug, Deserialize)]
@@ -353,7 +335,7 @@ pub struct CreateDelegatedAccountInput {
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateDelegatedAccountResponse {
pub struct CreateDelegatedAccountOutput {
pub did: Did,
pub handle: Handle,
}
@@ -363,18 +345,11 @@ pub async fn create_delegated_account(
_rate_limit: RateLimited<AccountCreationLimit>,
auth: Auth<Active>,
Json(input): Json<CreateDelegatedAccountInput>,
) -> Result<Response, ApiError> {
let can_control = match verify_can_control_accounts(&state, &auth).await {
Ok(proof) => proof,
Err(response) => return Ok(response),
};
) -> Result<Json<CreateDelegatedAccountOutput>, ApiError> {
let can_control = verify_can_control_accounts(&state, &auth).await?;
let handle = match tranquil_pds::api::validation::resolve_handle_input(&input.handle) {
Ok(h) => h,
Err(e) => {
return Ok(ApiError::InvalidRequest(e.to_string()).into_response());
}
};
let handle = tranquil_pds::api::validation::resolve_handle_input(&input.handle)
.map_err(|e| ApiError::InvalidRequest(e.to_string()))?;
let email = input
.email
@@ -384,18 +359,18 @@ pub async fn create_delegated_account(
if let Some(ref email) = email
&& !tranquil_pds::api::validation::is_valid_email(email)
{
return Ok(ApiError::InvalidEmail.into_response());
return Err(ApiError::InvalidEmail);
}
let validated_invite_code = if let Some(ref code) = input.invite_code {
match state.infra_repo.validate_invite_code(code).await {
match state.repos.infra.validate_invite_code(code).await {
Ok(validated) => Some(validated),
Err(_) => return Ok(ApiError::InvalidInviteCode.into_response()),
Err(_) => return Err(ApiError::InvalidInviteCode),
}
} else {
let invite_required = tranquil_config::get().server.invite_code_required;
if invite_required {
return Ok(ApiError::InviteCodeRequired.into_response());
return Err(ApiError::InviteCodeRequired);
}
None
};
@@ -409,6 +384,7 @@ pub async fn create_delegated_account(
info!(did = %did, handle = %handle, controller = %can_control.did(), "Created DID for delegated account");
let repo = init_genesis_repo(&state, &did, &plc.signing_key, &plc.signing_key_bytes).await?;
let repo_for_seq = repo.clone();
let create_input = tranquil_db_traits::CreateDelegatedAccountInput {
handle: handle.clone(),
@@ -425,65 +401,46 @@ pub async fn create_delegated_account(
};
let user_id = match state
.user_repo
.repos
.user
.create_delegated_account(&create_input)
.await
{
Ok(id) => id,
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
return Ok(ApiError::HandleNotAvailable(None).into_response());
return Err(ApiError::HandleNotAvailable(None));
}
Err(tranquil_db_traits::CreateAccountError::EmailTaken) => {
return Ok(ApiError::EmailTaken.into_response());
return Err(ApiError::EmailTaken);
}
Err(e) => {
error!("Error creating delegated account: {:?}", e);
return Ok(ApiError::InternalError(None).into_response());
return Err(ApiError::InternalError(None));
}
};
if let Some(validated) = validated_invite_code
&& let Err(e) = state
.infra_repo
.repos
.infra
.record_invite_code_use(&validated, user_id)
.await
{
warn!("Failed to record invite code use for {}: {:?}", did, e);
}
if let Err(e) =
tranquil_pds::repo_ops::sequence_identity_event(&state, &did, Some(&handle)).await
{
warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
crate::identity::provision::sequence_new_account(
&state,
&did,
tranquil_db_traits::AccountStatus::Active,
&handle,
&repo_for_seq,
handle.as_str(),
)
.await
{
warn!("Failed to sequence account event for {}: {}", did, e);
}
let profile_record = json!({
"$type": "app.bsky.actor.profile",
"displayName": handle
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
&state,
&did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
{
warn!("Failed to create default profile for {}: {}", did, e);
}
.await;
let _ = state
.delegation_repo
.repos
.delegation
.log_delegation_action(
&did,
&auth.did,
@@ -500,7 +457,7 @@ pub async fn create_delegated_account(
info!(did = %did, handle = %handle, controller = %&auth.did, "Delegated account created");
Ok(Json(CreateDelegatedAccountResponse { did, handle }).into_response())
Ok(Json(CreateDelegatedAccountOutput { did, handle }))
}
#[derive(Debug, Deserialize)]
@@ -511,7 +468,7 @@ pub struct ResolveControllerParams {
pub async fn resolve_controller(
State(state): State<AppState>,
Query(params): Query<ResolveControllerParams>,
) -> Result<Response, ApiError> {
) -> Result<Json<tranquil_pds::delegation::ResolvedIdentity>, ApiError> {
let identifier = params.identifier.trim().trim_start_matches('@');
let did: Did = if identifier.starts_with("did:") {
@@ -521,7 +478,7 @@ pub async fn resolve_controller(
} else {
let local_handle: Option<Handle> = identifier.parse().ok();
let local_user = match local_handle {
Some(ref h) => state.user_repo.get_by_handle(h).await.ok().flatten(),
Some(ref h) => state.repos.user.get_by_handle(h).await.ok().flatten(),
None => None,
};
match local_user {
@@ -536,7 +493,7 @@ pub async fn resolve_controller(
let resolved = tranquil_pds::delegation::resolve_identity(&state, &did)
.await
.ok_or(ApiError::ControllerNotFound)?;
.map_err(|_| ApiError::ControllerNotFound)?;
Ok(Json(resolved).into_response())
Ok(Json(resolved))
}
+4 -3
View File
@@ -169,7 +169,8 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
);
match state
.user_repo
.repos
.user
.store_discord_user_id(&discord_username, &discord_user_id, handle.as_deref())
.await
{
@@ -180,8 +181,8 @@ async fn handle_command(state: AppState, interaction: Interaction) -> Response {
"Verified Discord user and stored user ID"
);
if let Err(e) = comms_repo::enqueue_channel_verified(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
tranquil_db_traits::CommsChannel::Discord,
&discord_user_id,
+233 -363
View File
@@ -1,23 +1,20 @@
use super::did::verify_did_web;
use crate::common;
use axum::{
Json,
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use bcrypt::{DEFAULT_COST, hash};
use k256::{SecretKey, ecdsa::SigningKey};
use rand::rngs::OsRng;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{debug, error, info, warn};
use tracing::{debug, error, info};
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{ServiceTokenVerifier, extract_auth_token_from_header, is_service_token};
use tranquil_pds::rate_limit::{AccountCreationLimit, RateLimited};
use tranquil_pds::state::AppState;
use tranquil_pds::types::{Did, Handle, PlainPassword};
use tranquil_pds::validation::validate_password;
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAccountInput {
@@ -47,6 +44,145 @@ pub struct CreateAccountOutput {
pub verification_channel: tranquil_db_traits::CommsChannel,
}
async fn try_reactivate_migration(
state: &AppState,
did: &str,
handle: &str,
email: &Option<String>,
verification_channel: tranquil_db_traits::CommsChannel,
verification_recipient: Option<&str>,
) -> Option<Response> {
let did_typed: Did = match did.parse() {
Ok(d) => d,
Err(_) => return Some(ApiError::InternalError(Some("Invalid DID".into())).into_response()),
};
let handle_typed: Handle = match handle.parse() {
Ok(h) => h,
Err(_) => return Some(ApiError::InvalidHandle(None).into_response()),
};
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
did: did_typed.clone(),
new_handle: handle_typed.clone(),
new_email: email.clone(),
};
match state
.repos
.user
.reactivate_migration_account(&reactivate_input)
.await
{
Ok(reactivated) => {
info!(did = %did, old_handle = %reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration");
let secret_key_bytes = match state
.repos
.user
.get_user_key_by_id(reactivated.user_id)
.await
{
Ok(Some(key_info)) => {
match tranquil_pds::config::decrypt_key(
&key_info.key_bytes,
key_info.encryption_version,
) {
Ok(k) => k,
Err(e) => {
error!("Error decrypting key for reactivated account: {:?}", e);
return Some(ApiError::InternalError(None).into_response());
}
}
}
_ => {
error!("No signing key found for reactivated account");
return Some(
ApiError::InternalError(Some("Account signing key not found".into()))
.into_response(),
);
}
};
let access_meta =
match tranquil_pds::auth::create_access_token_with_metadata(did, &secret_key_bytes)
{
Ok(m) => m,
Err(e) => {
error!("Error creating access token: {:?}", e);
return Some(ApiError::InternalError(None).into_response());
}
};
let refresh_meta = match tranquil_pds::auth::create_refresh_token_with_metadata(
did,
&secret_key_bytes,
) {
Ok(m) => m,
Err(e) => {
error!("Error creating refresh token: {:?}", e);
return Some(ApiError::InternalError(None).into_response());
}
};
let session_data = tranquil_db_traits::SessionTokenCreate {
did: did_typed.clone(),
access_jti: access_meta.jti.clone(),
refresh_jti: refresh_meta.jti.clone(),
access_expires_at: access_meta.expires_at,
refresh_expires_at: refresh_meta.expires_at,
login_type: tranquil_db_traits::LoginType::Modern,
mfa_verified: false,
scope: Some("transition:generic transition:chat.bsky".to_string()),
controller_did: None,
app_password_name: None,
};
if let Err(e) = state.repos.session.create_session(&session_data).await {
error!("Error creating session: {:?}", e);
return Some(ApiError::InternalError(None).into_response());
}
let verification_required = match verification_recipient {
Some(recipient) => {
super::provision::enqueue_migration_verification(
state,
reactivated.user_id,
&did_typed,
verification_channel,
recipient,
)
.await;
true
}
None => false,
};
Some(
(
StatusCode::OK,
Json(CreateAccountOutput {
handle: handle.to_string().into(),
did: did_typed.clone(),
did_doc: state
.did_resolver
.fetch_did_document(did)
.await
.ok()
.map(|f| (*f).clone()),
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
verification_required,
verification_channel,
}),
)
.into_response(),
)
}
Err(tranquil_db_traits::MigrationReactivationError::NotFound) => None,
Err(tranquil_db_traits::MigrationReactivationError::NotDeactivated) => {
Some(ApiError::AccountAlreadyExists.into_response())
}
Err(tranquil_db_traits::MigrationReactivationError::HandleTaken) => {
Some(ApiError::HandleTaken.into_response())
}
Err(e) => {
error!("Error reactivating migration account: {:?}", e);
Some(ApiError::InternalError(None).into_response())
}
}
}
pub async fn create_account(
State(state): State<AppState>,
_rate_limit: RateLimited<AccountCreationLimit>,
@@ -153,82 +289,38 @@ pub async fn create_account(
let verification_channel = input
.verification_channel
.unwrap_or(tranquil_db_traits::CommsChannel::Email);
let verification_recipient = if is_migration {
None
} else {
Some(match verification_channel {
tranquil_db_traits::CommsChannel::Email => match &input.email {
Some(email) if !email.trim().is_empty() => email.trim().to_string(),
_ => return ApiError::MissingEmail.into_response(),
let verification_recipient = {
Some(
match common::extract_verification_recipient(
verification_channel,
&common::ChannelInput {
email: input.email.as_deref(),
discord_username: input.discord_username.as_deref(),
telegram_username: input.telegram_username.as_deref(),
signal_username: input.signal_username.as_deref(),
},
) {
Ok(r) => r,
Err(e) => return e.into_response(),
},
tranquil_db_traits::CommsChannel::Discord => match &input.discord_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().to_lowercase();
if !tranquil_pds::api::validation::is_valid_discord_username(&clean) {
return ApiError::InvalidRequest(
"Invalid Discord username. Must be 2-32 lowercase characters (letters, numbers, underscores, periods)".into(),
).into_response();
}
clean
}
_ => return ApiError::MissingDiscordId.into_response(),
},
tranquil_db_traits::CommsChannel::Telegram => match &input.telegram_username {
Some(username) if !username.trim().is_empty() => {
let clean = username.trim().trim_start_matches('@');
if !tranquil_pds::api::validation::is_valid_telegram_username(clean) {
return ApiError::InvalidRequest(
"Invalid Telegram username. Must be 5-32 characters, alphanumeric or underscore".into(),
).into_response();
}
clean.to_string()
}
_ => return ApiError::MissingTelegramUsername.into_response(),
},
tranquil_db_traits::CommsChannel::Signal => match &input.signal_username {
Some(username) if !username.trim().is_empty() => {
username.trim().trim_start_matches('@').to_lowercase()
}
_ => return ApiError::MissingSignalNumber.into_response(),
},
})
)
};
let hostname = &cfg.server.hostname;
let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<uuid::Uuid>) =
if let Some(signing_key_did) = &input.signing_key {
match state
.infra_repo
.get_reserved_signing_key(signing_key_did)
.await
{
Ok(Some(key)) => (key.private_key_bytes, Some(key.id)),
Ok(None) => {
return ApiError::InvalidSigningKey.into_response();
}
Err(e) => {
error!("Error looking up reserved signing key: {:?}", e);
return ApiError::InternalError(None).into_response();
}
}
} else {
let secret_key = SecretKey::random(&mut OsRng);
(secret_key.to_bytes().to_vec(), None)
let key_result =
match super::provision::resolve_signing_key(&state, input.signing_key.as_deref()).await {
Ok(k) => k,
Err(e) => return e.into_response(),
};
let signing_key = match SigningKey::from_slice(&secret_key_bytes) {
Ok(k) => k,
Err(e) => {
error!("Error creating signing key: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let secret_key_bytes = key_result.secret_key_bytes;
let signing_key = key_result.signing_key;
let reserved_key_id = key_result.reserved_key_id;
let did_type = input.did_type.as_deref().unwrap_or("plc");
let did = match did_type {
"web" => {
if !tranquil_pds::util::is_self_hosted_did_web_enabled() {
return ApiError::SelfHostedDidWebDisabled.into_response();
}
let encoded_handle = handle.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_handle);
let self_hosted_did = match common::create_self_hosted_did_web(&handle) {
Ok(d) => d,
Err(e) => return e.into_response(),
};
info!(did = %self_hosted_did, "Creating self-hosted did:web account (subdomain)");
self_hosted_did
}
@@ -289,137 +381,18 @@ pub async fn create_account(
}
}
};
if is_migration {
let did_typed: Did = match did.parse() {
Ok(d) => d,
Err(_) => return ApiError::InternalError(Some("Invalid DID".into())).into_response(),
};
let handle_typed: Handle = match handle.parse() {
Ok(h) => h,
Err(_) => return ApiError::InvalidHandle(None).into_response(),
};
let reactivate_input = tranquil_db_traits::MigrationReactivationInput {
did: did_typed.clone(),
new_handle: handle_typed.clone(),
new_email: email.clone(),
};
match state
.user_repo
.reactivate_migration_account(&reactivate_input)
.await
{
Ok(reactivated) => {
info!(did = %did, old_handle = %reactivated.old_handle, new_handle = %handle, "Preparing existing account for inbound migration");
let secret_key_bytes = match state
.user_repo
.get_user_key_by_id(reactivated.user_id)
.await
{
Ok(Some(key_info)) => {
match tranquil_pds::config::decrypt_key(
&key_info.key_bytes,
key_info.encryption_version,
) {
Ok(k) => k,
Err(e) => {
error!("Error decrypting key for reactivated account: {:?}", e);
return ApiError::InternalError(None).into_response();
}
}
}
_ => {
error!("No signing key found for reactivated account");
return ApiError::InternalError(Some(
"Account signing key not found".into(),
))
.into_response();
}
};
let access_meta = match tranquil_pds::auth::create_access_token_with_metadata(
&did,
&secret_key_bytes,
) {
Ok(m) => m,
Err(e) => {
error!("Error creating access token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let refresh_meta = match tranquil_pds::auth::create_refresh_token_with_metadata(
&did,
&secret_key_bytes,
) {
Ok(m) => m,
Err(e) => {
error!("Error creating refresh token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let session_data = tranquil_db_traits::SessionTokenCreate {
did: did_typed.clone(),
access_jti: access_meta.jti.clone(),
refresh_jti: refresh_meta.jti.clone(),
access_expires_at: access_meta.expires_at,
refresh_expires_at: refresh_meta.expires_at,
login_type: tranquil_db_traits::LoginType::Modern,
mfa_verified: false,
scope: Some("transition:generic transition:chat.bsky".to_string()),
controller_did: None,
app_password_name: None,
};
if let Err(e) = state.session_repo.create_session(&session_data).await {
error!("Error creating session: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let hostname = &tranquil_config::get().server.hostname;
let verification_required = if let Some(ref user_email) = email {
let token = tranquil_pds::auth::verification_token::generate_migration_token(
&did_typed, user_email,
);
let formatted_token =
tranquil_pds::auth::verification_token::format_token_for_display(&token);
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
reactivated.user_id,
user_email,
&formatted_token,
hostname,
)
.await
{
warn!("Failed to enqueue migration verification email: {:?}", e);
}
true
} else {
false
};
return (
axum::http::StatusCode::OK,
Json(CreateAccountOutput {
handle: handle.clone().into(),
did: did_typed.clone(),
did_doc: state.did_resolver.resolve_did_document(&did).await,
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
verification_required,
verification_channel: tranquil_db_traits::CommsChannel::Email,
}),
)
.into_response();
}
Err(tranquil_db_traits::MigrationReactivationError::NotFound) => {}
Err(tranquil_db_traits::MigrationReactivationError::NotDeactivated) => {
return ApiError::AccountAlreadyExists.into_response();
}
Err(tranquil_db_traits::MigrationReactivationError::HandleTaken) => {
return ApiError::HandleTaken.into_response();
}
Err(e) => {
error!("Error reactivating migration account: {:?}", e);
return ApiError::InternalError(None).into_response();
}
}
if is_migration
&& let Some(response) = try_reactivate_migration(
&state,
&did,
&handle,
&email,
verification_channel,
verification_recipient.as_deref(),
)
.await
{
return response;
}
let handle_typed: Handle = match handle.parse() {
@@ -427,7 +400,8 @@ pub async fn create_account(
Err(_) => return ApiError::InvalidHandle(None).into_response(),
};
let handle_available = match state
.user_repo
.repos
.user
.check_handle_available_for_new_account(&handle_typed)
.await
{
@@ -442,7 +416,7 @@ pub async fn create_account(
}
let is_bootstrap = state.bootstrap_invite_code.is_some()
&& state.user_repo.count_users().await.unwrap_or(1) == 0;
&& state.repos.user.count_users().await.unwrap_or(1) == 0;
if is_bootstrap {
match input.invite_code.as_deref() {
@@ -463,7 +437,7 @@ pub async fn create_account(
if let Some(code) = &input.invite_code
&& !code.trim().is_empty()
{
let valid = match state.user_repo.check_and_consume_invite_code(code).await {
let valid = match state.repos.user.check_and_consume_invite_code(code).await {
Ok(v) => v,
Err(e) => {
error!("Error checking invite code: {:?}", e);
@@ -480,19 +454,10 @@ pub async fn create_account(
return ApiError::InvalidRequest(e.to_string()).into_response();
}
let password_clone = input.password.clone();
let password_hash =
match tokio::task::spawn_blocking(move || hash(&password_clone, DEFAULT_COST)).await {
Ok(Ok(h)) => h,
Ok(Err(e)) => {
error!("Error hashing password: {:?}", e);
return ApiError::InternalError(None).into_response();
}
Err(e) => {
error!("Failed to spawn blocking task: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let password_hash = match crate::common::hash_password_async(&input.password).await {
Ok(h) => h,
Err(e) => return e.into_response(),
};
let deactivated_at: Option<chrono::DateTime<chrono::Utc>> = if is_migration || is_did_web_byod {
Some(chrono::Utc::now())
@@ -527,7 +492,13 @@ pub async fn create_account(
None
};
let comms = super::provision::normalize_comms_usernames(
input.discord_username.as_deref(),
input.telegram_username.as_deref(),
input.signal_username.as_deref(),
);
let preferred_comms_channel = verification_channel;
let repo_for_seq = repo.clone();
let create_input = tranquil_db_traits::CreatePasswordAccountInput {
handle: handle_typed.clone(),
@@ -535,23 +506,9 @@ pub async fn create_account(
did: did_for_commit.clone(),
password_hash,
preferred_comms_channel,
discord_username: input
.discord_username
.as_deref()
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty()),
telegram_username: input
.telegram_username
.as_deref()
.map(|s| s.trim().trim_start_matches('@'))
.filter(|s| !s.is_empty())
.map(String::from),
signal_username: input
.signal_username
.as_deref()
.map(|s| s.trim().trim_start_matches('@'))
.filter(|s| !s.is_empty())
.map(|s| s.to_lowercase()),
discord_username: comms.discord,
telegram_username: comms.telegram,
signal_username: comms.signal,
deactivated_at,
encrypted_key_bytes: repo.encrypted_key_bytes,
encryption_version: tranquil_pds::config::ENCRYPTION_VERSION,
@@ -567,7 +524,12 @@ pub async fn create_account(
birthdate_pref,
};
let create_result = match state.user_repo.create_password_account(&create_input).await {
let create_result = match state
.repos
.user
.create_password_account(&create_input)
.await
{
Ok(r) => r,
Err(tranquil_db_traits::CreateAccountError::HandleTaken) => {
return ApiError::HandleNotAvailable(None).into_response();
@@ -585,144 +547,52 @@ pub async fn create_account(
};
let user_id = create_result.user_id;
if !is_migration && !is_did_web_byod {
if let Err(e) = tranquil_pds::repo_ops::sequence_identity_event(
super::provision::sequence_new_account(
&state,
&did_for_commit,
Some(&handle_typed),
&handle_typed,
&repo_for_seq,
&input.handle,
)
.await
{
warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
&state,
&did_for_commit,
tranquil_db_traits::AccountStatus::Active,
)
.await
{
warn!("Failed to sequence account event for {}: {}", did, e);
}
if let Err(e) = tranquil_pds::repo_ops::sequence_genesis_commit(
&state,
&did_for_commit,
&repo.commit_cid,
&repo.mst_root_cid,
&rev_str,
)
.await
{
warn!("Failed to sequence commit event for {}: {}", did, e);
}
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
&state,
&did_for_commit,
&commit_cid_str,
Some(&rev_str),
)
.await
{
warn!("Failed to sequence sync event for {}: {}", did, e);
}
let profile_record = json!({
"$type": "app.bsky.actor.profile",
"displayName": input.handle
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
&state,
&did_for_commit,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
{
warn!("Failed to create default profile for {}: {}", did, e);
}
.await;
}
let hostname = &tranquil_config::get().server.hostname;
if !is_migration {
if let Some(ref recipient) = verification_recipient {
let verification_token = tranquil_pds::auth::verification_token::generate_signup_token(
super::provision::enqueue_signup_verification(
&state,
user_id,
&did_for_commit,
verification_channel,
recipient,
);
let formatted_token = tranquil_pds::auth::verification_token::format_token_for_display(
&verification_token,
);
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
user_id,
verification_channel,
recipient,
&formatted_token,
hostname,
)
.await
{
warn!(
"Failed to enqueue signup verification notification: {:?}",
e
);
}
.await;
}
} else if let Some(ref user_email) = email {
let token = tranquil_pds::auth::verification_token::generate_migration_token(
&did_for_commit,
user_email,
);
let formatted_token =
tranquil_pds::auth::verification_token::format_token_for_display(&token);
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
} else if let Some(ref recipient) = verification_recipient {
super::provision::enqueue_migration_verification(
&state,
user_id,
user_email,
&formatted_token,
hostname,
&did_for_commit,
verification_channel,
recipient,
)
.await
{
warn!("Failed to enqueue migration verification email: {:?}", e);
}
.await;
}
let access_meta =
match tranquil_pds::auth::create_access_token_with_metadata(&did, &secret_key_bytes) {
Ok(m) => m,
Err(e) => {
error!("createAccount: Error creating access token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let refresh_meta =
match tranquil_pds::auth::create_refresh_token_with_metadata(&did, &secret_key_bytes) {
Ok(m) => m,
Err(e) => {
error!("createAccount: Error creating refresh token: {:?}", e);
return ApiError::InternalError(None).into_response();
}
};
let session_data = tranquil_db_traits::SessionTokenCreate {
did: did_for_commit.clone(),
access_jti: access_meta.jti.clone(),
refresh_jti: refresh_meta.jti.clone(),
access_expires_at: access_meta.expires_at,
refresh_expires_at: refresh_meta.expires_at,
login_type: tranquil_db_traits::LoginType::Modern,
mfa_verified: false,
scope: Some("transition:generic transition:chat.bsky".to_string()),
controller_did: None,
app_password_name: None,
let session = match super::provision::create_and_store_session(
&state,
&did,
&did_for_commit,
&secret_key_bytes,
"transition:generic transition:chat.bsky",
None,
)
.await
{
Ok(s) => s,
Err(e) => return e.into_response(),
};
if let Err(e) = state.session_repo.create_session(&session_data).await {
error!("createAccount: Error creating session: {:?}", e);
return ApiError::InternalError(None).into_response();
}
let did_doc = state.did_resolver.resolve_did_document(&did).await;
let did_doc = state.did_resolver.fetch_did_document(&did).await.ok();
if is_migration {
info!(
@@ -736,9 +606,9 @@ pub async fn create_account(
Json(CreateAccountOutput {
handle: handle.clone().into(),
did: did_for_commit,
did_doc,
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
did_doc: did_doc.map(|f| (*f).clone()),
access_jwt: session.access_jwt,
refresh_jwt: session.refresh_jwt,
verification_required: !is_migration,
verification_channel,
}),
+103 -197
View File
@@ -1,3 +1,4 @@
use crate::common;
use axum::{
Json,
extract::{Path, Query, State},
@@ -10,6 +11,7 @@ use k256::elliptic_curve::sec1::ToEncodedPoint;
use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, warn};
use tranquil_pds::api::error::DbResultExt;
use tranquil_pds::api::{ApiError, DidResponse, EmptyResponse};
use tranquil_pds::auth::{Auth, NotTakendown};
use tranquil_pds::plc::signing_key_to_did_key;
@@ -52,7 +54,7 @@ pub async fn resolve_handle(
return ApiError::InvalidHandle(Some("Invalid handle format".into())).into_response();
}
};
let user = state.user_repo.get_by_handle(&handle).await;
let user = state.repos.user.get_by_handle(&handle).await;
match user {
Ok(Some(row)) => {
let _ = state
@@ -163,7 +165,8 @@ async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) ->
Err(_) => return ApiError::InvalidRequest("Invalid DID format".into()).into_response(),
};
let user = match state
.user_repo
.repos
.user
.get_user_for_did_doc_build(&expected_did_typed)
.await
{
@@ -180,7 +183,8 @@ async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) ->
let did = expected_did;
let overrides = state
.user_repo
.repos
.user
.get_did_web_overrides(user_id)
.await
.ok()
@@ -188,91 +192,20 @@ async fn serve_handle_did_doc(state: &AppState, handle: &str, hostname: &str) ->
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| {
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
.ok()
.filter(|p| !p.is_empty())
.map(|p| (ovr, p))
}) {
let also_known_as = if !ovr.also_known_as.is_empty() {
ovr.also_known_as.clone()
} else {
vec![format!("at://{}", current_handle)]
};
return Json(json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": did,
"alsoKnownAs": also_known_as,
"verificationMethod": parsed.iter().map(|m| json!({
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
"type": m.method_type,
"controller": did,
"publicKeyMultibase": m.public_key_multibase
})).collect::<Vec<_>>(),
"service": [{
"id": "#atproto_pds",
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": service_endpoint
}]
}))
.into_response();
}
let key_info = match state.user_repo.get_user_key_by_id(user_id).await {
Ok(Some(k)) => k,
Ok(None) => return ApiError::InternalError(None).into_response(),
Err(_) => return ApiError::InternalError(None).into_response(),
};
let key_bytes: Vec<u8> =
match tranquil_pds::config::decrypt_key(&key_info.key_bytes, key_info.encryption_version) {
Ok(k) => k,
Err(_) => {
return ApiError::InternalError(None).into_response();
}
};
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
Ok(pk) => pk,
Err(e) => {
tracing::error!("Failed to generate public key multibase: {}", e);
return ApiError::InternalError(None).into_response();
}
let verification_methods =
build_override_or_key_verification_methods(state, user_id, &did, overrides.as_ref()).await;
let verification_methods = match verification_methods {
Ok(vm) => vm,
Err(resp) => return resp,
};
let also_known_as = if let Some(ref ovr) = overrides {
if !ovr.also_known_as.is_empty() {
ovr.also_known_as.clone()
} else {
vec![format!("at://{}", current_handle)]
}
} else {
vec![format!("at://{}", current_handle)]
};
Json(json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": did,
"alsoKnownAs": also_known_as,
"verificationMethod": [{
"id": format!("{}#atproto", did),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": public_key_multibase
}],
"service": [{
"id": "#atproto_pds",
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": service_endpoint
}]
}))
let also_known_as = common::resolve_also_known_as(overrides.as_ref(), &current_handle);
Json(common::build_did_document(
&did,
also_known_as,
verification_methods,
&service_endpoint,
))
.into_response()
}
@@ -287,7 +220,8 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
}
};
let user = match state
.user_repo
.repos
.user
.get_did_web_info_by_handle(&current_handle_typed)
.await
{
@@ -315,7 +249,8 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
}
let overrides = state
.user_repo
.repos
.user
.get_did_web_overrides(user_id)
.await
.ok()
@@ -323,92 +258,65 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
if let Some((ovr, parsed)) = overrides.as_ref().and_then(|ovr| {
let verification_methods =
build_override_or_key_verification_methods(&state, user_id, &did, overrides.as_ref()).await;
let verification_methods = match verification_methods {
Ok(vm) => vm,
Err(resp) => return resp,
};
let also_known_as = common::resolve_also_known_as(overrides.as_ref(), &current_handle);
Json(common::build_did_document(
&did,
also_known_as,
verification_methods,
&service_endpoint,
))
.into_response()
}
async fn build_override_or_key_verification_methods(
state: &AppState,
user_id: uuid::Uuid,
did: &str,
overrides: Option<&tranquil_db_traits::DidWebOverrides>,
) -> Result<Vec<serde_json::Value>, Response> {
if let Some(parsed) = overrides.and_then(|ovr| {
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
.ok()
.filter(|p| !p.is_empty())
.map(|p| (ovr, p))
}) {
let also_known_as = if !ovr.also_known_as.is_empty() {
ovr.also_known_as.clone()
} else {
vec![format!("at://{}", current_handle)]
};
return Json(json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": did,
"alsoKnownAs": also_known_as,
"verificationMethod": parsed.iter().map(|m| json!({
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
"type": m.method_type,
"controller": did,
"publicKeyMultibase": m.public_key_multibase
})).collect::<Vec<_>>(),
"service": [{
"id": "#atproto_pds",
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": service_endpoint
}]
}))
.into_response();
return Ok(parsed
.iter()
.map(|m| {
json!({
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
"type": m.method_type,
"controller": did,
"publicKeyMultibase": m.public_key_multibase
})
})
.collect());
}
let key_info = match state.user_repo.get_user_key_by_id(user_id).await {
let key_info = match state.repos.user.get_user_key_by_id(user_id).await {
Ok(Some(k)) => k,
Ok(None) => return ApiError::InternalError(None).into_response(),
Err(_) => return ApiError::InternalError(None).into_response(),
};
let key_bytes: Vec<u8> =
match tranquil_pds::config::decrypt_key(&key_info.key_bytes, key_info.encryption_version) {
Ok(k) => k,
Err(_) => {
return ApiError::InternalError(None).into_response();
}
};
let public_key_multibase = match get_public_key_multibase(&key_bytes) {
Ok(pk) => pk,
Err(e) => {
tracing::error!("Failed to generate public key multibase: {}", e);
return ApiError::InternalError(None).into_response();
}
_ => return Err(ApiError::InternalError(None).into_response()),
};
let key_bytes =
tranquil_pds::config::decrypt_key(&key_info.key_bytes, key_info.encryption_version)
.map_err(|_| ApiError::InternalError(None).into_response())?;
let public_key_multibase = get_public_key_multibase(&key_bytes).map_err(|e| {
tracing::error!("Failed to generate public key multibase: {}", e);
ApiError::InternalError(None).into_response()
})?;
let also_known_as = if let Some(ref ovr) = overrides {
if !ovr.also_known_as.is_empty() {
ovr.also_known_as.clone()
} else {
vec![format!("at://{}", current_handle)]
}
} else {
vec![format!("at://{}", current_handle)]
};
Json(json!({
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": did,
"alsoKnownAs": also_known_as,
"verificationMethod": [{
"id": format!("{}#atproto", did),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": public_key_multibase
}],
"service": [{
"id": "#atproto_pds",
"type": tranquil_pds::plc::ServiceType::Pds.as_str(),
"serviceEndpoint": service_endpoint
}]
}))
.into_response()
Ok(vec![json!({
"id": format!("{}#atproto", did),
"type": "Multikey",
"controller": did,
"publicKeyMultibase": public_key_multibase
})])
}
#[derive(Debug, thiserror::Error)]
@@ -562,12 +470,13 @@ pub struct AtprotoPds {
pub async fn get_recommended_did_credentials(
State(state): State<AppState>,
auth: Auth<NotTakendown>,
) -> Result<Response, ApiError> {
) -> Result<Json<GetRecommendedDidCredentialsOutput>, ApiError> {
let handle = state
.user_repo
.repos
.user
.get_handle_by_did(&auth.did)
.await
.map_err(|_| ApiError::InternalError(None))?
.log_db_err("fetching handle for DID credentials")?
.ok_or(ApiError::InternalError(None))?;
let key_bytes = auth.key_bytes.clone().ok_or_else(|| {
@@ -593,21 +502,17 @@ pub async fn get_recommended_did_credentials(
};
vec![server_rotation_key]
};
Ok((
StatusCode::OK,
Json(GetRecommendedDidCredentialsOutput {
rotation_keys,
also_known_as: vec![format!("at://{}", handle)],
verification_methods: VerificationMethods { atproto: did_key },
services: Services {
atproto_pds: AtprotoPds {
service_type: tranquil_pds::plc::ServiceType::Pds.as_str().to_string(),
endpoint: pds_endpoint,
},
Ok(Json(GetRecommendedDidCredentialsOutput {
rotation_keys,
also_known_as: vec![format!("at://{}", handle)],
verification_methods: VerificationMethods { atproto: did_key },
services: Services {
atproto_pds: AtprotoPds {
service_type: tranquil_pds::plc::ServiceType::Pds.as_str().to_string(),
endpoint: pds_endpoint,
},
}),
)
.into_response())
},
}))
}
#[derive(Deserialize)]
@@ -619,14 +524,12 @@ pub async fn update_handle(
State(state): State<AppState>,
auth: Auth<NotTakendown>,
Json(input): Json<UpdateHandleInput>,
) -> Result<Response, ApiError> {
if let Err(e) = tranquil_pds::auth::scope_check::check_identity_scope(
) -> Result<Json<EmptyResponse>, ApiError> {
tranquil_pds::auth::scope_check::check_identity_scope(
&auth.auth_source,
auth.scope.as_deref(),
tranquil_pds::oauth::scopes::IdentityAttr::Handle,
) {
return Ok(e);
}
)?;
let did = auth.did.clone();
let _rate_limit = check_user_rate_limit_with_message::<HandleUpdateLimit>(
&state,
@@ -641,10 +544,11 @@ pub async fn update_handle(
)
.await?;
let user_row = state
.user_repo
.repos
.user
.get_id_and_handle_by_did(&did)
.await
.map_err(|_| ApiError::InternalError(None))?
.log_db_err("fetching user for handle update")?
.ok_or(ApiError::InternalError(None))?;
let user_id = user_row.id;
let current_handle = user_row.handle;
@@ -710,7 +614,7 @@ pub async fn update_handle(
{
warn!("Failed to sequence identity event for handle update: {}", e);
}
return Ok(EmptyResponse::ok().into_response());
return Ok(Json(EmptyResponse {}));
}
if short_part.contains('.') {
return Err(ApiError::InvalidHandle(Some(
@@ -736,7 +640,7 @@ pub async fn update_handle(
{
warn!("Failed to sequence identity event for handle update: {}", e);
}
return Ok(EmptyResponse::ok().into_response());
return Ok(Json(EmptyResponse {}));
}
match tranquil_pds::handle::verify_handle_ownership(&new_handle, &did).await {
Ok(()) => {}
@@ -763,15 +667,17 @@ pub async fn update_handle(
.parse()
.map_err(|_| ApiError::InvalidHandle(Some("Invalid handle format".into())))?;
let handle_exists = state
.user_repo
.repos
.user
.check_handle_exists(&handle_typed, user_id)
.await
.map_err(|_| ApiError::InternalError(None))?;
.log_db_err("checking handle existence")?;
if handle_exists {
return Err(ApiError::HandleTaken);
}
state
.user_repo
.repos
.user
.update_handle(user_id, &handle_typed)
.await
.map_err(|e| {
@@ -797,7 +703,7 @@ pub async fn update_handle(
if let Err(e) = update_plc_handle(&state, &did, &handle_typed).await {
warn!("Failed to update PLC handle: {}", e);
}
Ok(EmptyResponse::ok().into_response())
Ok(Json(EmptyResponse {}))
}
pub async fn update_plc_handle(
@@ -808,7 +714,7 @@ pub async fn update_plc_handle(
if !did.as_str().starts_with("did:plc:") {
return Ok(());
}
let user_row = match state.user_repo.get_user_with_key_by_did(did).await? {
let user_row = match state.repos.user.get_user_with_key_by_did(did).await? {
Some(r) => r,
None => return Ok(()),
};
@@ -835,7 +741,7 @@ pub async fn well_known_atproto_did(State(state): State<AppState>, headers: Head
Ok(h) => h,
Err(_) => return (StatusCode::BAD_REQUEST, "Invalid handle format").into_response(),
};
let user = state.user_repo.get_by_handle(&handle).await;
let user = state.repos.user.get_by_handle(&handle).await;
match user {
Ok(Some(row)) => row.did.to_string().into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Handle not found").into_response(),
+12 -15
View File
@@ -1,7 +1,4 @@
use axum::{
extract::State,
response::{IntoResponse, Response},
};
use axum::{Json, extract::State};
use chrono::{Duration, Utc};
use tracing::{info, warn};
use tranquil_pds::api::EmptyResponse;
@@ -16,34 +13,34 @@ fn generate_plc_token() -> String {
pub async fn request_plc_operation_signature(
State(state): State<AppState>,
auth: Auth<Permissive>,
) -> Result<Response, ApiError> {
if let Err(e) = tranquil_pds::auth::scope_check::check_identity_scope(
) -> Result<Json<EmptyResponse>, ApiError> {
tranquil_pds::auth::scope_check::check_identity_scope(
&auth.auth_source,
auth.scope.as_deref(),
tranquil_pds::oauth::scopes::IdentityAttr::Wildcard,
) {
return Ok(e);
}
)?;
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(&auth.did)
.await
.log_db_err("fetching user id")?
.ok_or(ApiError::AccountNotFound)?;
let _ = state.infra_repo.delete_plc_tokens_for_user(user_id).await;
let _ = state.repos.infra.delete_plc_tokens_for_user(user_id).await;
let plc_token = generate_plc_token();
let expires_at = Utc::now() + Duration::minutes(10);
state
.infra_repo
.repos
.infra
.insert_plc_token(user_id, &plc_token, expires_at)
.await
.log_db_err("creating PLC token")?;
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_plc_operation(
state.user_repo.as_ref(),
state.infra_repo.as_ref(),
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
&plc_token,
hostname,
@@ -53,5 +50,5 @@ pub async fn request_plc_operation_signature(
warn!("Failed to enqueue PLC operation notification: {:?}", e);
}
info!("PLC operation signature requested for user {}", auth.did);
Ok(EmptyResponse::ok().into_response())
Ok(Json(EmptyResponse {}))
}
+15 -23
View File
@@ -1,9 +1,4 @@
use axum::{
Json,
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
use axum::{Json, extract::State};
use chrono::Utc;
use k256::ecdsa::SigningKey;
use serde::{Deserialize, Serialize};
@@ -43,14 +38,12 @@ pub async fn sign_plc_operation(
State(state): State<AppState>,
auth: Auth<Permissive>,
Json(input): Json<SignPlcOperationInput>,
) -> Result<Response, ApiError> {
if let Err(e) = tranquil_pds::auth::scope_check::check_identity_scope(
) -> Result<Json<SignPlcOperationOutput>, ApiError> {
tranquil_pds::auth::scope_check::check_identity_scope(
&auth.auth_source,
auth.scope.as_deref(),
tranquil_pds::oauth::scopes::IdentityAttr::Wildcard,
) {
return Ok(e);
}
)?;
let did = &auth.did;
if did.starts_with("did:web:") {
return Err(ApiError::InvalidRequest(
@@ -62,25 +55,28 @@ pub async fn sign_plc_operation(
})?;
let user_id = state
.user_repo
.repos
.user
.get_id_by_did(did)
.await
.log_db_err("fetching user id")?
.ok_or(ApiError::AccountNotFound)?;
let token_expiry = state
.infra_repo
.repos
.infra
.get_plc_token_expiry(user_id, token)
.await
.log_db_err("fetching PLC token expiry")?
.ok_or_else(|| ApiError::InvalidToken(Some("Invalid or expired token".into())))?;
if Utc::now() > token_expiry {
let _ = state.infra_repo.delete_plc_token(user_id, token).await;
let _ = state.repos.infra.delete_plc_token(user_id, token).await;
return Err(ApiError::ExpiredToken(Some("Token has expired".into())));
}
let key_row = state
.user_repo
.repos
.user
.get_user_key_by_id(user_id)
.await
.log_db_err("fetching user key")?
@@ -143,13 +139,9 @@ pub async fn sign_plc_operation(
ApiError::InternalError(None)
})?;
let _ = state.infra_repo.delete_plc_token(user_id, token).await;
let _ = state.repos.infra.delete_plc_token(user_id, token).await;
info!("Signed PLC operation for user {}", did);
Ok((
StatusCode::OK,
Json(SignPlcOperationOutput {
operation: signed_op,
}),
)
.into_response())
Ok(Json(SignPlcOperationOutput {
operation: signed_op,
}))
}
+22 -25
View File
@@ -1,8 +1,4 @@
use axum::{
Json,
extract::State,
response::{IntoResponse, Response},
};
use axum::{Json, extract::State};
use k256::ecdsa::SigningKey;
use serde::Deserialize;
use serde_json::Value;
@@ -23,14 +19,12 @@ pub async fn submit_plc_operation(
State(state): State<AppState>,
auth: Auth<Permissive>,
Json(input): Json<SubmitPlcOperationInput>,
) -> Result<Response, ApiError> {
if let Err(e) = tranquil_pds::auth::scope_check::check_identity_scope(
) -> Result<Json<EmptyResponse>, ApiError> {
tranquil_pds::auth::scope_check::check_identity_scope(
&auth.auth_source,
auth.scope.as_deref(),
tranquil_pds::oauth::scopes::IdentityAttr::Wildcard,
) {
return Ok(e);
}
)?;
let did = &auth.did;
if did.starts_with("did:web:") {
return Err(ApiError::InvalidRequest(
@@ -44,14 +38,16 @@ pub async fn submit_plc_operation(
let hostname = &tranquil_config::get().server.hostname;
let public_url = format!("https://{}", hostname);
let user = state
.user_repo
.repos
.user
.get_id_and_handle_by_did(did)
.await
.log_db_err("fetching user")?
.ok_or(ApiError::AccountNotFound)?;
let key_row = state
.user_repo
.repos
.user
.get_user_key_by_id(user.id)
.await
.log_db_err("fetching user key")?
@@ -76,7 +72,7 @@ pub async fn submit_plc_operation(
.plc_rotation_key
.clone()
.unwrap_or_else(|| user_did_key.clone());
if let Some(rotation_keys) = op.get("rotationKeys").and_then(|v| v.as_array()) {
if let Some(rotation_keys) = op.get("rotationKeys").and_then(Value::as_array) {
let has_server_key = rotation_keys
.iter()
.any(|k| k.as_str() == Some(&server_rotation_key));
@@ -86,11 +82,11 @@ pub async fn submit_plc_operation(
));
}
}
if let Some(services) = op.get("services").and_then(|v| v.as_object())
&& let Some(pds) = services.get("atproto_pds").and_then(|v| v.as_object())
if let Some(services) = op.get("services").and_then(Value::as_object)
&& let Some(pds) = services.get("atproto_pds").and_then(Value::as_object)
{
let service_type = pds.get("type").and_then(|v| v.as_str());
let endpoint = pds.get("endpoint").and_then(|v| v.as_str());
let service_type = pds.get("type").and_then(Value::as_str);
let endpoint = pds.get("endpoint").and_then(Value::as_str);
if service_type != Some(tranquil_pds::plc::ServiceType::Pds.as_str()) {
return Err(ApiError::InvalidRequest(
"Incorrect type on atproto_pds service".into(),
@@ -102,8 +98,8 @@ pub async fn submit_plc_operation(
));
}
}
if let Some(verification_methods) = op.get("verificationMethods").and_then(|v| v.as_object())
&& let Some(atproto_key) = verification_methods.get("atproto").and_then(|v| v.as_str())
if let Some(verification_methods) = op.get("verificationMethods").and_then(Value::as_object)
&& let Some(atproto_key) = verification_methods.get("atproto").and_then(Value::as_str)
&& atproto_key != user_did_key
{
return Err(ApiError::InvalidRequest(
@@ -111,11 +107,11 @@ pub async fn submit_plc_operation(
));
}
if let Some(also_known_as) = (!user.handle.is_empty())
.then(|| op.get("alsoKnownAs").and_then(|v| v.as_array()))
.then(|| op.get("alsoKnownAs").and_then(Value::as_array))
.flatten()
{
let expected_handle = format!("at://{}", user.handle);
let first_aka = also_known_as.first().and_then(|v| v.as_str());
let first_aka = also_known_as.first().and_then(Value::as_str);
if first_aka != Some(&expected_handle) {
return Err(ApiError::InvalidRequest(
"Incorrect handle in alsoKnownAs".into(),
@@ -134,12 +130,13 @@ pub async fn submit_plc_operation(
.map_err(ApiError::from)?;
match state
.repo_repo
.repos
.repo
.insert_identity_event(did, Some(&user.handle))
.await
{
Ok(seq) => {
if let Err(e) = state.repo_repo.notify_update(seq).await {
if let Err(e) = state.repos.repo.notify_update(seq).await {
warn!("Failed to notify identity event: {:?}", e);
}
}
@@ -159,9 +156,9 @@ pub async fn submit_plc_operation(
.cache
.delete(&tranquil_pds::cache_keys::plc_data_key(did))
.await;
if state.did_resolver.refresh_did(did).await.is_none() {
if state.did_resolver.refresh_did(did).await.is_err() {
warn!(did = %did, "Failed to refresh DID cache after PLC update");
}
info!(did = %did, "PLC operation submitted successfully");
Ok(EmptyResponse::ok().into_response())
Ok(Json(EmptyResponse {}))
}
+242 -1
View File
@@ -2,10 +2,11 @@ use jacquard_common::types::{integer::LimitedU32, string::Tid};
use jacquard_repo::{mst::Mst, storage::BlockStore};
use k256::ecdsa::SigningKey;
use std::sync::Arc;
use tranquil_db_traits::CommsChannel;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::repo_ops::create_signed_commit;
use tranquil_pds::state::AppState;
use tranquil_pds::types::Did;
use tranquil_pds::types::{Did, Handle};
pub struct PlcDidResult {
pub did: Did,
@@ -74,6 +75,7 @@ pub async fn submit_plc_genesis(
Ok(genesis_result.did)
}
#[derive(Clone)]
pub struct GenesisRepo {
pub encrypted_key_bytes: Vec<u8>,
pub commit_cid: cid::Cid,
@@ -120,3 +122,242 @@ pub async fn init_genesis_repo(
genesis_block_cids: vec![mst_root.to_bytes(), commit_cid.to_bytes()],
})
}
pub struct SigningKeyResult {
pub secret_key_bytes: Vec<u8>,
pub signing_key: SigningKey,
pub reserved_key_id: Option<uuid::Uuid>,
}
pub async fn resolve_signing_key(
state: &AppState,
signing_key_did: Option<&str>,
) -> Result<SigningKeyResult, ApiError> {
match signing_key_did {
Some(key_did) => {
let key = state
.repos
.infra
.get_reserved_signing_key(key_did)
.await
.map_err(|e| {
tracing::error!("Error looking up reserved signing key: {:?}", e);
ApiError::InternalError(None)
})?
.ok_or(ApiError::InvalidSigningKey)?;
let signing_key = SigningKey::from_slice(&key.private_key_bytes).map_err(|e| {
tracing::error!("Error creating signing key: {:?}", e);
ApiError::InternalError(None)
})?;
Ok(SigningKeyResult {
secret_key_bytes: key.private_key_bytes,
signing_key,
reserved_key_id: Some(key.id),
})
}
None => {
use k256::SecretKey;
use rand::rngs::OsRng;
let secret_key = SecretKey::random(&mut OsRng);
let secret_key_bytes = secret_key.to_bytes().to_vec();
let signing_key = SigningKey::from_slice(&secret_key_bytes).map_err(|e| {
tracing::error!("Error creating signing key: {:?}", e);
ApiError::InternalError(None)
})?;
Ok(SigningKeyResult {
secret_key_bytes,
signing_key,
reserved_key_id: None,
})
}
}
}
pub async fn sequence_new_account(
state: &AppState,
did: &Did,
handle: &Handle,
repo: &GenesisRepo,
display_name: &str,
) {
if let Err(e) = tranquil_pds::repo_ops::sequence_identity_event(state, did, Some(handle)).await
{
tracing::warn!("Failed to sequence identity event for {}: {}", did, e);
}
if let Err(e) = tranquil_pds::repo_ops::sequence_account_event(
state,
did,
tranquil_db_traits::AccountStatus::Active,
)
.await
{
tracing::warn!("Failed to sequence account event for {}: {}", did, e);
}
if let Err(e) = tranquil_pds::repo_ops::sequence_genesis_commit(
state,
did,
&repo.commit_cid,
&repo.mst_root_cid,
&repo.repo_rev,
)
.await
{
tracing::warn!("Failed to sequence commit event for {}: {}", did, e);
}
if let Err(e) = tranquil_pds::repo_ops::sequence_sync_event(
state,
did,
&repo.commit_cid.to_string(),
Some(&repo.repo_rev),
)
.await
{
tracing::warn!("Failed to sequence sync event for {}: {}", did, e);
}
let profile_record = serde_json::json!({
"$type": "app.bsky.actor.profile",
"displayName": display_name
});
if let Err(e) = tranquil_pds::repo_ops::create_record_internal(
state,
did,
&tranquil_pds::types::PROFILE_COLLECTION,
&tranquil_pds::types::PROFILE_RKEY,
&profile_record,
)
.await
{
tracing::warn!("Failed to create default profile for {}: {}", did, e);
}
}
pub struct CommsUsernames {
pub discord: Option<String>,
pub telegram: Option<String>,
pub signal: Option<String>,
}
pub fn normalize_comms_usernames(
discord: Option<&str>,
telegram: Option<&str>,
signal: Option<&str>,
) -> CommsUsernames {
CommsUsernames {
discord: discord
.map(|s| s.trim().to_lowercase())
.filter(|s| !s.is_empty()),
telegram: telegram
.map(|s| s.trim().trim_start_matches('@'))
.filter(|s| !s.is_empty())
.map(String::from),
signal: signal
.map(|s| s.trim().trim_start_matches('@'))
.filter(|s| !s.is_empty())
.map(|s| s.to_lowercase()),
}
}
pub struct SessionResult {
pub access_jwt: String,
pub refresh_jwt: String,
}
pub async fn create_and_store_session(
state: &AppState,
did_str: &str,
did: &Did,
signing_key_bytes: &[u8],
scope: &str,
controller_did: Option<&Did>,
) -> Result<SessionResult, ApiError> {
let access_meta =
tranquil_pds::auth::create_access_token_with_metadata(did_str, signing_key_bytes).map_err(
|e| {
tracing::error!("Error creating access token: {:?}", e);
ApiError::InternalError(None)
},
)?;
let refresh_meta =
tranquil_pds::auth::create_refresh_token_with_metadata(did_str, signing_key_bytes)
.map_err(|e| {
tracing::error!("Error creating refresh token: {:?}", e);
ApiError::InternalError(None)
})?;
let session_data = tranquil_db_traits::SessionTokenCreate {
did: did.clone(),
access_jti: access_meta.jti.clone(),
refresh_jti: refresh_meta.jti.clone(),
access_expires_at: access_meta.expires_at,
refresh_expires_at: refresh_meta.expires_at,
login_type: tranquil_db_traits::LoginType::Modern,
mfa_verified: false,
scope: Some(scope.to_string()),
controller_did: controller_did.cloned(),
app_password_name: None,
};
state
.repos
.session
.create_session(&session_data)
.await
.map_err(|e| {
tracing::error!("Error creating session: {:?}", e);
ApiError::InternalError(None)
})?;
Ok(SessionResult {
access_jwt: access_meta.token,
refresh_jwt: refresh_meta.token,
})
}
pub async fn enqueue_signup_verification(
state: &AppState,
user_id: uuid::Uuid,
did: &Did,
channel: CommsChannel,
recipient: &str,
) {
let token =
tranquil_pds::auth::verification_token::generate_signup_token(did, channel, recipient);
let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token);
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_signup_verification(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
channel,
recipient,
&formatted,
hostname,
)
.await
{
tracing::warn!("Failed to enqueue signup verification: {:?}", e);
}
}
pub async fn enqueue_migration_verification(
state: &AppState,
user_id: uuid::Uuid,
did: &Did,
channel: CommsChannel,
recipient: &str,
) {
let token =
tranquil_pds::auth::verification_token::generate_migration_token(did, channel, recipient);
let formatted = tranquil_pds::auth::verification_token::format_token_for_display(&token);
let hostname = &tranquil_config::get().server.hostname;
if let Err(e) = tranquil_pds::comms::comms_repo::enqueue_migration_verification(
state.repos.user.as_ref(),
state.repos.infra.as_ref(),
user_id,
channel,
recipient,
&formatted,
hostname,
)
.await
{
tracing::warn!("Failed to enqueue migration verification: {:?}", e);
}
}
+8 -6
View File
@@ -1,6 +1,7 @@
pub mod actor;
pub mod admin;
pub mod age_assurance;
pub mod common;
pub mod delegation;
pub mod discord_webhook;
pub mod identity;
@@ -10,7 +11,6 @@ pub mod repo;
pub mod server;
pub mod telegram_webhook;
pub mod temp;
pub mod verification;
use tranquil_pds::state::AppState;
@@ -215,10 +215,6 @@ pub fn api_routes() -> axum::Router<AppState> {
"/_account.checkEmailInUse",
post(server::check_email_in_use),
)
.route(
"/_account.checkCommsChannelInUse",
post(server::check_comms_channel_in_use),
)
.route(
"/com.atproto.server.reserveSigningKey",
post(server::reserve_signing_key),
@@ -334,6 +330,12 @@ pub fn api_routes() -> axum::Router<AppState> {
get(admin::get_invite_codes),
)
.route("/_admin.getServerStats", get(admin::get_server_stats))
.route("/_admin.getSignalStatus", get(admin::get_signal_status))
.route("/_admin.linkSignalDevice", post(admin::link_signal_device))
.route(
"/_admin.unlinkSignalDevice",
post(admin::unlink_signal_device),
)
.route("/_server.getConfig", get(admin::get_server_config))
.route(
"/_admin.updateServerConfig",
@@ -390,7 +392,7 @@ pub fn api_routes() -> axum::Router<AppState> {
)
.route(
"/_account.confirmChannelVerification",
post(verification::confirm_channel_verification),
post(server::confirm_channel_verification),
)
.route("/_account.verifyToken", post(server::verify_token))
.route(

Some files were not shown because too many files have changed in this diff Show More