I've been saying "PDSes seem easy enough, they're what, some CRUD to a db? I can do that in my sleep". well i'm sleeping rn so let's go

Backups, adversarial migrations

Changed files
+7563 -2872
.sqlx
frontend
migrations
scripts
src
tests
+28
.sqlx/query-017b04caf42b30f2c8f9468acf61a83244b7c2fa5cacfaee41a946a6af5ef68e.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, backup_enabled FROM users WHERE did = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "backup_enabled", 14 + "type_info": "Bool" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Text" 20 + ] 21 + }, 22 + "nullable": [ 23 + false, 24 + false 25 + ] 26 + }, 27 + "hash": "017b04caf42b30f2c8f9468acf61a83244b7c2fa5cacfaee41a946a6af5ef68e" 28 + }
-16
.sqlx/query-0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text", 9 - "Timestamptz", 10 - "Text" 11 - ] 12 - }, 13 - "nullable": [] 14 - }, 15 - "hash": "0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe" 16 - }
+52
.sqlx/query-2728a7c672f95349b0406acfca24addfbc039379331142e3a7d78597f622382c.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev\n FROM users u\n JOIN repos r ON r.user_id = u.id\n WHERE u.did = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "backup_enabled", 19 + "type_info": "Bool" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "deactivated_at", 24 + "type_info": "Timestamptz" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "repo_root_cid", 29 + "type_info": "Text" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "repo_rev", 34 + "type_info": "Text" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + "Text" 40 + ] 41 + }, 42 + "nullable": [ 43 + false, 44 + false, 45 + false, 46 + true, 47 + false, 48 + true 49 + ] 50 + }, 51 + "hash": "2728a7c672f95349b0406acfca24addfbc039379331142e3a7d78597f622382c" 52 + }
-14
.sqlx/query-603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1", 4 - "describe": { 5 - "columns": [], 6 - "parameters": { 7 - "Left": [ 8 - "Text" 9 - ] 10 - }, 11 - "nullable": [] 12 - }, 13 - "hash": "603711611d2100957c67bb18485f03eecf54a5f2865fe2e40b251ab6d6c64cd1" 14 - }
+22
.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "email_verified", 9 + "type_info": "Bool" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Text" 15 + ] 16 + }, 17 + "nullable": [ 18 + false 19 + ] 20 + }, 21 + "hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4" 22 + }
+7 -7
.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json .sqlx/query-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1", 3 + "query": "SELECT id, handle, deactivated_at FROM users WHERE did = $1", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 10 10 }, 11 11 { 12 12 "ordinal": 1, 13 - "name": "migrated_to_pds", 13 + "name": "handle", 14 14 "type_info": "Text" 15 15 }, 16 16 { 17 17 "ordinal": 2, 18 - "name": "handle", 19 - "type_info": "Text" 18 + "name": "deactivated_at", 19 + "type_info": "Timestamptz" 20 20 } 21 21 ], 22 22 "parameters": { ··· 26 26 }, 27 27 "nullable": [ 28 28 false, 29 - true, 30 - false 29 + false, 30 + true 31 31 ] 32 32 }, 33 - "hash": "63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073" 33 + "hash": "e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7" 34 34 }
+2 -2
.sqlx/query-6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115.json .sqlx/query-ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf.json
··· 1 1 { 2 2 "db_name": "PostgreSQL", 3 - "query": "\n SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = rb.repo_id\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3\n ", 3 + "query": "\n SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3\n ", 4 4 "describe": { 5 5 "columns": [ 6 6 { ··· 26 26 false 27 27 ] 28 28 }, 29 - "hash": "6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115" 29 + "hash": "ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf" 30 30 }
+35
.sqlx/query-72a5e8d9f678caf2e6c03e43d78203941645529a4d0ccf18f1abf477cde6ed8d.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT ab.id, ab.storage_key, u.deactivated_at\n FROM account_backups ab\n JOIN users u ON u.id = ab.user_id\n WHERE ab.id = $1 AND u.did = $2\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "storage_key", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "deactivated_at", 19 + "type_info": "Timestamptz" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Uuid", 25 + "Text" 26 + ] 27 + }, 28 + "nullable": [ 29 + false, 30 + false, 31 + true 32 + ] 33 + }, 34 + "hash": "72a5e8d9f678caf2e6c03e43d78203941645529a4d0ccf18f1abf477cde6ed8d" 35 + }
-34
.sqlx/query-791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1", 4 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "did", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "migrated_to_pds", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "migrated_at", 19 - "type_info": "Timestamptz" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Text" 25 - ] 26 - }, 27 - "nullable": [ 28 - false, 29 - true, 30 - true 31 - ] 32 - }, 33 - "hash": "791d0d6ea6fbedc3e51fb8b1fda7bfe756f2631daf688e6646705725342ad67e" 34 - }
+19
.sqlx/query-7a05733a51eb9989d2aba807ab1806d67e3fbf8219d06edec7840fda89bf222c.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)\n VALUES ($1, $2, $3, $4, $5, $6)\n ", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid", 9 + "Text", 10 + "Text", 11 + "Text", 12 + "Int4", 13 + "Int8" 14 + ] 15 + }, 16 + "nullable": [] 17 + }, 18 + "hash": "7a05733a51eb9989d2aba807ab1806d67e3fbf8219d06edec7840fda89bf222c" 19 + }
+29
.sqlx/query-95d38301fed0592dc309b0d7d08559deab0c25965b41025eec6a2bced5dd5f0f.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT ab.storage_key, ab.repo_rev\n FROM account_backups ab\n JOIN users u ON u.id = ab.user_id\n WHERE ab.id = $1 AND u.did = $2\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "storage_key", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "repo_rev", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid", 20 + "Text" 21 + ] 22 + }, 23 + "nullable": [ 24 + false, 25 + false 26 + ] 27 + }, 28 + "hash": "95d38301fed0592dc309b0d7d08559deab0c25965b41025eec6a2bced5dd5f0f" 29 + }
+29
.sqlx/query-a36a237358f5dc502bb09258074139a5aef77adb0f6d58ffc5e998acbc00f144.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, storage_key\n FROM account_backups\n WHERE user_id = $1\n ORDER BY created_at DESC\n OFFSET $2\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "storage_key", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid", 20 + "Int8" 21 + ] 22 + }, 23 + "nullable": [ 24 + false, 25 + false 26 + ] 27 + }, 28 + "hash": "a36a237358f5dc502bb09258074139a5aef77adb0f6d58ffc5e998acbc00f144" 29 + }
+52
.sqlx/query-b4fb4ae0fb94168ee7144ea249e75bedc6d4fb54f09b3df2ce10903d4f04dfc4.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT id, repo_rev, repo_root_cid, block_count, size_bytes, created_at\n FROM account_backups\n WHERE user_id = $1\n ORDER BY created_at DESC\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "repo_rev", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "repo_root_cid", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "block_count", 24 + "type_info": "Int4" 25 + }, 26 + { 27 + "ordinal": 4, 28 + "name": "size_bytes", 29 + "type_info": "Int8" 30 + }, 31 + { 32 + "ordinal": 5, 33 + "name": "created_at", 34 + "type_info": "Timestamptz" 35 + } 36 + ], 37 + "parameters": { 38 + "Left": [ 39 + "Uuid" 40 + ] 41 + }, 42 + "nullable": [ 43 + false, 44 + false, 45 + false, 46 + false, 47 + false, 48 + false 49 + ] 50 + }, 51 + "hash": "b4fb4ae0fb94168ee7144ea249e75bedc6d4fb54f09b3df2ce10903d4f04dfc4" 52 + }
+40
.sqlx/query-d6d533b728887666b2a9ad2d2f9e6b173131842bb9b5f9068175397fd30a50ab.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT u.id as user_id, u.did, r.repo_root_cid, r.repo_rev\n FROM users u\n JOIN repos r ON r.user_id = u.id\n WHERE u.backup_enabled = true\n AND u.deactivated_at IS NULL\n AND (\n NOT EXISTS (\n SELECT 1 FROM account_backups ab WHERE ab.user_id = u.id\n )\n OR (\n SELECT MAX(ab.created_at) FROM account_backups ab WHERE ab.user_id = u.id\n ) < NOW() - make_interval(secs => $1)\n )\n LIMIT 50\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "user_id", 9 + "type_info": "Uuid" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "did", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "repo_root_cid", 19 + "type_info": "Text" 20 + }, 21 + { 22 + "ordinal": 3, 23 + "name": "repo_rev", 24 + "type_info": "Text" 25 + } 26 + ], 27 + "parameters": { 28 + "Left": [ 29 + "Float8" 30 + ] 31 + }, 32 + "nullable": [ 33 + false, 34 + false, 35 + false, 36 + true 37 + ] 38 + }, 39 + "hash": "d6d533b728887666b2a9ad2d2f9e6b173131842bb9b5f9068175397fd30a50ab" 40 + }
+34
.sqlx/query-f405fc944c383ab9f50b805da3e4bf302e40698beac5b06d3d19abd185de21c1.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n SELECT DISTINCT b.cid, b.storage_key, b.mime_type\n FROM blobs b\n JOIN record_blobs rb ON rb.blob_cid = b.cid\n WHERE rb.repo_id = $1\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "cid", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "storage_key", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "mime_type", 19 + "type_info": "Text" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Uuid" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + false, 30 + false 31 + ] 32 + }, 33 + "hash": "f405fc944c383ab9f50b805da3e4bf302e40698beac5b06d3d19abd185de21c1" 34 + }
+27
.sqlx/query-f6a7ab9916e50ee74e5ff41af4d7cc1b24f3ed740dc61b21d485ab6535037183.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "\n INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "id", 9 + "type_info": "Uuid" 10 + } 11 + ], 12 + "parameters": { 13 + "Left": [ 14 + "Uuid", 15 + "Text", 16 + "Text", 17 + "Text", 18 + "Int4", 19 + "Int8" 20 + ] 21 + }, 22 + "nullable": [ 23 + false 24 + ] 25 + }, 26 + "hash": "f6a7ab9916e50ee74e5ff41af4d7cc1b24f3ed740dc61b21d485ab6535037183" 27 + }
+15
.sqlx/query-f71428b1ce982504cd531937131d49196ec092b4d13e9ae7dcdaedfe98de5a70.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "UPDATE users SET backup_enabled = $1 WHERE did = $2", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Bool", 9 + "Text" 10 + ] 11 + }, 12 + "nullable": [] 13 + }, 14 + "hash": "f71428b1ce982504cd531937131d49196ec092b4d13e9ae7dcdaedfe98de5a70" 15 + }
+14
.sqlx/query-f85f8d49bbd2d5e048bd8c29081aef5b8097e2384793e85df72eeeb858b7c532.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "DELETE FROM account_backups WHERE id = $1", 4 + "describe": { 5 + "columns": [], 6 + "parameters": { 7 + "Left": [ 8 + "Uuid" 9 + ] 10 + }, 11 + "nullable": [] 12 + }, 13 + "hash": "f85f8d49bbd2d5e048bd8c29081aef5b8097e2384793e85df72eeeb858b7c532" 14 + }
+64
Cargo.lock
··· 111 111 checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" 112 112 113 113 [[package]] 114 + name = "arbitrary" 115 + version = "1.4.2" 116 + source = "registry+https://github.com/rust-lang/crates.io-index" 117 + checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" 118 + dependencies = [ 119 + "derive_arbitrary", 120 + ] 121 + 122 + [[package]] 114 123 name = "arc-swap" 115 124 version = "1.7.1" 116 125 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1621 1630 ] 1622 1631 1623 1632 [[package]] 1633 + name = "derive_arbitrary" 1634 + version = "1.4.2" 1635 + source = "registry+https://github.com/rust-lang/crates.io-index" 1636 + checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" 1637 + dependencies = [ 1638 + "proc-macro2", 1639 + "quote", 1640 + "syn 2.0.111", 1641 + ] 1642 + 1643 + [[package]] 1624 1644 name = "derive_more" 1625 1645 version = "1.0.0" 1626 1646 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1973 1993 checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" 1974 1994 dependencies = [ 1975 1995 "crc32fast", 1996 + "libz-rs-sys", 1976 1997 "miniz_oxide", 1977 1998 ] 1978 1999 ··· 3457 3478 dependencies = [ 3458 3479 "pkg-config", 3459 3480 "vcpkg", 3481 + ] 3482 + 3483 + [[package]] 3484 + name = "libz-rs-sys" 3485 + version = "0.5.5" 3486 + source = "registry+https://github.com/rust-lang/crates.io-index" 3487 + checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" 3488 + dependencies = [ 3489 + "zlib-rs", 3460 3490 ] 3461 3491 3462 3492 [[package]] ··· 6286 6316 "ed25519-dalek", 6287 6317 "futures", 6288 6318 "governor", 6319 + "hex", 6289 6320 "hickory-resolver", 6290 6321 "hkdf", 6291 6322 "hmac", ··· 6329 6360 "webauthn-rs", 6330 6361 "webauthn-rs-proto", 6331 6362 "wiremock", 6363 + "zip", 6332 6364 ] 6333 6365 6334 6366 [[package]] ··· 7289 7321 "proc-macro2", 7290 7322 "quote", 7291 7323 "syn 2.0.111", 7324 + ] 7325 + 7326 + [[package]] 7327 + name = "zip" 7328 + version = "7.0.0" 7329 + source = "registry+https://github.com/rust-lang/crates.io-index" 7330 + checksum = "bdd8a47718a4ee5fe78e07667cd36f3de80e7c2bfe727c7074245ffc7303c037" 7331 + dependencies = [ 7332 + "arbitrary", 7333 + "crc32fast", 7334 + "flate2", 7335 + "indexmap 2.12.1", 7336 + "memchr", 7337 + "zopfli", 7338 + ] 7339 + 7340 + [[package]] 7341 + name = "zlib-rs" 7342 + version = "0.5.5" 7343 + source = "registry+https://github.com/rust-lang/crates.io-index" 7344 + checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" 7345 + 7346 + [[package]] 7347 + name = "zopfli" 7348 + version = "0.8.3" 7349 + source = "registry+https://github.com/rust-lang/crates.io-index" 7350 + checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" 7351 + dependencies = [ 7352 + "bumpalo", 7353 + "crc32fast", 7354 + "log", 7355 + "simd-adler32", 7292 7356 ] 7293 7357 7294 7358 [[package]]
+2
Cargo.toml
··· 19 19 dotenvy = "0.15.7" 20 20 futures = "0.3.30" 21 21 governor = "0.10" 22 + hex = "0.4" 22 23 hkdf = "0.12" 23 24 hmac = "0.12" 24 25 aes-gcm = "0.10" ··· 62 63 totp-rs = { version = "5", features = ["qr"] } 63 64 webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 64 65 webauthn-rs-proto = "0.5.4" 66 + zip = { version = "7.0.0", default-features = false, features = ["deflate"] } 65 67 [features] 66 68 external-infra = [] 67 69 [dev-dependencies]
+1 -1
README.md
··· 14 14 15 15 This software isn't an afterthought by a company with limited resources. 16 16 17 - It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. 17 + It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), automatic backups to s3-compatible object storage (configurable retention and frequency, one-click restore), and a built-in web UI for account management, OAuth consent, repo browsing, and admin. 18 18 19 19 The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor. 20 20
+2 -15
TODO.md
··· 2 2 3 3 ## Active development 4 4 5 - ### Migration tool 6 - Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states. 7 - 8 - - [x] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow 9 - - [x] Support `createAccount` with existing DID + service auth token 10 - - [x] Progress tracking with resume capability 11 - - [ ] Scheduled automatic backups (CAR export) 12 - - [ ] One-click restore from backup 13 - 14 - Outbound migration wizard exists but is disabled. Rethinking the approach: instead of a managed flow with `migratingTo` state, pds-hosted did:web users should just have direct control over their DID document. They can independently update serviceEndpoint, add/remove keys, export their repo, deactivate their account. 15 - 16 - - [ ] Remove `migratingTo` field and related state machine 17 - - [ ] Let did:web users edit their DID doc fields (serviceEndpoint, keys) whenever 18 - - [ ] Repo export as standalone feature, not tied to migration wizard 19 - 20 5 ### Plugin system 21 6 Extensible architecture allowing third-party plugins to add functionality. Going with wasm-based rather than scripting language. 22 7 ··· 69 54 App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords. 70 55 71 56 Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices. 57 + 58 + Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management.
+94
frontend/deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 + "npm:@atcute/cbor@^2.2.8": "2.2.8", 5 + "npm:@atcute/crypto@^2.3.0": "2.3.0", 6 + "npm:@atcute/did-plc@~0.3.1": "0.3.1", 7 + "npm:@atcute/multibase@^1.1.6": "1.1.6", 4 8 "npm:@noble/secp256k1@^2.1.0": "2.3.0", 5 9 "npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3", 6 10 "npm:@testing-library/jest-dom@^6.6.3": "6.9.1", ··· 30 34 "lru-cache" 31 35 ] 32 36 }, 37 + "@atcute/cbor@2.2.8": { 38 + "integrity": "sha512-UzOAN9BuN6JCXgn0ryV8qZuRJUDrNqrbLd6EFM8jc6RYssjRyGRxNy6RZ1NU/07Hd8Tq/0pz8+nQiMu5Zai5uw==", 39 + "dependencies": [ 40 + "@atcute/cid", 41 + "@atcute/multibase", 42 + "@atcute/uint8array" 43 + ] 44 + }, 45 + "@atcute/cid@2.3.0": { 46 + "integrity": "sha512-1SRdkTuMs/l5arQ+7Ag0F7JAueZqtzYE0d2gmbkuzi8EPweNU1kYlQs0CE4dSd81YF8PMDTOQty0K2ATq9CW9g==", 47 + "dependencies": [ 48 + "@atcute/multibase", 49 + "@atcute/uint8array" 50 + ] 51 + }, 52 + "@atcute/crypto@2.3.0": { 53 + "integrity": "sha512-w5pkJKCjbNMQu+F4JRHbR3ROQyhi1wbn+GSC6WDQamcYHkZmEZk1/eoI354bIQOOfkEM6aFLv718iskrkon4GQ==", 54 + "dependencies": [ 55 + "@atcute/multibase", 56 + "@atcute/uint8array", 57 + "@noble/secp256k1@3.0.0" 58 + ] 59 + }, 60 + "@atcute/did-plc@0.3.1": { 61 + "integrity": "sha512-KsuVdRtaaIPMmlcCDcxZzLg6OWm7rajczquhIHfA3s57+c34PFQbdY4Lsc2BvDwZ0fUjmbwzvQI3Zio2VcZa7w==", 62 + "dependencies": [ 63 + "@atcute/cbor", 64 + "@atcute/cid", 65 + "@atcute/crypto", 66 + "@atcute/identity", 67 + "@atcute/lexicons", 68 + "@atcute/multibase", 69 + "@atcute/uint8array", 70 + "@atcute/util-fetch", 71 + "@badrap/valita" 72 + ] 73 + }, 74 + "@atcute/identity@1.1.3": { 75 + "integrity": "sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==", 76 + "dependencies": [ 77 + "@atcute/lexicons", 78 + "@badrap/valita" 79 + ] 80 + }, 81 + "@atcute/lexicons@1.2.6": { 82 + "integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==", 83 + "dependencies": [ 84 + "@atcute/uint8array", 85 + "@atcute/util-text", 86 + "@standard-schema/spec", 87 + "esm-env" 88 + ] 89 + }, 90 + "@atcute/multibase@1.1.6": { 91 + "integrity": "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==", 92 + "dependencies": [ 93 + "@atcute/uint8array" 94 + ] 95 + }, 96 + "@atcute/uint8array@1.0.6": { 97 + "integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==" 98 + }, 99 + "@atcute/util-fetch@1.0.4": { 100 + "integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==", 101 + "dependencies": [ 102 + "@badrap/valita" 103 + ] 104 + }, 105 + "@atcute/util-text@0.0.1": { 106 + "integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==", 107 + "dependencies": [ 108 + "unicode-segmenter" 109 + ] 110 + }, 33 111 "@babel/code-frame@7.27.1": { 34 112 "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", 35 113 "dependencies": [ ··· 43 121 }, 44 122 "@babel/runtime@7.28.4": { 45 123 "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==" 124 + }, 125 + "@badrap/valita@0.4.6": { 126 + "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==" 46 127 }, 47 128 "@csstools/color-helpers@5.1.0": { 48 129 "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==" ··· 498 579 "@noble/secp256k1@2.3.0": { 499 580 "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==" 500 581 }, 582 + "@noble/secp256k1@3.0.0": { 583 + "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==" 584 + }, 501 585 "@rollup/rollup-android-arm-eabi@4.53.3": { 502 586 "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", 503 587 "os": ["android"], ··· 607 691 "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", 608 692 "os": ["win32"], 609 693 "cpu": ["x64"] 694 + }, 695 + "@standard-schema/spec@1.1.0": { 696 + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" 610 697 }, 611 698 "@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": { 612 699 "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", ··· 1545 1632 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1546 1633 "bin": true 1547 1634 }, 1635 + "unicode-segmenter@0.14.4": { 1636 + "integrity": "sha512-pR5VCiCrLrKOL6FRW61jnk9+wyMtKKowq+jyFY9oc6uHbWKhDL4yVRiI4YZPksGMK72Pahh8m0cn/0JvbDDyJg==" 1637 + }, 1548 1638 "vite-node@2.1.9": { 1549 1639 "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", 1550 1640 "dependencies": [ ··· 1671 1761 "workspace": { 1672 1762 "packageJson": { 1673 1763 "dependencies": [ 1764 + "npm:@atcute/cbor@^2.2.8", 1765 + "npm:@atcute/crypto@^2.3.0", 1766 + "npm:@atcute/did-plc@~0.3.1", 1767 + "npm:@atcute/multibase@^1.1.6", 1674 1768 "npm:@noble/secp256k1@^2.1.0", 1675 1769 "npm:@sveltejs/vite-plugin-svelte@5", 1676 1770 "npm:@testing-library/jest-dom@^6.6.3",
+4
frontend/package.json
··· 12 12 "test:coverage": "vitest run --coverage" 13 13 }, 14 14 "dependencies": { 15 + "@atcute/cbor": "^2.2.8", 16 + "@atcute/crypto": "^2.3.0", 17 + "@atcute/did-plc": "^0.3.1", 18 + "@atcute/multibase": "^1.1.6", 15 19 "@noble/secp256k1": "^2.1.0", 16 20 "multiformats": "^13.3.1", 17 21 "svelte-i18n": "^4.0.1"
+2 -2
frontend/src/components/ReauthModal.svelte
··· 228 228 /> 229 229 </div> 230 230 <button type="submit" class="btn-primary" disabled={loading || !password}> 231 - {loading ? $_('reauth.verifying') : $_('reauth.verify')} 231 + {loading ? $_('common.verifying') : $_('common.verify')} 232 232 </button> 233 233 </form> 234 234 {:else if activeMethod === 'totp'} ··· 247 247 /> 248 248 </div> 249 249 <button type="submit" class="btn-primary" disabled={loading || !totpCode}> 250 - {loading ? $_('reauth.verifying') : $_('reauth.verify')} 250 + {loading ? $_('common.verifying') : $_('common.verify')} 251 251 </button> 252 252 </form> 253 253 {:else if activeMethod === 'passkey'}
+86
frontend/src/components/migration/AppPasswordStep.svelte
··· 1 + <script lang="ts"> 2 + import { _ } from '../../lib/i18n' 3 + 4 + interface Props { 5 + appPassword: string 6 + appPasswordName: string 7 + loading: boolean 8 + onContinue: () => void 9 + } 10 + 11 + let { 12 + appPassword, 13 + appPasswordName, 14 + loading, 15 + onContinue, 16 + }: Props = $props() 17 + 18 + let copied = $state(false) 19 + let acknowledged = $state(false) 20 + 21 + function copyPassword() { 22 + navigator.clipboard.writeText(appPassword) 23 + copied = true 24 + } 25 + </script> 26 + 27 + <div class="step-content"> 28 + <h2>{$_('migration.inbound.appPassword.title')}</h2> 29 + <p>{$_('migration.inbound.appPassword.desc')}</p> 30 + 31 + <div class="warning-box"> 32 + <strong>{$_('migration.inbound.appPassword.warning')}</strong> 33 + </div> 34 + 35 + <div class="app-password-display"> 36 + <div class="app-password-label"> 37 + {$_('migration.inbound.appPassword.label')}: <strong>{appPasswordName}</strong> 38 + </div> 39 + <code class="app-password-code">{appPassword}</code> 40 + <button type="button" class="copy-btn" onclick={copyPassword}> 41 + {copied ? $_('common.copied') : $_('common.copyToClipboard')} 42 + </button> 43 + </div> 44 + 45 + <label class="checkbox-label"> 46 + <input type="checkbox" bind:checked={acknowledged} /> 47 + <span>{$_('migration.inbound.appPassword.saved')}</span> 48 + </label> 49 + 50 + <div class="button-row"> 51 + <button onclick={onContinue} disabled={!acknowledged || loading}> 52 + {loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')} 53 + </button> 54 + </div> 55 + </div> 56 + 57 + <style> 58 + .app-password-display { 59 + background: var(--bg-card); 60 + border: 2px solid var(--accent); 61 + border-radius: var(--radius-xl); 62 + padding: var(--space-6); 63 + text-align: center; 64 + margin: var(--space-4) 0; 65 + } 66 + .app-password-label { 67 + font-size: var(--text-sm); 68 + color: var(--text-secondary); 69 + margin-bottom: var(--space-4); 70 + } 71 + .app-password-code { 72 + display: block; 73 + font-size: var(--text-xl); 74 + font-family: ui-monospace, monospace; 75 + letter-spacing: 0.1em; 76 + padding: var(--space-5); 77 + background: var(--bg-input); 78 + border-radius: var(--radius-md); 79 + margin-bottom: var(--space-4); 80 + user-select: all; 81 + } 82 + .copy-btn { 83 + padding: var(--space-3) var(--space-5); 84 + font-size: var(--text-sm); 85 + } 86 + </style>
+185
frontend/src/components/migration/ChooseHandleStep.svelte
··· 1 + <script lang="ts"> 2 + import type { AuthMethod, ServerDescription } from '../../lib/migration/types' 3 + import { _ } from '../../lib/i18n' 4 + 5 + interface Props { 6 + handleInput: string 7 + selectedDomain: string 8 + handleAvailable: boolean | null 9 + checkingHandle: boolean 10 + email: string 11 + password: string 12 + authMethod: AuthMethod 13 + inviteCode: string 14 + serverInfo: ServerDescription | null 15 + migratingFromLabel: string 16 + migratingFromValue: string 17 + loading?: boolean 18 + onHandleChange: (handle: string) => void 19 + onDomainChange: (domain: string) => void 20 + onCheckHandle: () => void 21 + onEmailChange: (email: string) => void 22 + onPasswordChange: (password: string) => void 23 + onAuthMethodChange: (method: AuthMethod) => void 24 + onInviteCodeChange: (code: string) => void 25 + onBack: () => void 26 + onContinue: () => void 27 + } 28 + 29 + let { 30 + handleInput, 31 + selectedDomain, 32 + handleAvailable, 33 + checkingHandle, 34 + email, 35 + password, 36 + authMethod, 37 + inviteCode, 38 + serverInfo, 39 + migratingFromLabel, 40 + migratingFromValue, 41 + loading = false, 42 + onHandleChange, 43 + onDomainChange, 44 + onCheckHandle, 45 + onEmailChange, 46 + onPasswordChange, 47 + onAuthMethodChange, 48 + onInviteCodeChange, 49 + onBack, 50 + onContinue, 51 + }: Props = $props() 52 + 53 + const canContinue = $derived( 54 + handleInput.trim() && 55 + email && 56 + (authMethod === 'passkey' || password) && 57 + handleAvailable !== false 58 + ) 59 + </script> 60 + 61 + <div class="step-content"> 62 + <h2>{$_('migration.inbound.chooseHandle.title')}</h2> 63 + <p>{$_('migration.inbound.chooseHandle.desc')}</p> 64 + 65 + <div class="current-info"> 66 + <span class="label">{migratingFromLabel}:</span> 67 + <span class="value">{migratingFromValue}</span> 68 + </div> 69 + 70 + <div class="field"> 71 + <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label> 72 + <div class="handle-input-group"> 73 + <input 74 + id="new-handle" 75 + type="text" 76 + placeholder="username" 77 + value={handleInput} 78 + oninput={(e) => onHandleChange((e.target as HTMLInputElement).value)} 79 + onblur={onCheckHandle} 80 + /> 81 + {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 82 + <select value={selectedDomain} onchange={(e) => onDomainChange((e.target as HTMLSelectElement).value)}> 83 + {#each serverInfo.availableUserDomains as domain} 84 + <option value={domain}>.{domain}</option> 85 + {/each} 86 + </select> 87 + {/if} 88 + </div> 89 + 90 + {#if checkingHandle} 91 + <p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p> 92 + {:else if handleAvailable === true} 93 + <p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p> 94 + {:else if handleAvailable === false} 95 + <p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p> 96 + {:else} 97 + <p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p> 98 + {/if} 99 + </div> 100 + 101 + <div class="field"> 102 + <label for="email">{$_('migration.inbound.chooseHandle.email')}</label> 103 + <input 104 + id="email" 105 + type="email" 106 + placeholder="you@example.com" 107 + value={email} 108 + oninput={(e) => onEmailChange((e.target as HTMLInputElement).value)} 109 + required 110 + /> 111 + </div> 112 + 113 + <div class="field"> 114 + <label>{$_('migration.inbound.chooseHandle.authMethod')}</label> 115 + <div class="auth-method-options"> 116 + <label class="auth-option" class:selected={authMethod === 'password'}> 117 + <input 118 + type="radio" 119 + name="auth-method" 120 + value="password" 121 + checked={authMethod === 'password'} 122 + onchange={() => onAuthMethodChange('password')} 123 + /> 124 + <div class="auth-option-content"> 125 + <strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong> 126 + <span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span> 127 + </div> 128 + </label> 129 + <label class="auth-option" class:selected={authMethod === 'passkey'}> 130 + <input 131 + type="radio" 132 + name="auth-method" 133 + value="passkey" 134 + checked={authMethod === 'passkey'} 135 + onchange={() => onAuthMethodChange('passkey')} 136 + /> 137 + <div class="auth-option-content"> 138 + <strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong> 139 + <span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span> 140 + </div> 141 + </label> 142 + </div> 143 + </div> 144 + 145 + {#if authMethod === 'password'} 146 + <div class="field"> 147 + <label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label> 148 + <input 149 + id="new-password" 150 + type="password" 151 + placeholder="Password for your new account" 152 + value={password} 153 + oninput={(e) => onPasswordChange((e.target as HTMLInputElement).value)} 154 + required 155 + minlength={8} 156 + /> 157 + <p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p> 158 + </div> 159 + {:else} 160 + <div class="info-box"> 161 + <p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p> 162 + </div> 163 + {/if} 164 + 165 + {#if serverInfo?.inviteCodeRequired} 166 + <div class="field"> 167 + <label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label> 168 + <input 169 + id="invite" 170 + type="text" 171 + placeholder="Enter invite code" 172 + value={inviteCode} 173 + oninput={(e) => onInviteCodeChange((e.target as HTMLInputElement).value)} 174 + required 175 + /> 176 + </div> 177 + {/if} 178 + 179 + <div class="button-row"> 180 + <button class="ghost" onclick={onBack} disabled={loading}>{$_('migration.inbound.common.back')}</button> 181 + <button disabled={!canContinue || loading} onclick={onContinue}> 182 + {$_('migration.inbound.common.continue')} 183 + </button> 184 + </div> 185 + </div>
+64
frontend/src/components/migration/EmailVerifyStep.svelte
··· 1 + <script lang="ts"> 2 + import { _ } from '../../lib/i18n' 3 + 4 + interface Props { 5 + email: string 6 + token: string 7 + loading: boolean 8 + error: string | null 9 + onTokenChange: (token: string) => void 10 + onSubmit: (e: Event) => void 11 + onResend: () => void 12 + } 13 + 14 + let { 15 + email, 16 + token, 17 + loading, 18 + error, 19 + onTokenChange, 20 + onSubmit, 21 + onResend, 22 + }: Props = $props() 23 + </script> 24 + 25 + <div class="step-content"> 26 + <h2>{$_('migration.inbound.emailVerify.title')}</h2> 27 + <p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${email}</strong>` } })}</p> 28 + 29 + <div class="info-box"> 30 + <p> 31 + {$_('migration.inbound.emailVerify.hint')} 32 + </p> 33 + </div> 34 + 35 + {#if error} 36 + <div class="message error"> 37 + {error} 38 + </div> 39 + {/if} 40 + 41 + <form onsubmit={onSubmit}> 42 + <div class="field"> 43 + <label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label> 44 + <input 45 + id="email-verify-token" 46 + type="text" 47 + placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')} 48 + value={token} 49 + oninput={(e) => onTokenChange((e.target as HTMLInputElement).value)} 50 + disabled={loading} 51 + required 52 + /> 53 + </div> 54 + 55 + <div class="button-row"> 56 + <button type="button" class="ghost" onclick={onResend} disabled={loading}> 57 + {$_('migration.inbound.emailVerify.resend')} 58 + </button> 59 + <button type="submit" disabled={loading || !token}> 60 + {loading ? $_('common.verifying') : $_('common.verify')} 61 + </button> 62 + </div> 63 + </form> 64 + </div>
+23
frontend/src/components/migration/ErrorStep.svelte
··· 1 + <script lang="ts"> 2 + import { _ } from '../../lib/i18n' 3 + 4 + interface Props { 5 + error: string | null 6 + onStartOver: () => void 7 + } 8 + 9 + let { error, onStartOver }: Props = $props() 10 + </script> 11 + 12 + <div class="step-content"> 13 + <h2>{$_('migration.inbound.error.title')}</h2> 14 + <p>{$_('migration.inbound.error.desc')}</p> 15 + 16 + <div class="message error"> 17 + {error || $_('migration.inbound.error.unknown')} 18 + </div> 19 + 20 + <div class="button-row"> 21 + <button class="ghost" onclick={onStartOver}>{$_('migration.inbound.error.startOver')}</button> 22 + </div> 23 + </div>
+64 -306
frontend/src/components/migration/InboundWizard.svelte
··· 5 5 import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client' 6 6 import { _ } from '../../lib/i18n' 7 7 import '../../styles/migration.css' 8 + import ErrorStep from './ErrorStep.svelte' 9 + import SuccessStep from './SuccessStep.svelte' 10 + import ChooseHandleStep from './ChooseHandleStep.svelte' 11 + import EmailVerifyStep from './EmailVerifyStep.svelte' 12 + import PasskeySetupStep from './PasskeySetupStep.svelte' 13 + import AppPasswordStep from './AppPasswordStep.svelte' 8 14 9 15 interface ResumeInfo { 10 - direction: 'inbound' | 'outbound' 16 + direction: 'inbound' 11 17 sourceHandle: string 12 18 targetHandle: string 13 19 sourcePdsUrl: string ··· 37 43 let checkingHandle = $state(false) 38 44 let selectedAuthMethod = $state<AuthMethod>('password') 39 45 let passkeyName = $state('') 40 - let appPasswordCopied = $state(false) 41 - let appPasswordAcknowledged = $state(false) 42 46 43 47 const isResuming = $derived(flow.state.needsReauth === true) 44 48 const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:")) ··· 234 238 } 235 239 } 236 240 237 - function copyAppPassword() { 238 - if (flow.state.generatedAppPassword) { 239 - navigator.clipboard.writeText(flow.state.generatedAppPassword) 240 - appPasswordCopied = true 241 - } 242 - } 243 - 244 241 async function handleProceedFromAppPassword() { 245 242 loading = true 246 243 try { ··· 352 349 </label> 353 350 354 351 <div class="button-row"> 355 - <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 356 - <button disabled={!understood} onclick={() => flow.setStep('source-handle')}> 352 + <button type="button" class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 353 + <button type="button" disabled={!understood} onclick={() => flow.setStep('source-handle')}> 357 354 {$_('migration.inbound.common.continue')} 358 355 </button> 359 356 </div> ··· 409 406 </div> 410 407 411 408 {:else if flow.state.step === 'choose-handle'} 412 - <div class="step-content"> 413 - <h2>{$_('migration.inbound.chooseHandle.title')}</h2> 414 - <p>{$_('migration.inbound.chooseHandle.desc')}</p> 415 - 416 - <div class="current-info"> 417 - <span class="label">{$_('migration.inbound.chooseHandle.migratingFrom')}:</span> 418 - <span class="value">{flow.state.sourceHandle}</span> 419 - </div> 420 - 421 - <div class="field"> 422 - <label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label> 423 - <div class="handle-input-group"> 424 - <input 425 - id="new-handle" 426 - type="text" 427 - placeholder="username" 428 - bind:value={handleInput} 429 - onblur={checkHandle} 430 - /> 431 - {#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 432 - <select bind:value={selectedDomain}> 433 - {#each serverInfo.availableUserDomains as domain} 434 - <option value={domain}>.{domain}</option> 435 - {/each} 436 - </select> 437 - {/if} 438 - </div> 439 - 440 - {#if checkingHandle} 441 - <p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p> 442 - {:else if handleAvailable === true} 443 - <p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p> 444 - {:else if handleAvailable === false} 445 - <p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p> 446 - {:else} 447 - <p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p> 448 - {/if} 449 - </div> 450 - 451 - <div class="field"> 452 - <label for="email">{$_('migration.inbound.chooseHandle.email')}</label> 453 - <input 454 - id="email" 455 - type="email" 456 - placeholder="you@example.com" 457 - bind:value={flow.state.targetEmail} 458 - oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)} 459 - required 460 - /> 461 - </div> 462 - 463 - <div class="field"> 464 - <label>{$_('migration.inbound.chooseHandle.authMethod')}</label> 465 - <div class="auth-method-options"> 466 - <label class="auth-option" class:selected={selectedAuthMethod === 'password'}> 467 - <input 468 - type="radio" 469 - name="auth-method" 470 - value="password" 471 - bind:group={selectedAuthMethod} 472 - /> 473 - <div class="auth-option-content"> 474 - <strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong> 475 - <span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span> 476 - </div> 477 - </label> 478 - <label class="auth-option" class:selected={selectedAuthMethod === 'passkey'}> 479 - <input 480 - type="radio" 481 - name="auth-method" 482 - value="passkey" 483 - bind:group={selectedAuthMethod} 484 - /> 485 - <div class="auth-option-content"> 486 - <strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong> 487 - <span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span> 488 - </div> 489 - </label> 490 - </div> 491 - </div> 492 - 493 - {#if selectedAuthMethod === 'password'} 494 - <div class="field"> 495 - <label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label> 496 - <input 497 - id="new-password" 498 - type="password" 499 - placeholder="Password for your new account" 500 - bind:value={flow.state.targetPassword} 501 - oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} 502 - required 503 - minlength="8" 504 - /> 505 - <p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p> 506 - </div> 507 - {:else} 508 - <div class="info-box"> 509 - <p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p> 510 - </div> 511 - {/if} 512 - 513 - {#if serverInfo?.inviteCodeRequired} 514 - <div class="field"> 515 - <label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label> 516 - <input 517 - id="invite" 518 - type="text" 519 - placeholder="Enter invite code" 520 - bind:value={flow.state.inviteCode} 521 - oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 522 - required 523 - /> 524 - </div> 525 - {/if} 526 - 527 - <div class="button-row"> 528 - <button class="ghost" onclick={() => flow.setStep('source-handle')}>{$_('migration.inbound.common.back')}</button> 529 - <button 530 - disabled={!handleInput.trim() || !flow.state.targetEmail || (selectedAuthMethod === 'password' && !flow.state.targetPassword) || handleAvailable === false} 531 - onclick={proceedToReviewWithAuth} 532 - > 533 - {$_('migration.inbound.common.continue')} 534 - </button> 535 - </div> 536 - </div> 409 + <ChooseHandleStep 410 + {handleInput} 411 + {selectedDomain} 412 + {handleAvailable} 413 + {checkingHandle} 414 + email={flow.state.targetEmail} 415 + password={flow.state.targetPassword} 416 + authMethod={selectedAuthMethod} 417 + inviteCode={flow.state.inviteCode} 418 + {serverInfo} 419 + migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')} 420 + migratingFromValue={flow.state.sourceHandle} 421 + {loading} 422 + onHandleChange={(h) => handleInput = h} 423 + onDomainChange={(d) => selectedDomain = d} 424 + onCheckHandle={checkHandle} 425 + onEmailChange={(e) => flow.updateField('targetEmail', e)} 426 + onPasswordChange={(p) => flow.updateField('targetPassword', p)} 427 + onAuthMethodChange={(m) => selectedAuthMethod = m} 428 + onInviteCodeChange={(c) => flow.updateField('inviteCode', c)} 429 + onBack={() => flow.setStep('source-handle')} 430 + onContinue={proceedToReviewWithAuth} 431 + /> 537 432 538 433 {:else if flow.state.step === 'review'} 539 434 <div class="step-content"> ··· 620 515 </div> 621 516 622 517 {:else if flow.state.step === 'passkey-setup'} 623 - <div class="step-content"> 624 - <h2>{$_('migration.inbound.passkeySetup.title')}</h2> 625 - <p>{$_('migration.inbound.passkeySetup.desc')}</p> 626 - 627 - {#if flow.state.error} 628 - <div class="message error"> 629 - {flow.state.error} 630 - </div> 631 - {/if} 632 - 633 - <div class="field"> 634 - <label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label> 635 - <input 636 - id="passkey-name" 637 - type="text" 638 - placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')} 639 - bind:value={passkeyName} 640 - disabled={loading} 641 - /> 642 - <p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p> 643 - </div> 644 - 645 - <div class="passkey-section"> 646 - <p>{$_('migration.inbound.passkeySetup.instructions')}</p> 647 - <button class="primary" onclick={registerPasskey} disabled={loading}> 648 - {loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')} 649 - </button> 650 - </div> 651 - </div> 518 + <PasskeySetupStep 519 + {passkeyName} 520 + {loading} 521 + error={flow.state.error} 522 + onPasskeyNameChange={(n) => passkeyName = n} 523 + onRegister={registerPasskey} 524 + /> 652 525 653 526 {:else if flow.state.step === 'app-password'} 654 - <div class="step-content"> 655 - <h2>{$_('migration.inbound.appPassword.title')}</h2> 656 - <p>{$_('migration.inbound.appPassword.desc')}</p> 657 - 658 - <div class="warning-box"> 659 - <strong>{$_('migration.inbound.appPassword.warning')}</strong> 660 - </div> 661 - 662 - <div class="app-password-display"> 663 - <div class="app-password-label"> 664 - {$_('migration.inbound.appPassword.label')}: <strong>{flow.state.generatedAppPasswordName}</strong> 665 - </div> 666 - <code class="app-password-code">{flow.state.generatedAppPassword}</code> 667 - <button type="button" class="copy-btn" onclick={copyAppPassword}> 668 - {appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')} 669 - </button> 670 - </div> 671 - 672 - <label class="checkbox-label"> 673 - <input type="checkbox" bind:checked={appPasswordAcknowledged} /> 674 - <span>{$_('migration.inbound.appPassword.saved')}</span> 675 - </label> 676 - 677 - <div class="button-row"> 678 - <button onclick={handleProceedFromAppPassword} disabled={!appPasswordAcknowledged || loading}> 679 - {loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')} 680 - </button> 681 - </div> 682 - </div> 527 + <AppPasswordStep 528 + appPassword={flow.state.generatedAppPassword || ''} 529 + appPasswordName={flow.state.generatedAppPasswordName || ''} 530 + {loading} 531 + onContinue={handleProceedFromAppPassword} 532 + /> 683 533 684 534 {:else if flow.state.step === 'email-verify'} 685 - <div class="step-content"> 686 - <h2>{$_('migration.inbound.emailVerify.title')}</h2> 687 - <p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${flow.state.targetEmail}</strong>` } })}</p> 688 - 689 - <div class="info-box"> 690 - <p> 691 - {$_('migration.inbound.emailVerify.hint')} 692 - </p> 693 - </div> 694 - 695 - {#if flow.state.error} 696 - <div class="message error"> 697 - {flow.state.error} 698 - </div> 699 - {/if} 700 - 701 - <form onsubmit={submitEmailVerify}> 702 - <div class="field"> 703 - <label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label> 704 - <input 705 - id="email-verify-token" 706 - type="text" 707 - placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')} 708 - bind:value={flow.state.emailVerifyToken} 709 - oninput={(e) => flow.updateField('emailVerifyToken', (e.target as HTMLInputElement).value)} 710 - disabled={loading} 711 - required 712 - /> 713 - </div> 714 - 715 - <div class="button-row"> 716 - <button type="button" class="ghost" onclick={resendEmailVerify} disabled={loading}> 717 - {$_('migration.inbound.emailVerify.resend')} 718 - </button> 719 - <button type="submit" disabled={loading || !flow.state.emailVerifyToken}> 720 - {loading ? $_('migration.inbound.emailVerify.verifying') : $_('migration.inbound.emailVerify.verify')} 721 - </button> 722 - </div> 723 - </form> 724 - </div> 535 + <EmailVerifyStep 536 + email={flow.state.targetEmail} 537 + token={flow.state.emailVerifyToken} 538 + {loading} 539 + error={flow.state.error} 540 + onTokenChange={(t) => flow.updateField('emailVerifyToken', t)} 541 + onSubmit={submitEmailVerify} 542 + onResend={resendEmailVerify} 543 + /> 725 544 726 545 {:else if flow.state.step === 'plc-token'} 727 546 <div class="step-content"> ··· 837 656 </div> 838 657 839 658 {:else if flow.state.step === 'success'} 840 - <div class="step-content success-content"> 841 - <div class="success-icon">✓</div> 842 - <h2>{$_('migration.inbound.success.title')}</h2> 843 - <p>{$_('migration.inbound.success.desc')}</p> 844 - 845 - <div class="success-details"> 846 - <div class="detail-row"> 847 - <span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span> 848 - <span class="value">{flow.state.targetHandle}</span> 849 - </div> 850 - <div class="detail-row"> 851 - <span class="label">{$_('migration.inbound.success.did')}:</span> 852 - <span class="value mono">{flow.state.sourceDid}</span> 853 - </div> 854 - </div> 855 - 856 - {#if flow.state.progress.blobsFailed.length > 0} 857 - <div class="message warning"> 858 - {$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })} 859 - </div> 860 - {/if} 861 - 862 - <p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p> 863 - </div> 659 + <SuccessStep handle={flow.state.targetHandle} did={flow.state.sourceDid}> 660 + {#snippet extraContent()} 661 + {#if flow.state.progress.blobsFailed.length > 0} 662 + <div class="message warning"> 663 + {$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })} 664 + </div> 665 + {/if} 666 + {/snippet} 667 + </SuccessStep> 864 668 865 669 {:else if flow.state.step === 'error'} 866 - <div class="step-content"> 867 - <h2>{$_('migration.inbound.error.title')}</h2> 868 - <p>{$_('migration.inbound.error.desc')}</p> 869 - 870 - <div class="message error"> 871 - {flow.state.error || 'An unknown error occurred. Please check the browser console for details.'} 872 - </div> 873 - 874 - <div class="button-row"> 875 - <button class="ghost" onclick={onBack}>{$_('migration.inbound.error.startOver')}</button> 876 - </div> 877 - </div> 670 + <ErrorStep error={flow.state.error} onStartOver={onBack} /> 878 671 {/if} 879 672 </div> 880 673 881 674 <style> 882 - .passkey-section { 883 - margin-top: 16px; 884 - } 885 - .passkey-section button { 886 - width: 100%; 887 - margin-top: 12px; 888 - } 889 - .app-password-display { 890 - background: var(--bg-card); 891 - border: 2px solid var(--accent); 892 - border-radius: var(--radius-xl); 893 - padding: var(--space-6); 894 - text-align: center; 895 - margin: var(--space-4) 0; 896 - } 897 - .app-password-label { 898 - font-size: var(--text-sm); 899 - color: var(--text-secondary); 900 - margin-bottom: var(--space-4); 901 - } 902 - .app-password-code { 903 - display: block; 904 - font-size: var(--text-xl); 905 - font-family: ui-monospace, monospace; 906 - letter-spacing: 0.1em; 907 - padding: var(--space-5); 908 - background: var(--bg-input); 909 - border-radius: var(--radius-md); 910 - margin-bottom: var(--space-4); 911 - user-select: all; 912 - } 913 - .copy-btn { 914 - padding: var(--space-3) var(--space-5); 915 - font-size: var(--text-sm); 916 - } 917 675 .resume-info { 918 676 margin-bottom: var(--space-5); 919 677 }
+591
frontend/src/components/migration/OfflineInboundWizard.svelte
··· 1 + <script lang="ts"> 2 + import type { OfflineInboundMigrationFlow } from '../../lib/migration' 3 + import type { AuthMethod, ServerDescription } from '../../lib/migration/types' 4 + import { getErrorMessage } from '../../lib/migration/types' 5 + import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client' 6 + import { _ } from '../../lib/i18n' 7 + import '../../styles/migration.css' 8 + import ErrorStep from './ErrorStep.svelte' 9 + import SuccessStep from './SuccessStep.svelte' 10 + import ChooseHandleStep from './ChooseHandleStep.svelte' 11 + import EmailVerifyStep from './EmailVerifyStep.svelte' 12 + import PasskeySetupStep from './PasskeySetupStep.svelte' 13 + import AppPasswordStep from './AppPasswordStep.svelte' 14 + 15 + interface Props { 16 + flow: OfflineInboundMigrationFlow 17 + onBack: () => void 18 + onComplete: () => void 19 + } 20 + 21 + let { flow, onBack, onComplete }: Props = $props() 22 + 23 + let serverInfo = $state<ServerDescription | null>(null) 24 + let loading = $state(false) 25 + let understood = $state(false) 26 + let handleInput = $state('') 27 + let selectedDomain = $state('') 28 + let handleAvailable = $state<boolean | null>(null) 29 + let checkingHandle = $state(false) 30 + let validatingKey = $state(false) 31 + let keyValid = $state<boolean | null>(null) 32 + let fileInputRef = $state<HTMLInputElement | null>(null) 33 + let selectedAuthMethod = $state<AuthMethod>('password') 34 + let passkeyName = $state('') 35 + 36 + let redirectTriggered = $state(false) 37 + 38 + $effect(() => { 39 + if (flow.state.step === 'welcome' || flow.state.step === 'choose-handle') { 40 + loadServerInfo() 41 + } 42 + if (flow.state.step === 'choose-handle') { 43 + handleInput = '' 44 + handleAvailable = null 45 + } 46 + }) 47 + 48 + $effect(() => { 49 + if (flow.state.step === 'success' && !redirectTriggered) { 50 + redirectTriggered = true 51 + setTimeout(() => { 52 + onComplete() 53 + }, 2000) 54 + } 55 + }) 56 + 57 + $effect(() => { 58 + if (flow.state.step === 'email-verify') { 59 + const interval = setInterval(async () => { 60 + if (flow.state.emailVerifyToken.trim()) return 61 + await flow.checkEmailVerifiedAndProceed() 62 + }, 3000) 63 + return () => clearInterval(interval) 64 + } 65 + }) 66 + 67 + async function loadServerInfo() { 68 + if (!serverInfo) { 69 + serverInfo = await flow.loadLocalServerInfo() 70 + if (serverInfo.availableUserDomains.length > 0) { 71 + selectedDomain = serverInfo.availableUserDomains[0] 72 + } 73 + } 74 + } 75 + 76 + function handleFileSelect(e: Event) { 77 + const input = e.target as HTMLInputElement 78 + const file = input.files?.[0] 79 + if (!file) return 80 + 81 + const reader = new FileReader() 82 + reader.onload = () => { 83 + const arrayBuffer = reader.result as ArrayBuffer 84 + flow.setCarFile(new Uint8Array(arrayBuffer), file.name) 85 + } 86 + reader.readAsArrayBuffer(file) 87 + } 88 + 89 + async function validateRotationKey() { 90 + if (!flow.state.rotationKey || !flow.state.userDid) return 91 + 92 + validatingKey = true 93 + keyValid = null 94 + 95 + try { 96 + const isValid = await flow.validateRotationKey() 97 + keyValid = isValid 98 + if (isValid) { 99 + flow.setStep('choose-handle') 100 + } 101 + } catch (err) { 102 + flow.setError(getErrorMessage(err)) 103 + keyValid = false 104 + } finally { 105 + validatingKey = false 106 + } 107 + } 108 + 109 + async function startMigration() { 110 + loading = true 111 + try { 112 + await flow.runMigration() 113 + } catch (err) { 114 + flow.setError(getErrorMessage(err)) 115 + } finally { 116 + loading = false 117 + } 118 + } 119 + 120 + const steps = $derived( 121 + flow.state.authMethod === 'passkey' 122 + ? ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Passkey', 'App Password', 'Complete'] 123 + : ['Enter DID', 'Upload CAR', 'Rotation Key', 'Handle', 'Review', 'Import', 'Blobs', 'Verify Email', 'Complete'] 124 + ) 125 + 126 + function getCurrentStepIndex(): number { 127 + const isPasskey = flow.state.authMethod === 'passkey' 128 + switch (flow.state.step) { 129 + case 'welcome': return 0 130 + case 'provide-did': return 0 131 + case 'upload-car': return 1 132 + case 'provide-rotation-key': return 2 133 + case 'choose-handle': return 3 134 + case 'review': return 4 135 + case 'creating': 136 + case 'importing': return 5 137 + case 'migrating-blobs': return 6 138 + case 'email-verify': return 7 139 + case 'passkey-setup': return isPasskey ? 8 : 7 140 + case 'app-password': return 9 141 + case 'plc-signing': 142 + case 'finalizing': return isPasskey ? 10 : 8 143 + case 'success': return isPasskey ? 10 : 8 144 + default: return 0 145 + } 146 + } 147 + 148 + async function checkHandle() { 149 + if (!handleInput.trim()) return 150 + 151 + const fullHandle = handleInput.includes('.') 152 + ? handleInput 153 + : `${handleInput}.${selectedDomain}` 154 + 155 + checkingHandle = true 156 + handleAvailable = null 157 + 158 + try { 159 + handleAvailable = await flow.checkHandleAvailability(fullHandle) 160 + } catch { 161 + handleAvailable = true 162 + } finally { 163 + checkingHandle = false 164 + } 165 + } 166 + 167 + function proceedToReview() { 168 + const fullHandle = handleInput.includes('.') 169 + ? handleInput 170 + : `${handleInput}.${selectedDomain}` 171 + 172 + flow.setTargetHandle(fullHandle) 173 + flow.setAuthMethod(selectedAuthMethod) 174 + flow.setStep('review') 175 + } 176 + 177 + async function submitEmailVerify(e: Event) { 178 + e.preventDefault() 179 + loading = true 180 + try { 181 + await flow.submitEmailVerifyToken(flow.state.emailVerifyToken) 182 + } catch (err) { 183 + flow.setError(getErrorMessage(err)) 184 + } finally { 185 + loading = false 186 + } 187 + } 188 + 189 + async function resendEmailVerify() { 190 + loading = true 191 + try { 192 + await flow.resendEmailVerification() 193 + flow.setError(null) 194 + } catch (err) { 195 + flow.setError(getErrorMessage(err)) 196 + } finally { 197 + loading = false 198 + } 199 + } 200 + 201 + async function registerPasskey() { 202 + loading = true 203 + flow.setError(null) 204 + 205 + try { 206 + if (!window.PublicKeyCredential) { 207 + throw new Error('Passkeys are not supported in this browser. Please use a modern browser with WebAuthn support.') 208 + } 209 + 210 + await flow.registerPasskey(passkeyName || undefined) 211 + } catch (err) { 212 + const message = getErrorMessage(err) 213 + if (message.includes('cancelled') || message.includes('AbortError')) { 214 + flow.setError('Passkey registration was cancelled. Please try again.') 215 + } else { 216 + flow.setError(message) 217 + } 218 + } finally { 219 + loading = false 220 + } 221 + } 222 + 223 + async function handleProceedFromAppPassword() { 224 + loading = true 225 + try { 226 + await flow.proceedFromAppPassword() 227 + } catch (err) { 228 + flow.setError(getErrorMessage(err)) 229 + } finally { 230 + loading = false 231 + } 232 + } 233 + </script> 234 + 235 + <div class="migration-wizard"> 236 + <div class="step-indicator"> 237 + {#each steps as _, i} 238 + <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 239 + <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 240 + </div> 241 + {#if i < steps.length - 1} 242 + <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 243 + {/if} 244 + {/each} 245 + </div> 246 + <div class="current-step-label"> 247 + <strong>{steps[getCurrentStepIndex()]}</strong> · Step {getCurrentStepIndex() + 1} of {steps.length} 248 + </div> 249 + 250 + {#if flow.state.error} 251 + <div class="message error">{flow.state.error}</div> 252 + {/if} 253 + 254 + {#if flow.state.step === 'welcome'} 255 + <div class="step-content"> 256 + <h2>{$_('migration.offline.welcome.title')}</h2> 257 + <p>{$_('migration.offline.welcome.desc')}</p> 258 + 259 + <div class="warning-box"> 260 + <strong>{$_('migration.offline.welcome.warningTitle')}</strong> 261 + <p>{$_('migration.offline.welcome.warningDesc')}</p> 262 + </div> 263 + 264 + <div class="info-box"> 265 + <h3>{$_('migration.offline.welcome.requirementsTitle')}</h3> 266 + <ul> 267 + <li>{$_('migration.offline.welcome.requirement1')}</li> 268 + <li>{$_('migration.offline.welcome.requirement2')}</li> 269 + <li>{$_('migration.offline.welcome.requirement3')}</li> 270 + </ul> 271 + </div> 272 + 273 + <label class="checkbox-label"> 274 + <input type="checkbox" bind:checked={understood} /> 275 + <span>{$_('migration.offline.welcome.understand')}</span> 276 + </label> 277 + 278 + <div class="button-row"> 279 + <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 280 + <button disabled={!understood} onclick={() => flow.setStep('provide-did')}> 281 + {$_('migration.inbound.common.continue')} 282 + </button> 283 + </div> 284 + </div> 285 + 286 + {:else if flow.state.step === 'provide-did'} 287 + <div class="step-content"> 288 + <h2>{$_('migration.offline.provideDid.title')}</h2> 289 + <p>{$_('migration.offline.provideDid.desc')}</p> 290 + 291 + <div class="field"> 292 + <label for="user-did">{$_('migration.offline.provideDid.label')}</label> 293 + <input 294 + id="user-did" 295 + type="text" 296 + placeholder="did:plc:abc123..." 297 + value={flow.state.userDid} 298 + oninput={(e) => flow.setUserDid((e.target as HTMLInputElement).value)} 299 + /> 300 + <p class="hint">{$_('migration.offline.provideDid.hint')}</p> 301 + </div> 302 + 303 + <div class="button-row"> 304 + <button class="ghost" onclick={() => flow.setStep('welcome')}>{$_('migration.inbound.common.back')}</button> 305 + <button disabled={!flow.state.userDid.startsWith('did:')} onclick={() => flow.setStep('upload-car')}> 306 + {$_('migration.inbound.common.continue')} 307 + </button> 308 + </div> 309 + </div> 310 + 311 + {:else if flow.state.step === 'upload-car'} 312 + <div class="step-content"> 313 + <h2>{$_('migration.offline.uploadCar.title')}</h2> 314 + <p>{$_('migration.offline.uploadCar.desc')}</p> 315 + 316 + {#if flow.state.carNeedsReupload} 317 + <div class="warning-box"> 318 + <strong>{$_('migration.offline.uploadCar.reuploadWarningTitle')}</strong> 319 + <p>{$_('migration.offline.uploadCar.reuploadWarning')}</p> 320 + {#if flow.state.carFileName} 321 + <p><strong>Previous file:</strong> {flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</p> 322 + {/if} 323 + </div> 324 + {/if} 325 + 326 + <div class="field"> 327 + <label for="car-file">{$_('migration.offline.uploadCar.label')}</label> 328 + <div class="file-input-container"> 329 + <input 330 + id="car-file" 331 + type="file" 332 + accept=".car" 333 + onchange={handleFileSelect} 334 + bind:this={fileInputRef} 335 + /> 336 + {#if flow.state.carFile && flow.state.carFileName} 337 + <div class="file-info"> 338 + <span class="file-name">{flow.state.carFileName}</span> 339 + <span class="file-size">({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span> 340 + </div> 341 + {/if} 342 + </div> 343 + <p class="hint">{$_('migration.offline.uploadCar.hint')}</p> 344 + </div> 345 + 346 + <div class="button-row"> 347 + <button class="ghost" onclick={() => flow.setStep('provide-did')}>{$_('migration.inbound.common.back')}</button> 348 + <button disabled={!flow.state.carFile} onclick={() => flow.setStep('provide-rotation-key')}> 349 + {$_('migration.inbound.common.continue')} 350 + </button> 351 + </div> 352 + </div> 353 + 354 + {:else if flow.state.step === 'provide-rotation-key'} 355 + <div class="step-content"> 356 + <h2>{$_('migration.offline.rotationKey.title')}</h2> 357 + <p>{$_('migration.offline.rotationKey.desc')}</p> 358 + 359 + <div class="warning-box"> 360 + <strong>{$_('migration.offline.rotationKey.securityWarningTitle')}</strong> 361 + <ul> 362 + <li>{$_('migration.offline.rotationKey.securityWarning1')}</li> 363 + <li>{$_('migration.offline.rotationKey.securityWarning2')}</li> 364 + <li>{$_('migration.offline.rotationKey.securityWarning3')}</li> 365 + </ul> 366 + </div> 367 + 368 + <div class="field"> 369 + <label for="rotation-key">{$_('migration.offline.rotationKey.label')}</label> 370 + <textarea 371 + id="rotation-key" 372 + rows={4} 373 + placeholder={$_('migration.offline.rotationKey.placeholder')} 374 + value={flow.state.rotationKey} 375 + oninput={(e) => { 376 + flow.setRotationKey((e.target as HTMLTextAreaElement).value) 377 + keyValid = null 378 + }} 379 + ></textarea> 380 + <p class="hint">{$_('migration.offline.rotationKey.hint')}</p> 381 + </div> 382 + 383 + {#if keyValid === true} 384 + <div class="message success">{$_('migration.offline.rotationKey.valid')}</div> 385 + {:else if keyValid === false} 386 + <div class="message error">{$_('migration.offline.rotationKey.invalid')}</div> 387 + {/if} 388 + 389 + <div class="button-row"> 390 + <button class="ghost" onclick={() => flow.setStep('upload-car')}>{$_('migration.inbound.common.back')}</button> 391 + <button 392 + disabled={!flow.state.rotationKey || validatingKey} 393 + onclick={validateRotationKey} 394 + > 395 + {validatingKey ? $_('migration.offline.rotationKey.validating') : $_('migration.offline.rotationKey.validate')} 396 + </button> 397 + </div> 398 + </div> 399 + 400 + {:else if flow.state.step === 'choose-handle'} 401 + <ChooseHandleStep 402 + {handleInput} 403 + {selectedDomain} 404 + {handleAvailable} 405 + {checkingHandle} 406 + email={flow.state.targetEmail} 407 + password={flow.state.targetPassword} 408 + authMethod={selectedAuthMethod} 409 + inviteCode={flow.state.inviteCode} 410 + {serverInfo} 411 + migratingFromLabel={$_('migration.offline.chooseHandle.migratingDid')} 412 + migratingFromValue={flow.state.userDid} 413 + {loading} 414 + onHandleChange={(h) => handleInput = h} 415 + onDomainChange={(d) => selectedDomain = d} 416 + onCheckHandle={checkHandle} 417 + onEmailChange={(e) => flow.setTargetEmail(e)} 418 + onPasswordChange={(p) => flow.setTargetPassword(p)} 419 + onAuthMethodChange={(m) => selectedAuthMethod = m} 420 + onInviteCodeChange={(c) => flow.setInviteCode(c)} 421 + onBack={() => flow.setStep('provide-rotation-key')} 422 + onContinue={proceedToReview} 423 + /> 424 + 425 + {:else if flow.state.step === 'review'} 426 + <div class="step-content"> 427 + <h2>{$_('migration.inbound.review.title')}</h2> 428 + <p>{$_('migration.offline.review.desc')}</p> 429 + 430 + <div class="review-card"> 431 + <div class="review-row"> 432 + <span class="label">{$_('migration.inbound.review.did')}:</span> 433 + <span class="value mono">{flow.state.userDid}</span> 434 + </div> 435 + <div class="review-row"> 436 + <span class="label">{$_('migration.inbound.review.newHandle')}:</span> 437 + <span class="value">{flow.state.targetHandle}</span> 438 + </div> 439 + <div class="review-row"> 440 + <span class="label">{$_('migration.offline.review.carFile')}:</span> 441 + <span class="value">{flow.state.carFileName} ({(flow.state.carSizeBytes / 1024 / 1024).toFixed(2)} MB)</span> 442 + </div> 443 + <div class="review-row"> 444 + <span class="label">{$_('migration.offline.review.rotationKey')}:</span> 445 + <span class="value mono">{flow.state.rotationKeyDidKey}</span> 446 + </div> 447 + <div class="review-row"> 448 + <span class="label">{$_('migration.inbound.review.targetPds')}:</span> 449 + <span class="value">{window.location.origin}</span> 450 + </div> 451 + <div class="review-row"> 452 + <span class="label">{$_('migration.inbound.review.email')}:</span> 453 + <span class="value">{flow.state.targetEmail}</span> 454 + </div> 455 + <div class="review-row"> 456 + <span class="label">{$_('migration.inbound.review.authentication')}:</span> 457 + <span class="value">{flow.state.authMethod === 'passkey' ? $_('migration.inbound.review.authPasskey') : $_('migration.inbound.review.authPassword')}</span> 458 + </div> 459 + </div> 460 + 461 + <div class="warning-box"> 462 + <strong>{$_('migration.offline.review.plcWarningTitle')}</strong> 463 + <p>{$_('migration.offline.review.plcWarning')}</p> 464 + </div> 465 + 466 + <div class="button-row"> 467 + <button class="ghost" onclick={() => flow.setStep('choose-handle')} disabled={loading}>{$_('migration.inbound.common.back')}</button> 468 + <button onclick={startMigration} disabled={loading}> 469 + {loading ? $_('migration.inbound.review.starting') : $_('migration.inbound.review.startMigration')} 470 + </button> 471 + </div> 472 + </div> 473 + 474 + {:else if flow.state.step === 'creating' || flow.state.step === 'importing'} 475 + <div class="step-content"> 476 + <h2>{$_('migration.offline.migrating.title')}</h2> 477 + <p>{$_('migration.offline.migrating.desc')}</p> 478 + 479 + <div class="progress-section"> 480 + <div class="progress-item" class:completed={flow.state.step !== 'creating'} class:active={flow.state.step === 'creating'}> 481 + <span class="icon">{flow.state.step !== 'creating' ? '✓' : '○'}</span> 482 + <span>{$_('migration.offline.migrating.creating')}</span> 483 + </div> 484 + <div class="progress-item" class:active={flow.state.step === 'importing'}> 485 + <span class="icon">○</span> 486 + <span>{$_('migration.offline.migrating.importing')}</span> 487 + </div> 488 + </div> 489 + 490 + <p class="status-text">{flow.state.progress.currentOperation}</p> 491 + </div> 492 + 493 + {:else if flow.state.step === 'migrating-blobs'} 494 + <div class="step-content"> 495 + <h2>{$_('migration.offline.blobs.title')}</h2> 496 + <p>{$_('migration.offline.blobs.desc')}</p> 497 + 498 + <div class="progress-section"> 499 + <div class="progress-item completed"> 500 + <span class="icon">✓</span> 501 + <span>{$_('migration.offline.migrating.importing')}</span> 502 + </div> 503 + <div class="progress-item active"> 504 + <span class="icon">○</span> 505 + <span>{$_('migration.offline.blobs.migrating')}</span> 506 + </div> 507 + </div> 508 + 509 + {#if flow.state.progress.blobsTotal > 0} 510 + <div class="blob-progress"> 511 + <div class="blob-progress-bar"> 512 + <div 513 + class="blob-progress-fill" 514 + style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 515 + ></div> 516 + </div> 517 + <p class="blob-progress-text"> 518 + {flow.state.progress.blobsMigrated} / {flow.state.progress.blobsTotal} blobs 519 + </p> 520 + </div> 521 + {/if} 522 + 523 + <p class="status-text">{flow.state.progress.currentOperation}</p> 524 + 525 + {#if flow.state.progress.blobsFailed.length > 0} 526 + <div class="warning-box"> 527 + <strong>{$_('migration.offline.blobs.failedTitle')}</strong> 528 + <p>{$_('migration.offline.blobs.failedDesc', { values: { count: flow.state.progress.blobsFailed.length } })}</p> 529 + </div> 530 + {/if} 531 + </div> 532 + 533 + {:else if flow.state.step === 'email-verify'} 534 + <EmailVerifyStep 535 + email={flow.state.targetEmail} 536 + token={flow.state.emailVerifyToken} 537 + {loading} 538 + error={flow.state.error} 539 + onTokenChange={(t) => flow.updateField('emailVerifyToken', t)} 540 + onSubmit={submitEmailVerify} 541 + onResend={resendEmailVerify} 542 + /> 543 + 544 + {:else if flow.state.step === 'passkey-setup'} 545 + <PasskeySetupStep 546 + {passkeyName} 547 + {loading} 548 + error={flow.state.error} 549 + onPasskeyNameChange={(n) => passkeyName = n} 550 + onRegister={registerPasskey} 551 + /> 552 + 553 + {:else if flow.state.step === 'app-password'} 554 + <AppPasswordStep 555 + appPassword={flow.state.generatedAppPassword || ''} 556 + appPasswordName={flow.state.generatedAppPasswordName || ''} 557 + {loading} 558 + onContinue={handleProceedFromAppPassword} 559 + /> 560 + 561 + {:else if flow.state.step === 'plc-signing' || flow.state.step === 'finalizing'} 562 + <div class="step-content"> 563 + <h2>{$_('migration.inbound.finalizing.title')}</h2> 564 + <p>{$_('migration.inbound.finalizing.desc')}</p> 565 + 566 + <div class="progress-section"> 567 + <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 568 + <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 569 + <span>{$_('migration.inbound.finalizing.signingPlc')}</span> 570 + </div> 571 + <div class="progress-item" class:completed={flow.state.progress.activated}> 572 + <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 573 + <span>{$_('migration.inbound.finalizing.activating')}</span> 574 + </div> 575 + </div> 576 + 577 + <p class="status-text">{flow.state.progress.currentOperation}</p> 578 + </div> 579 + 580 + {:else if flow.state.step === 'success'} 581 + <SuccessStep 582 + handle={flow.state.targetHandle} 583 + did={flow.state.userDid} 584 + description={$_('migration.offline.success.desc')} 585 + /> 586 + 587 + {:else if flow.state.step === 'error'} 588 + <ErrorStep error={flow.state.error} onStartOver={onBack} /> 589 + {/if} 590 + </div> 591 +
-546
frontend/src/components/migration/OutboundWizard.svelte
··· 1 - <script lang="ts"> 2 - import type { OutboundMigrationFlow } from '../../lib/migration' 3 - import type { ServerDescription } from '../../lib/migration/types' 4 - import { getAuthState, logout } from '../../lib/auth.svelte' 5 - import '../../styles/migration.css' 6 - 7 - interface Props { 8 - flow: OutboundMigrationFlow 9 - onBack: () => void 10 - onComplete: () => void 11 - } 12 - 13 - let { flow, onBack, onComplete }: Props = $props() 14 - 15 - const auth = getAuthState() 16 - 17 - let loading = $state(false) 18 - let understood = $state(false) 19 - let pdsUrlInput = $state('') 20 - let handleInput = $state('') 21 - let selectedDomain = $state('') 22 - let confirmFinal = $state(false) 23 - 24 - $effect(() => { 25 - if (flow.state.step === 'success') { 26 - setTimeout(async () => { 27 - await logout() 28 - onComplete() 29 - }, 3000) 30 - } 31 - }) 32 - 33 - $effect(() => { 34 - if (flow.state.targetServerInfo?.availableUserDomains?.length) { 35 - selectedDomain = flow.state.targetServerInfo.availableUserDomains[0] 36 - } 37 - }) 38 - 39 - async function validatePds(e: Event) { 40 - e.preventDefault() 41 - loading = true 42 - flow.updateField('error', null) 43 - 44 - try { 45 - let url = pdsUrlInput.trim() 46 - if (!url.startsWith('http://') && !url.startsWith('https://')) { 47 - url = `https://${url}` 48 - } 49 - await flow.validateTargetPds(url) 50 - flow.setStep('new-account') 51 - } catch (err) { 52 - flow.setError((err as Error).message) 53 - } finally { 54 - loading = false 55 - } 56 - } 57 - 58 - function proceedToReview() { 59 - const fullHandle = handleInput.includes('.') 60 - ? handleInput 61 - : `${handleInput}.${selectedDomain}` 62 - 63 - flow.updateField('targetHandle', fullHandle) 64 - flow.setStep('review') 65 - } 66 - 67 - async function startMigration() { 68 - if (!auth.session) return 69 - loading = true 70 - try { 71 - await flow.startMigration(auth.session.did) 72 - } catch (err) { 73 - flow.setError((err as Error).message) 74 - } finally { 75 - loading = false 76 - } 77 - } 78 - 79 - async function submitPlcToken(e: Event) { 80 - e.preventDefault() 81 - loading = true 82 - try { 83 - await flow.submitPlcToken(flow.state.plcToken) 84 - } catch (err) { 85 - flow.setError((err as Error).message) 86 - } finally { 87 - loading = false 88 - } 89 - } 90 - 91 - async function resendToken() { 92 - loading = true 93 - try { 94 - await flow.resendPlcToken() 95 - flow.setError(null) 96 - } catch (err) { 97 - flow.setError((err as Error).message) 98 - } finally { 99 - loading = false 100 - } 101 - } 102 - 103 - function isDidWeb(): boolean { 104 - return auth.session?.did?.startsWith('did:web:') ?? false 105 - } 106 - 107 - const steps = ['Target', 'Setup', 'Review', 'Transfer', 'Verify', 'Complete'] 108 - function getCurrentStepIndex(): number { 109 - switch (flow.state.step) { 110 - case 'welcome': return -1 111 - case 'target-pds': return 0 112 - case 'new-account': return 1 113 - case 'review': return 2 114 - case 'migrating': return 3 115 - case 'plc-token': 116 - case 'finalizing': return 4 117 - case 'success': return 5 118 - default: return 0 119 - } 120 - } 121 - </script> 122 - 123 - <div class="migration-wizard"> 124 - {#if flow.state.step !== 'welcome'} 125 - <div class="step-indicator"> 126 - {#each steps as stepName, i} 127 - <div class="step" class:active={i === getCurrentStepIndex()} class:completed={i < getCurrentStepIndex()}> 128 - <div class="step-dot">{i < getCurrentStepIndex() ? '✓' : i + 1}</div> 129 - <span class="step-label">{stepName}</span> 130 - </div> 131 - {#if i < steps.length - 1} 132 - <div class="step-line" class:completed={i < getCurrentStepIndex()}></div> 133 - {/if} 134 - {/each} 135 - </div> 136 - {/if} 137 - 138 - {#if flow.state.error} 139 - <div class="migration-message error">{flow.state.error}</div> 140 - {/if} 141 - 142 - {#if flow.state.step === 'welcome'} 143 - <div class="step-content"> 144 - <h2>Migrate Your Account Away</h2> 145 - <p>This wizard will help you move your AT Protocol account from this PDS to another one.</p> 146 - 147 - <div class="current-account"> 148 - <span class="label">Current account:</span> 149 - <span class="value">@{auth.session?.handle}</span> 150 - </div> 151 - 152 - {#if isDidWeb()} 153 - <div class="migration-warning-box"> 154 - <strong>did:web Migration Notice</strong> 155 - <p> 156 - Your account uses a did:web identifier ({auth.session?.did}). After migrating, this PDS will 157 - continue serving your DID document with an updated service endpoint pointing to your new PDS. 158 - </p> 159 - <p> 160 - You can return here anytime to update the forwarding if you migrate again in the future. 161 - </p> 162 - </div> 163 - {/if} 164 - 165 - <div class="migration-info-box"> 166 - <h3>What will happen:</h3> 167 - <ol> 168 - <li>Choose your new PDS</li> 169 - <li>Set up your account on the new server</li> 170 - <li>Your repository and blobs will be transferred</li> 171 - <li>Verify the migration via email</li> 172 - <li>Your identity will be updated to point to the new PDS</li> 173 - <li>Your account here will be deactivated</li> 174 - </ol> 175 - </div> 176 - 177 - <div class="migration-warning-box"> 178 - <strong>Before you proceed:</strong> 179 - <ul> 180 - <li>You need access to the email registered with this account</li> 181 - <li>You will lose access to this account on this PDS</li> 182 - <li>Make sure you trust the destination PDS</li> 183 - <li>Large accounts may take several minutes to transfer</li> 184 - </ul> 185 - </div> 186 - 187 - <label class="checkbox-label"> 188 - <input type="checkbox" bind:checked={understood} /> 189 - <span>I understand that my account will be moved and deactivated here</span> 190 - </label> 191 - 192 - <div class="button-row"> 193 - <button class="ghost" onclick={onBack}>Cancel</button> 194 - <button disabled={!understood} onclick={() => flow.setStep('target-pds')}> 195 - Continue 196 - </button> 197 - </div> 198 - </div> 199 - 200 - {:else if flow.state.step === 'target-pds'} 201 - <div class="step-content"> 202 - <h2>Choose Your New PDS</h2> 203 - <p>Enter the URL of the PDS you want to migrate to.</p> 204 - 205 - <form onsubmit={validatePds}> 206 - <div class="migration-field"> 207 - <label for="pds-url">PDS URL</label> 208 - <input 209 - id="pds-url" 210 - type="text" 211 - placeholder="pds.example.com" 212 - bind:value={pdsUrlInput} 213 - disabled={loading} 214 - required 215 - /> 216 - <p class="migration-hint">The server address of your new PDS (e.g., bsky.social, pds.example.com)</p> 217 - </div> 218 - 219 - <div class="button-row"> 220 - <button type="button" class="ghost" onclick={() => flow.setStep('welcome')} disabled={loading}>Back</button> 221 - <button type="submit" disabled={loading || !pdsUrlInput.trim()}> 222 - {loading ? 'Checking...' : 'Connect'} 223 - </button> 224 - </div> 225 - </form> 226 - 227 - {#if flow.state.targetServerInfo} 228 - <div class="server-info"> 229 - <h3>Connected to PDS</h3> 230 - <div class="info-row"> 231 - <span class="label">Server:</span> 232 - <span class="value">{flow.state.targetPdsUrl}</span> 233 - </div> 234 - {#if flow.state.targetServerInfo.availableUserDomains.length > 0} 235 - <div class="info-row"> 236 - <span class="label">Available domains:</span> 237 - <span class="value">{flow.state.targetServerInfo.availableUserDomains.join(', ')}</span> 238 - </div> 239 - {/if} 240 - <div class="info-row"> 241 - <span class="label">Invite required:</span> 242 - <span class="value">{flow.state.targetServerInfo.inviteCodeRequired ? 'Yes' : 'No'}</span> 243 - </div> 244 - {#if flow.state.targetServerInfo.links?.termsOfService} 245 - <a href={flow.state.targetServerInfo.links.termsOfService} target="_blank" rel="noopener"> 246 - Terms of Service 247 - </a> 248 - {/if} 249 - {#if flow.state.targetServerInfo.links?.privacyPolicy} 250 - <a href={flow.state.targetServerInfo.links.privacyPolicy} target="_blank" rel="noopener"> 251 - Privacy Policy 252 - </a> 253 - {/if} 254 - </div> 255 - {/if} 256 - </div> 257 - 258 - {:else if flow.state.step === 'new-account'} 259 - <div class="step-content"> 260 - <h2>Set Up Your New Account</h2> 261 - <p>Configure your account details on the new PDS.</p> 262 - 263 - <div class="current-info"> 264 - <span class="label">Migrating to:</span> 265 - <span class="value">{flow.state.targetPdsUrl}</span> 266 - </div> 267 - 268 - <div class="migration-field"> 269 - <label for="new-handle">New Handle</label> 270 - <div class="handle-input-group"> 271 - <input 272 - id="new-handle" 273 - type="text" 274 - placeholder="username" 275 - bind:value={handleInput} 276 - /> 277 - {#if flow.state.targetServerInfo && flow.state.targetServerInfo.availableUserDomains.length > 0 && !handleInput.includes('.')} 278 - <select bind:value={selectedDomain}> 279 - {#each flow.state.targetServerInfo.availableUserDomains as domain} 280 - <option value={domain}>.{domain}</option> 281 - {/each} 282 - </select> 283 - {/if} 284 - </div> 285 - <p class="migration-hint">You can also use your own domain by entering the full handle (e.g., alice.mydomain.com)</p> 286 - </div> 287 - 288 - <div class="migration-field"> 289 - <label for="email">Email Address</label> 290 - <input 291 - id="email" 292 - type="email" 293 - placeholder="you@example.com" 294 - bind:value={flow.state.targetEmail} 295 - oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)} 296 - required 297 - /> 298 - </div> 299 - 300 - <div class="migration-field"> 301 - <label for="new-password">Password</label> 302 - <input 303 - id="new-password" 304 - type="password" 305 - placeholder="Password for your new account" 306 - bind:value={flow.state.targetPassword} 307 - oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)} 308 - required 309 - minlength="8" 310 - /> 311 - <p class="migration-hint">At least 8 characters. This will be your password on the new PDS.</p> 312 - </div> 313 - 314 - {#if flow.state.targetServerInfo?.inviteCodeRequired} 315 - <div class="migration-field"> 316 - <label for="invite">Invite Code</label> 317 - <input 318 - id="invite" 319 - type="text" 320 - placeholder="Enter invite code" 321 - bind:value={flow.state.inviteCode} 322 - oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)} 323 - required 324 - /> 325 - <p class="migration-hint">Required by this PDS to create an account</p> 326 - </div> 327 - {/if} 328 - 329 - <div class="button-row"> 330 - <button class="ghost" onclick={() => flow.setStep('target-pds')}>Back</button> 331 - <button 332 - disabled={!handleInput.trim() || !flow.state.targetEmail || !flow.state.targetPassword} 333 - onclick={proceedToReview} 334 - > 335 - Continue 336 - </button> 337 - </div> 338 - </div> 339 - 340 - {:else if flow.state.step === 'review'} 341 - <div class="step-content"> 342 - <h2>Review Migration</h2> 343 - <p>Please confirm the details of your migration.</p> 344 - 345 - <div class="review-card"> 346 - <div class="review-row"> 347 - <span class="label">Current Handle:</span> 348 - <span class="value">@{auth.session?.handle}</span> 349 - </div> 350 - <div class="review-row"> 351 - <span class="label">New Handle:</span> 352 - <span class="value">@{flow.state.targetHandle}</span> 353 - </div> 354 - <div class="review-row"> 355 - <span class="label">DID:</span> 356 - <span class="value mono">{auth.session?.did}</span> 357 - </div> 358 - <div class="review-row"> 359 - <span class="label">From PDS:</span> 360 - <span class="value">{window.location.origin}</span> 361 - </div> 362 - <div class="review-row"> 363 - <span class="label">To PDS:</span> 364 - <span class="value">{flow.state.targetPdsUrl}</span> 365 - </div> 366 - <div class="review-row"> 367 - <span class="label">New Email:</span> 368 - <span class="value">{flow.state.targetEmail}</span> 369 - </div> 370 - </div> 371 - 372 - <div class="migration-warning-box final-warning"> 373 - <strong>This action cannot be easily undone!</strong> 374 - <p> 375 - After migration completes, your account on this PDS will be deactivated. 376 - To return, you would need to migrate back from the new PDS. 377 - </p> 378 - </div> 379 - 380 - <label class="checkbox-label"> 381 - <input type="checkbox" bind:checked={confirmFinal} /> 382 - <span>I confirm I want to migrate my account to {flow.state.targetPdsUrl}</span> 383 - </label> 384 - 385 - <div class="button-row"> 386 - <button class="ghost" onclick={() => flow.setStep('new-account')} disabled={loading}>Back</button> 387 - <button class="danger" onclick={startMigration} disabled={loading || !confirmFinal}> 388 - {loading ? 'Starting...' : 'Start Migration'} 389 - </button> 390 - </div> 391 - </div> 392 - 393 - {:else if flow.state.step === 'migrating'} 394 - <div class="step-content"> 395 - <h2>Migration in Progress</h2> 396 - <p>Please wait while your account is being transferred...</p> 397 - 398 - <div class="progress-section"> 399 - <div class="progress-item" class:completed={flow.state.progress.repoExported}> 400 - <span class="icon">{flow.state.progress.repoExported ? '✓' : '○'}</span> 401 - <span>Export repository</span> 402 - </div> 403 - <div class="progress-item" class:completed={flow.state.progress.repoImported}> 404 - <span class="icon">{flow.state.progress.repoImported ? '✓' : '○'}</span> 405 - <span>Import repository to new PDS</span> 406 - </div> 407 - <div class="progress-item" class:active={flow.state.progress.repoImported && !flow.state.progress.prefsMigrated}> 408 - <span class="icon">{flow.state.progress.blobsMigrated === flow.state.progress.blobsTotal && flow.state.progress.blobsTotal > 0 ? '✓' : '○'}</span> 409 - <span>Migrate blobs ({flow.state.progress.blobsMigrated}/{flow.state.progress.blobsTotal})</span> 410 - </div> 411 - <div class="progress-item" class:completed={flow.state.progress.prefsMigrated}> 412 - <span class="icon">{flow.state.progress.prefsMigrated ? '✓' : '○'}</span> 413 - <span>Migrate preferences</span> 414 - </div> 415 - </div> 416 - 417 - {#if flow.state.progress.blobsTotal > 0} 418 - <div class="progress-bar"> 419 - <div 420 - class="progress-fill" 421 - style="width: {(flow.state.progress.blobsMigrated / flow.state.progress.blobsTotal) * 100}%" 422 - ></div> 423 - </div> 424 - {/if} 425 - 426 - <p class="status-text">{flow.state.progress.currentOperation}</p> 427 - </div> 428 - 429 - {:else if flow.state.step === 'plc-token'} 430 - <div class="step-content"> 431 - <h2>Verify Migration</h2> 432 - <p>A verification code has been sent to your email ({auth.session?.email}).</p> 433 - 434 - <div class="migration-info-box"> 435 - <p> 436 - This code confirms you have access to the account and authorizes updating your identity 437 - to point to the new PDS. 438 - </p> 439 - </div> 440 - 441 - <form onsubmit={submitPlcToken}> 442 - <div class="migration-field"> 443 - <label for="plc-token">Verification Code</label> 444 - <input 445 - id="plc-token" 446 - type="text" 447 - placeholder="Enter code from email" 448 - bind:value={flow.state.plcToken} 449 - oninput={(e) => flow.updateField('plcToken', (e.target as HTMLInputElement).value)} 450 - disabled={loading} 451 - required 452 - /> 453 - </div> 454 - 455 - <div class="button-row"> 456 - <button type="button" class="ghost" onclick={resendToken} disabled={loading}> 457 - Resend Code 458 - </button> 459 - <button type="submit" disabled={loading || !flow.state.plcToken}> 460 - {loading ? 'Verifying...' : 'Complete Migration'} 461 - </button> 462 - </div> 463 - </form> 464 - </div> 465 - 466 - {:else if flow.state.step === 'finalizing'} 467 - <div class="step-content"> 468 - <h2>Finalizing Migration</h2> 469 - <p>Please wait while we complete the migration...</p> 470 - 471 - <div class="progress-section"> 472 - <div class="progress-item" class:completed={flow.state.progress.plcSigned}> 473 - <span class="icon">{flow.state.progress.plcSigned ? '✓' : '○'}</span> 474 - <span>Sign identity update</span> 475 - </div> 476 - <div class="progress-item" class:completed={flow.state.progress.activated}> 477 - <span class="icon">{flow.state.progress.activated ? '✓' : '○'}</span> 478 - <span>Activate account on new PDS</span> 479 - </div> 480 - <div class="progress-item" class:completed={flow.state.progress.deactivated}> 481 - <span class="icon">{flow.state.progress.deactivated ? '✓' : '○'}</span> 482 - <span>Deactivate account here</span> 483 - </div> 484 - </div> 485 - 486 - <p class="status-text">{flow.state.progress.currentOperation}</p> 487 - </div> 488 - 489 - {:else if flow.state.step === 'success'} 490 - <div class="step-content success-content"> 491 - <div class="success-icon">✓</div> 492 - <h2>Migration Complete!</h2> 493 - <p>Your account has been successfully migrated to your new PDS.</p> 494 - 495 - <div class="success-details"> 496 - <div class="detail-row"> 497 - <span class="label">Your new handle:</span> 498 - <span class="value">@{flow.state.targetHandle}</span> 499 - </div> 500 - <div class="detail-row"> 501 - <span class="label">New PDS:</span> 502 - <span class="value">{flow.state.targetPdsUrl}</span> 503 - </div> 504 - <div class="detail-row"> 505 - <span class="label">DID:</span> 506 - <span class="value mono">{auth.session?.did}</span> 507 - </div> 508 - </div> 509 - 510 - {#if flow.state.progress.blobsFailed.length > 0} 511 - <div class="migration-warning-box"> 512 - <strong>Note:</strong> {flow.state.progress.blobsFailed.length} blobs could not be migrated. 513 - These may be images or other media that are no longer available. 514 - </div> 515 - {/if} 516 - 517 - <div class="next-steps"> 518 - <h3>Next Steps</h3> 519 - <ol> 520 - <li>Visit your new PDS at <a href={flow.state.targetPdsUrl} target="_blank" rel="noopener">{flow.state.targetPdsUrl}</a></li> 521 - <li>Log in with your new credentials</li> 522 - <li>Your followers and following will continue to work</li> 523 - </ol> 524 - </div> 525 - 526 - <p class="redirect-text">Logging out in a moment...</p> 527 - </div> 528 - 529 - {:else if flow.state.step === 'error'} 530 - <div class="step-content"> 531 - <h2>Migration Error</h2> 532 - <p>An error occurred during migration.</p> 533 - 534 - <div class="migration-error-box"> 535 - {flow.state.error} 536 - </div> 537 - 538 - <div class="button-row"> 539 - <button class="ghost" onclick={onBack}>Start Over</button> 540 - </div> 541 - </div> 542 - {/if} 543 - </div> 544 - 545 - <style> 546 - </style>
+60
frontend/src/components/migration/PasskeySetupStep.svelte
··· 1 + <script lang="ts"> 2 + import { _ } from '../../lib/i18n' 3 + 4 + interface Props { 5 + passkeyName: string 6 + loading: boolean 7 + error: string | null 8 + onPasskeyNameChange: (name: string) => void 9 + onRegister: () => void 10 + } 11 + 12 + let { 13 + passkeyName, 14 + loading, 15 + error, 16 + onPasskeyNameChange, 17 + onRegister, 18 + }: Props = $props() 19 + </script> 20 + 21 + <div class="step-content"> 22 + <h2>{$_('migration.inbound.passkeySetup.title')}</h2> 23 + <p>{$_('migration.inbound.passkeySetup.desc')}</p> 24 + 25 + {#if error} 26 + <div class="message error"> 27 + {error} 28 + </div> 29 + {/if} 30 + 31 + <div class="field"> 32 + <label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label> 33 + <input 34 + id="passkey-name" 35 + type="text" 36 + placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')} 37 + value={passkeyName} 38 + oninput={(e) => onPasskeyNameChange((e.target as HTMLInputElement).value)} 39 + disabled={loading} 40 + /> 41 + <p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p> 42 + </div> 43 + 44 + <div class="passkey-section"> 45 + <p>{$_('migration.inbound.passkeySetup.instructions')}</p> 46 + <button class="primary" onclick={onRegister} disabled={loading}> 47 + {loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')} 48 + </button> 49 + </div> 50 + </div> 51 + 52 + <style> 53 + .passkey-section { 54 + margin-top: 16px; 55 + } 56 + .passkey-section button { 57 + width: 100%; 58 + margin-top: 12px; 59 + } 60 + </style>
+36
frontend/src/components/migration/SuccessStep.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte' 3 + import { _ } from '../../lib/i18n' 4 + 5 + interface Props { 6 + handle: string 7 + did: string 8 + description?: string 9 + extraContent?: Snippet 10 + } 11 + 12 + let { handle, did, description, extraContent }: Props = $props() 13 + </script> 14 + 15 + <div class="step-content success-content"> 16 + <div class="success-icon">✓</div> 17 + <h2>{$_('migration.inbound.success.title')}</h2> 18 + <p>{description || $_('migration.inbound.success.desc')}</p> 19 + 20 + <div class="success-details"> 21 + <div class="detail-row"> 22 + <span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span> 23 + <span class="value">{handle}</span> 24 + </div> 25 + <div class="detail-row"> 26 + <span class="label">{$_('migration.inbound.success.did')}:</span> 27 + <span class="value mono">{did}</span> 28 + </div> 29 + </div> 30 + 31 + {#if extraContent} 32 + {@render extraContent()} 33 + {/if} 34 + 35 + <p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p> 36 + </div>
+155 -47
frontend/src/lib/api.ts
··· 205 205 return data; 206 206 }, 207 207 208 + async createAccountWithServiceAuth( 209 + serviceAuthToken: string, 210 + params: { 211 + did: string; 212 + handle: string; 213 + email: string; 214 + password: string; 215 + inviteCode?: string; 216 + }, 217 + ): Promise<Session> { 218 + const url = `${API_BASE}/com.atproto.server.createAccount`; 219 + const response = await fetch(url, { 220 + method: "POST", 221 + headers: { 222 + "Content-Type": "application/json", 223 + "Authorization": `Bearer ${serviceAuthToken}`, 224 + }, 225 + body: JSON.stringify({ 226 + did: params.did, 227 + handle: params.handle, 228 + email: params.email, 229 + password: params.password, 230 + inviteCode: params.inviteCode, 231 + }), 232 + }); 233 + const data = await response.json(); 234 + if (!response.ok) { 235 + throw new ApiError(response.status, data.error, data.message); 236 + } 237 + return data; 238 + }, 239 + 208 240 async confirmSignup( 209 241 did: string, 210 242 verificationCode: string, ··· 226 258 return xrpc("com.atproto.server.createSession", { 227 259 method: "POST", 228 260 body: { identifier, password }, 261 + }); 262 + }, 263 + 264 + async checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { 265 + return xrpc("_checkEmailVerified", { 266 + method: "POST", 267 + body: { identifier }, 229 268 }); 230 269 }, 231 270 ··· 379 418 signalNumber: string | null; 380 419 signalVerified: boolean; 381 420 }> { 382 - return xrpc("com.tranquil.account.getNotificationPrefs", { token }); 421 + return xrpc("_account.getNotificationPrefs", { token }); 383 422 }, 384 423 385 424 async updateNotificationPrefs(token: string, prefs: { ··· 388 427 telegramUsername?: string; 389 428 signalNumber?: string; 390 429 }): Promise<{ success: boolean }> { 391 - return xrpc("com.tranquil.account.updateNotificationPrefs", { 430 + return xrpc("_account.updateNotificationPrefs", { 392 431 method: "POST", 393 432 token, 394 433 body: prefs, ··· 401 440 identifier: string, 402 441 code: string, 403 442 ): Promise<{ success: boolean }> { 404 - return xrpc("com.tranquil.account.confirmChannelVerification", { 443 + return xrpc("_account.confirmChannelVerification", { 405 444 method: "POST", 406 445 token, 407 446 body: { channel, identifier, code }, ··· 418 457 body: string; 419 458 }>; 420 459 }> { 421 - return xrpc("com.tranquil.account.getNotificationHistory", { token }); 460 + return xrpc("_account.getNotificationHistory", { token }); 422 461 }, 423 462 424 463 async getServerStats(token: string): Promise<{ ··· 427 466 recordCount: number; 428 467 blobStorageBytes: number; 429 468 }> { 430 - return xrpc("com.tranquil.admin.getServerStats", { token }); 469 + return xrpc("_admin.getServerStats", { token }); 431 470 }, 432 471 433 472 async getServerConfig(): Promise<{ ··· 438 477 secondaryColorDark: string | null; 439 478 logoCid: string | null; 440 479 }> { 441 - return xrpc("com.tranquil.server.getConfig"); 480 + return xrpc("_server.getConfig"); 442 481 }, 443 482 444 483 async updateServerConfig( ··· 452 491 logoCid?: string; 453 492 }, 454 493 ): Promise<{ success: boolean }> { 455 - return xrpc("com.tranquil.admin.updateServerConfig", { 494 + return xrpc("_admin.updateServerConfig", { 456 495 method: "POST", 457 496 token, 458 497 body: config, ··· 495 534 currentPassword: string, 496 535 newPassword: string, 497 536 ): Promise<void> { 498 - await xrpc("com.tranquil.account.changePassword", { 537 + await xrpc("_account.changePassword", { 499 538 method: "POST", 500 539 token, 501 540 body: { currentPassword, newPassword }, ··· 503 542 }, 504 543 505 544 async removePassword(token: string): Promise<{ success: boolean }> { 506 - return xrpc("com.tranquil.account.removePassword", { 545 + return xrpc("_account.removePassword", { 507 546 method: "POST", 508 547 token, 509 548 }); 510 549 }, 511 550 512 551 async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 513 - return xrpc("com.tranquil.account.getPasswordStatus", { token }); 552 + return xrpc("_account.getPasswordStatus", { token }); 514 553 }, 515 554 516 555 async getLegacyLoginPreference( 517 556 token: string, 518 557 ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 519 - return xrpc("com.tranquil.account.getLegacyLoginPreference", { token }); 558 + return xrpc("_account.getLegacyLoginPreference", { token }); 520 559 }, 521 560 522 561 async updateLegacyLoginPreference( 523 562 token: string, 524 563 allowLegacyLogin: boolean, 525 564 ): Promise<{ allowLegacyLogin: boolean }> { 526 - return xrpc("com.tranquil.account.updateLegacyLoginPreference", { 565 + return xrpc("_account.updateLegacyLoginPreference", { 527 566 method: "POST", 528 567 token, 529 568 body: { allowLegacyLogin }, ··· 534 573 token: string, 535 574 preferredLocale: string, 536 575 ): Promise<{ preferredLocale: string }> { 537 - return xrpc("com.tranquil.account.updateLocale", { 576 + return xrpc("_account.updateLocale", { 538 577 method: "POST", 539 578 token, 540 579 body: { preferredLocale }, ··· 551 590 isCurrent: boolean; 552 591 }>; 553 592 }> { 554 - return xrpc("com.tranquil.account.listSessions", { token }); 593 + return xrpc("_account.listSessions", { token }); 555 594 }, 556 595 557 596 async revokeSession(token: string, sessionId: string): Promise<void> { 558 - await xrpc("com.tranquil.account.revokeSession", { 597 + await xrpc("_account.revokeSession", { 559 598 method: "POST", 560 599 token, 561 600 body: { sessionId }, ··· 563 602 }, 564 603 565 604 async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 566 - return xrpc("com.tranquil.account.revokeAllSessions", { 605 + return xrpc("_account.revokeAllSessions", { 567 606 method: "POST", 568 607 token, 569 608 }); ··· 868 907 lastSeenAt: string; 869 908 }>; 870 909 }> { 871 - return xrpc("com.tranquil.account.listTrustedDevices", { token }); 910 + return xrpc("_account.listTrustedDevices", { token }); 872 911 }, 873 912 874 913 async revokeTrustedDevice( 875 914 token: string, 876 915 deviceId: string, 877 916 ): Promise<{ success: boolean }> { 878 - return xrpc("com.tranquil.account.revokeTrustedDevice", { 917 + return xrpc("_account.revokeTrustedDevice", { 879 918 method: "POST", 880 919 token, 881 920 body: { deviceId }, ··· 887 926 deviceId: string, 888 927 friendlyName: string, 889 928 ): Promise<{ success: boolean }> { 890 - return xrpc("com.tranquil.account.updateTrustedDevice", { 929 + return xrpc("_account.updateTrustedDevice", { 891 930 method: "POST", 892 931 token, 893 932 body: { deviceId, friendlyName }, ··· 899 938 lastReauthAt: string | null; 900 939 availableMethods: string[]; 901 940 }> { 902 - return xrpc("com.tranquil.account.getReauthStatus", { token }); 941 + return xrpc("_account.getReauthStatus", { token }); 903 942 }, 904 943 905 944 async reauthPassword( 906 945 token: string, 907 946 password: string, 908 947 ): Promise<{ success: boolean; reauthAt: string }> { 909 - return xrpc("com.tranquil.account.reauthPassword", { 948 + return xrpc("_account.reauthPassword", { 910 949 method: "POST", 911 950 token, 912 951 body: { password }, ··· 917 956 token: string, 918 957 code: string, 919 958 ): Promise<{ success: boolean; reauthAt: string }> { 920 - return xrpc("com.tranquil.account.reauthTotp", { 959 + return xrpc("_account.reauthTotp", { 921 960 method: "POST", 922 961 token, 923 962 body: { code }, ··· 925 964 }, 926 965 927 966 async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 928 - return xrpc("com.tranquil.account.reauthPasskeyStart", { 967 + return xrpc("_account.reauthPasskeyStart", { 929 968 method: "POST", 930 969 token, 931 970 }); ··· 935 974 token: string, 936 975 credential: unknown, 937 976 ): Promise<{ success: boolean; reauthAt: string }> { 938 - return xrpc("com.tranquil.account.reauthPasskeyFinish", { 977 + return xrpc("_account.reauthPasskeyFinish", { 939 978 method: "POST", 940 979 token, 941 980 body: { credential }, ··· 982 1021 setupToken: string; 983 1022 setupExpiresAt: string; 984 1023 }> { 985 - const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`; 1024 + const url = `${API_BASE}/_account.createPasskeyAccount`; 986 1025 const headers: Record<string, string> = { 987 1026 "Content-Type": "application/json", 988 1027 }; ··· 1009 1048 setupToken: string, 1010 1049 friendlyName?: string, 1011 1050 ): Promise<{ options: unknown }> { 1012 - return xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 1051 + return xrpc("_account.startPasskeyRegistrationForSetup", { 1013 1052 method: "POST", 1014 1053 body: { did, setupToken, friendlyName }, 1015 1054 }); ··· 1026 1065 appPassword: string; 1027 1066 appPasswordName: string; 1028 1067 }> { 1029 - return xrpc("com.tranquil.account.completePasskeySetup", { 1068 + return xrpc("_account.completePasskeySetup", { 1030 1069 method: "POST", 1031 1070 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 1032 1071 }); 1033 1072 }, 1034 1073 1035 1074 async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 1036 - return xrpc("com.tranquil.account.requestPasskeyRecovery", { 1075 + return xrpc("_account.requestPasskeyRecovery", { 1037 1076 method: "POST", 1038 1077 body: { email }, 1039 1078 }); ··· 1044 1083 recoveryToken: string, 1045 1084 newPassword: string, 1046 1085 ): Promise<{ success: boolean }> { 1047 - return xrpc("com.tranquil.account.recoverPasskeyAccount", { 1086 + return xrpc("_account.recoverPasskeyAccount", { 1048 1087 method: "POST", 1049 1088 body: { did, recoveryToken, newPassword }, 1050 1089 }); ··· 1077 1116 purpose: string; 1078 1117 channel: string; 1079 1118 }> { 1080 - return xrpc("com.tranquil.account.verifyToken", { 1119 + return xrpc("_account.verifyToken", { 1081 1120 method: "POST", 1082 1121 body: { token, identifier }, 1083 1122 token: accessToken, ··· 1085 1124 }, 1086 1125 1087 1126 async getDidDocument(token: string): Promise<DidDocument> { 1088 - return xrpc("com.tranquil.account.getDidDocument", { token }); 1127 + return xrpc("_account.getDidDocument", { token }); 1089 1128 }, 1090 1129 1091 1130 async updateDidDocument( ··· 1096 1135 serviceEndpoint?: string; 1097 1136 }, 1098 1137 ): Promise<{ success: boolean }> { 1099 - return xrpc("com.tranquil.account.updateDidDocument", { 1138 + return xrpc("_account.updateDidDocument", { 1100 1139 method: "POST", 1101 1140 token, 1102 1141 body: params, ··· 1106 1145 async deactivateAccount( 1107 1146 token: string, 1108 1147 deleteAfter?: string, 1109 - migratingTo?: string, 1110 1148 ): Promise<void> { 1111 1149 await xrpc("com.atproto.server.deactivateAccount", { 1112 1150 method: "POST", 1113 1151 token, 1114 - body: { deleteAfter, migratingTo }, 1152 + body: { deleteAfter }, 1153 + }); 1154 + }, 1155 + 1156 + async getRepo(token: string, did: string): Promise<ArrayBuffer> { 1157 + const url = `${API_BASE}/com.atproto.sync.getRepo?did=${ 1158 + encodeURIComponent(did) 1159 + }`; 1160 + const res = await fetch(url, { 1161 + headers: { Authorization: `Bearer ${token}` }, 1162 + }); 1163 + if (!res.ok) { 1164 + const err = await res.json().catch(() => ({ 1165 + error: "Unknown", 1166 + message: res.statusText, 1167 + })); 1168 + throw new ApiError(res.status, err.error, err.message); 1169 + } 1170 + return res.arrayBuffer(); 1171 + }, 1172 + 1173 + async listBackups(token: string): Promise<{ 1174 + backups: Array<{ 1175 + id: string; 1176 + repoRev: string; 1177 + repoRootCid: string; 1178 + blockCount: number; 1179 + sizeBytes: number; 1180 + createdAt: string; 1181 + }>; 1182 + backupEnabled: boolean; 1183 + }> { 1184 + return xrpc("_backup.listBackups", { token }); 1185 + }, 1186 + 1187 + async getBackup(token: string, id: string): Promise<Blob> { 1188 + const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`; 1189 + const res = await fetch(url, { 1190 + headers: { Authorization: `Bearer ${token}` }, 1115 1191 }); 1192 + if (!res.ok) { 1193 + const err = await res.json().catch(() => ({ 1194 + error: "Unknown", 1195 + message: res.statusText, 1196 + })); 1197 + throw new ApiError(res.status, err.error, err.message); 1198 + } 1199 + return res.blob(); 1116 1200 }, 1117 1201 1118 - async getMigrationStatus(token: string): Promise<{ 1119 - migratedToPds?: string; 1120 - migratedAt?: string; 1121 - forwardingEnabled: boolean; 1202 + async createBackup(token: string): Promise<{ 1203 + id: string; 1204 + repoRev: string; 1205 + sizeBytes: number; 1206 + blockCount: number; 1122 1207 }> { 1123 - return xrpc("com.tranquil.account.getMigrationStatus", { token }); 1208 + return xrpc("_backup.createBackup", { 1209 + method: "POST", 1210 + token, 1211 + }); 1124 1212 }, 1125 1213 1126 - async updateMigrationForwarding( 1127 - token: string, 1128 - forwardingPds?: string, 1129 - ): Promise<{ success: boolean }> { 1130 - return xrpc("com.tranquil.account.updateMigrationForwarding", { 1214 + async deleteBackup(token: string, id: string): Promise<void> { 1215 + await xrpc("_backup.deleteBackup", { 1131 1216 method: "POST", 1132 1217 token, 1133 - body: { forwardingPds }, 1218 + params: { id }, 1134 1219 }); 1135 1220 }, 1136 1221 1137 - async clearMigrationForwarding(token: string): Promise<{ success: boolean }> { 1138 - return xrpc("com.tranquil.account.clearMigrationForwarding", { 1222 + async setBackupEnabled( 1223 + token: string, 1224 + enabled: boolean, 1225 + ): Promise<{ enabled: boolean }> { 1226 + return xrpc("_backup.setEnabled", { 1139 1227 method: "POST", 1140 1228 token, 1229 + body: { enabled }, 1141 1230 }); 1231 + }, 1232 + 1233 + async importRepo(token: string, car: Uint8Array): Promise<void> { 1234 + const url = `${API_BASE}/com.atproto.repo.importRepo`; 1235 + const res = await fetch(url, { 1236 + method: "POST", 1237 + headers: { 1238 + Authorization: `Bearer ${token}`, 1239 + "Content-Type": "application/vnd.ipld.car", 1240 + }, 1241 + body: car, 1242 + }); 1243 + if (!res.ok) { 1244 + const err = await res.json().catch(() => ({ 1245 + error: "Unknown", 1246 + message: res.statusText, 1247 + })); 1248 + throw new ApiError(res.status, err.error, err.message); 1249 + } 1142 1250 }, 1143 1251 };
+16 -42
frontend/src/lib/migration/atproto-client.ts
··· 372 372 ); 373 373 } 374 374 375 - async deactivateAccount(migratingTo?: string): Promise<void> { 375 + async deactivateAccount(): Promise<void> { 376 376 apiLog( 377 377 "POST", 378 378 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, 379 - { 380 - migratingTo, 381 - }, 382 379 ); 383 380 const start = Date.now(); 384 381 try { 385 - const body: { migratingTo?: string } = {}; 386 - if (migratingTo) { 387 - body.migratingTo = migratingTo; 388 - } 389 382 await this.xrpc("com.atproto.server.deactivateAccount", { 390 383 httpMethod: "POST", 391 - body, 392 384 }); 393 385 apiLog( 394 386 "POST", ··· 396 388 { 397 389 durationMs: Date.now() - start, 398 390 success: true, 399 - migratingTo, 400 391 }, 401 392 ); 402 393 } catch (e) { ··· 409 400 error: err.message, 410 401 errorCode: err.error, 411 402 status: err.status, 412 - migratingTo, 413 403 }, 414 404 ); 415 405 throw e; ··· 420 410 return this.xrpc("com.atproto.server.checkAccountStatus"); 421 411 } 422 412 423 - async getMigrationStatus(): Promise<{ 424 - did: string; 425 - didType: string; 426 - migrated: boolean; 427 - migratedToPds?: string; 428 - migratedAt?: string; 429 - }> { 430 - return this.xrpc("com.tranquil.account.getMigrationStatus"); 431 - } 432 - 433 - async updateMigrationForwarding(pdsUrl: string): Promise<{ 434 - success: boolean; 435 - migratedToPds: string; 436 - migratedAt: string; 437 - }> { 438 - return this.xrpc("com.tranquil.account.updateMigrationForwarding", { 439 - httpMethod: "POST", 440 - body: { pdsUrl }, 441 - }); 442 - } 443 - 444 - async clearMigrationForwarding(): Promise<{ success: boolean }> { 445 - return this.xrpc("com.tranquil.account.clearMigrationForwarding", { 446 - httpMethod: "POST", 447 - }); 448 - } 449 - 450 413 async resolveHandle(handle: string): Promise<{ did: string }> { 451 414 return this.xrpc("com.atproto.identity.resolveHandle", { 452 415 params: { handle }, ··· 468 431 return session; 469 432 } 470 433 434 + async checkEmailVerified(identifier: string): Promise<boolean> { 435 + const result = await this.xrpc<{ verified: boolean }>( 436 + "_checkEmailVerified", 437 + { 438 + httpMethod: "POST", 439 + body: { identifier }, 440 + }, 441 + ); 442 + return result.verified; 443 + } 444 + 471 445 async verifyToken( 472 446 token: string, 473 447 identifier: string, 474 448 ): Promise< 475 449 { success: boolean; did: string; purpose: string; channel: string } 476 450 > { 477 - return this.xrpc("com.tranquil.account.verifyToken", { 451 + return this.xrpc("_account.verifyToken", { 478 452 httpMethod: "POST", 479 453 body: { token, identifier }, 480 454 }); ··· 498 472 } 499 473 500 474 const res = await fetch( 501 - `${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`, 475 + `${this.baseUrl}/xrpc/_account.createPasskeyAccount`, 502 476 { 503 477 method: "POST", 504 478 headers, ··· 530 504 setupToken: string, 531 505 friendlyName?: string, 532 506 ): Promise<StartPasskeyRegistrationResponse> { 533 - return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 507 + return this.xrpc("_account.startPasskeyRegistrationForSetup", { 534 508 httpMethod: "POST", 535 509 body: { did, setupToken, friendlyName }, 536 510 }); ··· 542 516 passkeyCredential: unknown, 543 517 passkeyFriendlyName?: string, 544 518 ): Promise<CompletePasskeySetupResponse> { 545 - return this.xrpc("com.tranquil.account.completePasskeySetup", { 519 + return this.xrpc("_account.completePasskeySetup", { 546 520 httpMethod: "POST", 547 521 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 548 522 });
+156
frontend/src/lib/migration/blob-migration.ts
··· 1 + import type { AtprotoClient } from "./atproto-client"; 2 + import type { MigrationProgress } from "./types"; 3 + 4 + export interface BlobMigrationResult { 5 + migrated: number; 6 + failed: string[]; 7 + total: number; 8 + sourceUnreachable: boolean; 9 + } 10 + 11 + export async function migrateBlobs( 12 + localClient: AtprotoClient, 13 + sourceClient: AtprotoClient | null, 14 + userDid: string, 15 + onProgress: (update: Partial<MigrationProgress>) => void, 16 + ): Promise<BlobMigrationResult> { 17 + const missingBlobs: string[] = []; 18 + let cursor: string | undefined; 19 + 20 + console.log("[blob-migration] Starting blob migration for", userDid); 21 + console.log( 22 + "[blob-migration] Source client:", 23 + sourceClient ? "available" : "NOT AVAILABLE", 24 + ); 25 + 26 + onProgress({ currentOperation: "Checking for missing blobs..." }); 27 + 28 + do { 29 + const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( 30 + cursor, 31 + 100, 32 + ); 33 + console.log( 34 + "[blob-migration] listMissingBlobs returned", 35 + blobs.length, 36 + "blobs, cursor:", 37 + nextCursor, 38 + ); 39 + for (const blob of blobs) { 40 + missingBlobs.push(blob.cid); 41 + } 42 + cursor = nextCursor; 43 + } while (cursor); 44 + 45 + console.log("[blob-migration] Total missing blobs:", missingBlobs.length); 46 + onProgress({ blobsTotal: missingBlobs.length }); 47 + 48 + if (missingBlobs.length === 0) { 49 + console.log("[blob-migration] No blobs to migrate"); 50 + onProgress({ currentOperation: "No blobs to migrate" }); 51 + return { migrated: 0, failed: [], total: 0, sourceUnreachable: false }; 52 + } 53 + 54 + if (!sourceClient) { 55 + console.warn( 56 + "[blob-migration] No source client available, cannot fetch blobs", 57 + ); 58 + onProgress({ 59 + currentOperation: 60 + `${missingBlobs.length} media files missing. No source PDS URL available - your old server may have shut down. Posts will work, but some images/media may be unavailable.`, 61 + }); 62 + return { 63 + migrated: 0, 64 + failed: missingBlobs, 65 + total: missingBlobs.length, 66 + sourceUnreachable: true, 67 + }; 68 + } 69 + 70 + onProgress({ currentOperation: `Migrating ${missingBlobs.length} blobs...` }); 71 + 72 + let migrated = 0; 73 + const failed: string[] = []; 74 + let sourceUnreachable = false; 75 + 76 + for (const cid of missingBlobs) { 77 + if (sourceUnreachable) { 78 + failed.push(cid); 79 + continue; 80 + } 81 + 82 + try { 83 + onProgress({ 84 + currentOperation: `Migrating blob ${ 85 + migrated + 1 86 + }/${missingBlobs.length}...`, 87 + }); 88 + 89 + console.log("[blob-migration] Fetching blob", cid, "from source"); 90 + const blobData = await sourceClient.getBlob(userDid, cid); 91 + console.log( 92 + "[blob-migration] Got blob", 93 + cid, 94 + "size:", 95 + blobData.byteLength, 96 + ); 97 + await localClient.uploadBlob(blobData, "application/octet-stream"); 98 + console.log("[blob-migration] Uploaded blob", cid); 99 + migrated++; 100 + onProgress({ blobsMigrated: migrated }); 101 + } catch (e) { 102 + const errorMessage = (e as Error).message || String(e); 103 + console.error( 104 + "[blob-migration] Failed to migrate blob", 105 + cid, 106 + ":", 107 + errorMessage, 108 + ); 109 + 110 + const isNetworkError = 111 + errorMessage.includes("fetch") || 112 + errorMessage.includes("network") || 113 + errorMessage.includes("CORS") || 114 + errorMessage.includes("Failed to fetch") || 115 + errorMessage.includes("NetworkError") || 116 + errorMessage.includes("blocked by CORS"); 117 + 118 + if (isNetworkError) { 119 + sourceUnreachable = true; 120 + console.warn( 121 + "[blob-migration] Source appears unreachable (likely CORS or network issue), skipping remaining blobs", 122 + ); 123 + const remaining = missingBlobs.length - migrated - 1; 124 + if (migrated > 0) { 125 + onProgress({ 126 + currentOperation: 127 + `Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${remaining + 1} could not be fetched - these may need to be re-uploaded.`, 128 + }); 129 + } else { 130 + onProgress({ 131 + currentOperation: 132 + `Cannot reach source PDS (browser security restriction). This commonly happens when the old server has shut down or doesn't allow cross-origin requests. Your posts will work, but ${missingBlobs.length} media files couldn't be recovered.`, 133 + }); 134 + } 135 + } 136 + failed.push(cid); 137 + } 138 + } 139 + 140 + if (migrated === missingBlobs.length) { 141 + onProgress({ 142 + currentOperation: `All ${migrated} blobs migrated successfully`, 143 + }); 144 + } else if (migrated > 0) { 145 + onProgress({ 146 + currentOperation: 147 + `${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.`, 148 + }); 149 + } else { 150 + onProgress({ 151 + currentOperation: `Could not migrate blobs (${failed.length} missing)`, 152 + }); 153 + } 154 + 155 + return { migrated, failed, total: missingBlobs.length, sourceUnreachable }; 156 + }
+17 -318
frontend/src/lib/migration/flow.svelte.ts
··· 2 2 InboundMigrationState, 3 3 InboundStep, 4 4 MigrationProgress, 5 - OutboundMigrationState, 6 - OutboundStep, 7 5 PasskeyAccountSetup, 8 6 ServerDescription, 9 7 StoredMigrationState, ··· 30 28 updateProgress, 31 29 updateStep, 32 30 } from "./storage"; 31 + import { migrateBlobs as migrateBlobsUtil } from "./blob-migration"; 33 32 34 33 function migrationLog(stage: string, data?: Record<string, unknown>) { 35 34 const timestamp = new Date().toISOString(); ··· 85 84 let sourceClient: AtprotoClient | null = null; 86 85 let localClient: AtprotoClient | null = null; 87 86 let localServerInfo: ServerDescription | null = null; 87 + let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> = 88 + null; 88 89 89 90 function setStep(step: InboundStep) { 90 91 state.step = step; 91 92 state.error = null; 92 - saveMigrationState(state); 93 - updateStep(step); 93 + if (step !== "success") { 94 + saveMigrationState(state); 95 + updateStep(step); 96 + } 94 97 } 95 98 96 99 function setError(error: string) { ··· 458 461 async function migrateBlobs(): Promise<void> { 459 462 if (!sourceClient || !localClient) return; 460 463 461 - let cursor: string | undefined; 462 - let migrated = 0; 463 - 464 - do { 465 - const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( 466 - cursor, 467 - 100, 468 - ); 469 - 470 - for (const blob of blobs) { 471 - try { 472 - setProgress({ 473 - currentOperation: `Migrating blob ${ 474 - migrated + 1 475 - }/${state.progress.blobsTotal}...`, 476 - }); 477 - 478 - const blobData = await sourceClient.getBlob( 479 - state.sourceDid, 480 - blob.cid, 481 - ); 482 - await localClient.uploadBlob(blobData, "application/octet-stream"); 483 - migrated++; 484 - setProgress({ blobsMigrated: migrated }); 485 - } catch { 486 - state.progress.blobsFailed.push(blob.cid); 487 - } 488 - } 464 + const result = await migrateBlobsUtil( 465 + localClient, 466 + sourceClient, 467 + state.sourceDid, 468 + setProgress, 469 + ); 489 470 490 - cursor = nextCursor; 491 - } while (cursor); 471 + state.progress.blobsFailed = result.failed; 492 472 } 493 473 494 474 async function migratePreferences(): Promise<void> { ··· 578 558 579 559 checkingEmailVerification = true; 580 560 try { 561 + const verified = await localClient.checkEmailVerified(state.targetEmail); 562 + if (!verified) return false; 563 + 581 564 await localClient.loginDeactivated( 582 565 state.targetEmail, 583 566 state.targetPassword, ··· 978 961 }; 979 962 } 980 963 981 - export function createOutboundMigrationFlow() { 982 - let state = $state<OutboundMigrationState>({ 983 - direction: "outbound", 984 - step: "welcome", 985 - localDid: "", 986 - localHandle: "", 987 - targetPdsUrl: "", 988 - targetPdsDid: "", 989 - targetHandle: "", 990 - targetEmail: "", 991 - targetPassword: "", 992 - inviteCode: "", 993 - targetAccessToken: null, 994 - targetRefreshToken: null, 995 - serviceAuthToken: null, 996 - plcToken: "", 997 - progress: createInitialProgress(), 998 - error: null, 999 - targetServerInfo: null, 1000 - }); 1001 - 1002 - let localClient: AtprotoClient | null = null; 1003 - let targetClient: AtprotoClient | null = null; 1004 - 1005 - function setStep(step: OutboundStep) { 1006 - state.step = step; 1007 - state.error = null; 1008 - saveMigrationState(state); 1009 - updateStep(step); 1010 - } 1011 - 1012 - function setError(error: string) { 1013 - state.error = error; 1014 - saveMigrationState(state); 1015 - } 1016 - 1017 - function setProgress(updates: Partial<MigrationProgress>) { 1018 - state.progress = { ...state.progress, ...updates }; 1019 - updateProgress(updates); 1020 - } 1021 - 1022 - async function validateTargetPds(url: string): Promise<ServerDescription> { 1023 - const normalizedUrl = url.replace(/\/$/, ""); 1024 - targetClient = new AtprotoClient(normalizedUrl); 1025 - 1026 - try { 1027 - const serverInfo = await targetClient.describeServer(); 1028 - state.targetPdsUrl = normalizedUrl; 1029 - state.targetPdsDid = serverInfo.did; 1030 - state.targetServerInfo = serverInfo; 1031 - return serverInfo; 1032 - } catch (e) { 1033 - throw new Error(`Could not connect to PDS: ${(e as Error).message}`); 1034 - } 1035 - } 1036 - 1037 - function initLocalClient( 1038 - accessToken: string, 1039 - did?: string, 1040 - handle?: string, 1041 - ): void { 1042 - localClient = createLocalClient(); 1043 - localClient.setAccessToken(accessToken); 1044 - if (did) { 1045 - state.localDid = did; 1046 - } 1047 - if (handle) { 1048 - state.localHandle = handle; 1049 - } 1050 - } 1051 - 1052 - async function startMigration(currentDid: string): Promise<void> { 1053 - if (!localClient || !targetClient) { 1054 - throw new Error("Not connected to PDSes"); 1055 - } 1056 - 1057 - setStep("migrating"); 1058 - setProgress({ currentOperation: "Getting service auth token..." }); 1059 - 1060 - try { 1061 - const { token } = await localClient.getServiceAuth( 1062 - state.targetPdsDid, 1063 - "com.atproto.server.createAccount", 1064 - ); 1065 - state.serviceAuthToken = token; 1066 - 1067 - setProgress({ currentOperation: "Creating account on new PDS..." }); 1068 - 1069 - const accountParams = { 1070 - did: currentDid, 1071 - handle: state.targetHandle, 1072 - email: state.targetEmail, 1073 - password: state.targetPassword, 1074 - inviteCode: state.inviteCode || undefined, 1075 - }; 1076 - 1077 - const session = await targetClient.createAccount(accountParams, token); 1078 - state.targetAccessToken = session.accessJwt; 1079 - state.targetRefreshToken = session.refreshJwt; 1080 - targetClient.setAccessToken(session.accessJwt); 1081 - 1082 - setProgress({ currentOperation: "Exporting repository..." }); 1083 - 1084 - const car = await localClient.getRepo(currentDid); 1085 - setProgress({ 1086 - repoExported: true, 1087 - currentOperation: "Importing repository...", 1088 - }); 1089 - 1090 - await targetClient.importRepo(car); 1091 - setProgress({ 1092 - repoImported: true, 1093 - currentOperation: "Counting blobs...", 1094 - }); 1095 - 1096 - const accountStatus = await targetClient.checkAccountStatus(); 1097 - setProgress({ 1098 - blobsTotal: accountStatus.expectedBlobs, 1099 - currentOperation: "Migrating blobs...", 1100 - }); 1101 - 1102 - await migrateBlobs(currentDid); 1103 - 1104 - setProgress({ currentOperation: "Migrating preferences..." }); 1105 - await migratePreferences(); 1106 - 1107 - setProgress({ currentOperation: "Requesting PLC operation token..." }); 1108 - await localClient.requestPlcOperationSignature(); 1109 - 1110 - setStep("plc-token"); 1111 - } catch (e) { 1112 - const err = e as Error & { error?: string; status?: number }; 1113 - const message = err.message || err.error || 1114 - `Unknown error (status ${err.status || "unknown"})`; 1115 - setError(message); 1116 - setStep("error"); 1117 - } 1118 - } 1119 - 1120 - async function migrateBlobs(did: string): Promise<void> { 1121 - if (!localClient || !targetClient) return; 1122 - 1123 - let cursor: string | undefined; 1124 - let migrated = 0; 1125 - 1126 - do { 1127 - const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs( 1128 - cursor, 1129 - 100, 1130 - ); 1131 - 1132 - for (const blob of blobs) { 1133 - try { 1134 - setProgress({ 1135 - currentOperation: `Migrating blob ${ 1136 - migrated + 1 1137 - }/${state.progress.blobsTotal}...`, 1138 - }); 1139 - 1140 - const blobData = await localClient.getBlob(did, blob.cid); 1141 - await targetClient.uploadBlob(blobData, "application/octet-stream"); 1142 - migrated++; 1143 - setProgress({ blobsMigrated: migrated }); 1144 - } catch { 1145 - state.progress.blobsFailed.push(blob.cid); 1146 - } 1147 - } 1148 - 1149 - cursor = nextCursor; 1150 - } while (cursor); 1151 - } 1152 - 1153 - async function migratePreferences(): Promise<void> { 1154 - if (!localClient || !targetClient) return; 1155 - 1156 - try { 1157 - const prefs = await localClient.getPreferences(); 1158 - await targetClient.putPreferences(prefs); 1159 - setProgress({ prefsMigrated: true }); 1160 - } catch { /* optional, best-effort */ } 1161 - } 1162 - 1163 - async function submitPlcToken(token: string): Promise<void> { 1164 - if (!localClient || !targetClient) { 1165 - throw new Error("Not connected to PDSes"); 1166 - } 1167 - 1168 - state.plcToken = token; 1169 - setStep("finalizing"); 1170 - setProgress({ currentOperation: "Signing PLC operation..." }); 1171 - 1172 - try { 1173 - const credentials = await targetClient.getRecommendedDidCredentials(); 1174 - 1175 - const { operation } = await localClient.signPlcOperation({ 1176 - token, 1177 - ...credentials, 1178 - }); 1179 - 1180 - setProgress({ 1181 - plcSigned: true, 1182 - currentOperation: "Submitting PLC operation...", 1183 - }); 1184 - 1185 - await targetClient.submitPlcOperation(operation); 1186 - 1187 - setProgress({ currentOperation: "Activating account on new PDS..." }); 1188 - await targetClient.activateAccount(); 1189 - setProgress({ activated: true }); 1190 - 1191 - setProgress({ currentOperation: "Deactivating old account..." }); 1192 - try { 1193 - await localClient.deactivateAccount(state.targetPdsUrl); 1194 - setProgress({ deactivated: true }); 1195 - } catch { /* optional, best-effort */ } 1196 - 1197 - setStep("success"); 1198 - clearMigrationState(); 1199 - } catch (e) { 1200 - const err = e as Error & { error?: string; status?: number }; 1201 - const message = err.message || err.error || 1202 - `Unknown error (status ${err.status || "unknown"})`; 1203 - setError(message); 1204 - setStep("plc-token"); 1205 - } 1206 - } 1207 - 1208 - async function resendPlcToken(): Promise<void> { 1209 - if (!localClient) { 1210 - throw new Error("Not connected to local PDS"); 1211 - } 1212 - await localClient.requestPlcOperationSignature(); 1213 - } 1214 - 1215 - function reset(): void { 1216 - state = { 1217 - direction: "outbound", 1218 - step: "welcome", 1219 - localDid: "", 1220 - localHandle: "", 1221 - targetPdsUrl: "", 1222 - targetPdsDid: "", 1223 - targetHandle: "", 1224 - targetEmail: "", 1225 - targetPassword: "", 1226 - inviteCode: "", 1227 - targetAccessToken: null, 1228 - targetRefreshToken: null, 1229 - serviceAuthToken: null, 1230 - plcToken: "", 1231 - progress: createInitialProgress(), 1232 - error: null, 1233 - targetServerInfo: null, 1234 - }; 1235 - localClient = null; 1236 - targetClient = null; 1237 - clearMigrationState(); 1238 - } 1239 - 1240 - return { 1241 - get state() { 1242 - return state; 1243 - }, 1244 - setStep, 1245 - setError, 1246 - validateTargetPds, 1247 - initLocalClient, 1248 - startMigration, 1249 - submitPlcToken, 1250 - resendPlcToken, 1251 - reset, 1252 - 1253 - updateField<K extends keyof OutboundMigrationState>( 1254 - field: K, 1255 - value: OutboundMigrationState[K], 1256 - ) { 1257 - state[field] = value; 1258 - }, 1259 - }; 1260 - } 1261 - 1262 964 export type InboundMigrationFlow = ReturnType< 1263 965 typeof createInboundMigrationFlow 1264 966 >; 1265 - export type OutboundMigrationFlow = ReturnType< 1266 - typeof createOutboundMigrationFlow 1267 - >;
+8 -2
frontend/src/lib/migration/index.ts
··· 1 1 export * from "./types"; 2 2 export * from "./atproto-client"; 3 3 export * from "./storage"; 4 + export * from "./blob-migration"; 4 5 export { 5 6 createInboundMigrationFlow, 6 - createOutboundMigrationFlow, 7 7 type InboundMigrationFlow, 8 - type OutboundMigrationFlow, 9 8 } from "./flow.svelte"; 9 + export { 10 + clearOfflineState, 11 + createOfflineInboundMigrationFlow, 12 + getOfflineResumeInfo, 13 + hasPendingOfflineMigration, 14 + } from "./offline-flow.svelte"; 15 + export type { OfflineInboundMigrationFlow } from "./offline-flow.svelte";
+765
frontend/src/lib/migration/offline-flow.svelte.ts
··· 1 + import type { 2 + AuthMethod, 3 + MigrationProgress, 4 + OfflineInboundMigrationState, 5 + OfflineInboundStep, 6 + ServerDescription, 7 + } from "./types"; 8 + import { 9 + AtprotoClient, 10 + base64UrlEncode, 11 + createLocalClient, 12 + prepareWebAuthnCreationOptions, 13 + } from "./atproto-client"; 14 + import { api } from "../api"; 15 + import { type KeypairInfo, plcOps, type PrivateKey } from "./plc-ops"; 16 + import { migrateBlobs as migrateBlobsUtil } from "./blob-migration"; 17 + import { Secp256k1PrivateKeyExportable } from "@atcute/crypto"; 18 + 19 + const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state"; 20 + const MAX_AGE_MS = 24 * 60 * 60 * 1000; 21 + 22 + interface StoredOfflineMigrationState { 23 + version: number; 24 + step: OfflineInboundStep; 25 + startedAt: string; 26 + userDid: string; 27 + carFileName: string; 28 + carSizeBytes: number; 29 + rotationKeyDidKey: string; 30 + targetHandle: string; 31 + targetEmail: string; 32 + authMethod: AuthMethod; 33 + passkeySetupToken?: string; 34 + oldPdsUrl?: string; 35 + plcUpdatedTemporarily?: boolean; 36 + progress: { 37 + accountCreated: boolean; 38 + repoImported: boolean; 39 + plcSigned: boolean; 40 + activated: boolean; 41 + }; 42 + lastError?: string; 43 + } 44 + 45 + function saveOfflineState(state: OfflineInboundMigrationState): void { 46 + const stored: StoredOfflineMigrationState = { 47 + version: 1, 48 + step: state.step, 49 + startedAt: new Date().toISOString(), 50 + userDid: state.userDid, 51 + carFileName: state.carFileName, 52 + carSizeBytes: state.carSizeBytes, 53 + rotationKeyDidKey: state.rotationKeyDidKey, 54 + targetHandle: state.targetHandle, 55 + targetEmail: state.targetEmail, 56 + authMethod: state.authMethod, 57 + passkeySetupToken: state.passkeySetupToken ?? undefined, 58 + oldPdsUrl: state.oldPdsUrl ?? undefined, 59 + plcUpdatedTemporarily: state.plcUpdatedTemporarily || undefined, 60 + progress: { 61 + accountCreated: state.progress.repoExported, 62 + repoImported: state.progress.repoImported, 63 + plcSigned: state.progress.plcSigned, 64 + activated: state.progress.activated, 65 + }, 66 + lastError: state.error ?? undefined, 67 + }; 68 + try { 69 + localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(stored)); 70 + } catch { /* ignore localStorage errors */ } 71 + } 72 + 73 + function loadOfflineState(): StoredOfflineMigrationState | null { 74 + try { 75 + const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); 76 + if (!stored) return null; 77 + const state = JSON.parse(stored) as StoredOfflineMigrationState; 78 + if (state.version !== 1) { 79 + clearOfflineState(); 80 + return null; 81 + } 82 + const startedAt = new Date(state.startedAt).getTime(); 83 + if (Date.now() - startedAt > MAX_AGE_MS) { 84 + clearOfflineState(); 85 + return null; 86 + } 87 + return state; 88 + } catch { 89 + /* ignore parse errors */ 90 + clearOfflineState(); 91 + return null; 92 + } 93 + } 94 + 95 + function clearOfflineState(): void { 96 + try { 97 + localStorage.removeItem(OFFLINE_STORAGE_KEY); 98 + } catch { /* ignore localStorage errors */ } 99 + } 100 + 101 + export function hasPendingOfflineMigration(): boolean { 102 + return loadOfflineState() !== null; 103 + } 104 + 105 + export function getOfflineResumeInfo(): { 106 + step: OfflineInboundStep; 107 + userDid: string; 108 + targetHandle: string; 109 + } | null { 110 + const state = loadOfflineState(); 111 + if (!state) return null; 112 + return { 113 + step: state.step, 114 + userDid: state.userDid, 115 + targetHandle: state.targetHandle, 116 + }; 117 + } 118 + 119 + export { clearOfflineState }; 120 + 121 + function createInitialProgress(): MigrationProgress { 122 + return { 123 + repoExported: false, 124 + repoImported: false, 125 + blobsTotal: 0, 126 + blobsMigrated: 0, 127 + blobsFailed: [], 128 + prefsMigrated: false, 129 + plcSigned: false, 130 + activated: false, 131 + deactivated: false, 132 + currentOperation: "", 133 + }; 134 + } 135 + 136 + export type OfflineInboundMigrationFlow = ReturnType< 137 + typeof createOfflineInboundMigrationFlow 138 + >; 139 + 140 + export function createOfflineInboundMigrationFlow() { 141 + let state = $state<OfflineInboundMigrationState>({ 142 + direction: "offline-inbound", 143 + step: "welcome", 144 + userDid: "", 145 + carFile: null, 146 + carFileName: "", 147 + carSizeBytes: 0, 148 + carNeedsReupload: false, 149 + rotationKey: "", 150 + rotationKeyDidKey: "", 151 + oldPdsUrl: null, 152 + targetHandle: "", 153 + targetEmail: "", 154 + targetPassword: "", 155 + inviteCode: "", 156 + authMethod: "password", 157 + localAccessToken: null, 158 + localRefreshToken: null, 159 + passkeySetupToken: null, 160 + generatedAppPassword: null, 161 + generatedAppPasswordName: null, 162 + emailVerifyToken: "", 163 + progress: createInitialProgress(), 164 + error: null, 165 + plcUpdatedTemporarily: false, 166 + }); 167 + 168 + let localServerInfo: ServerDescription | null = null; 169 + let userRotationKeypair: KeypairInfo | null = null; 170 + let tempVerificationKeypair: Secp256k1PrivateKeyExportable | null = null; 171 + 172 + function setStep(step: OfflineInboundStep) { 173 + state.step = step; 174 + state.error = null; 175 + if (step !== "success") { 176 + saveOfflineState(state); 177 + } 178 + } 179 + 180 + function setError(error: string | null) { 181 + state.error = error; 182 + saveOfflineState(state); 183 + } 184 + 185 + function setProgress(updates: Partial<MigrationProgress>) { 186 + state.progress = { ...state.progress, ...updates }; 187 + saveOfflineState(state); 188 + } 189 + 190 + async function loadLocalServerInfo(): Promise<ServerDescription> { 191 + if (!localServerInfo) { 192 + const client = createLocalClient(); 193 + localServerInfo = await client.describeServer(); 194 + } 195 + return localServerInfo; 196 + } 197 + 198 + async function checkHandleAvailability(handle: string): Promise<boolean> { 199 + const client = createLocalClient(); 200 + try { 201 + await client.resolveHandle(handle); 202 + return false; 203 + } catch { 204 + return true; 205 + } 206 + } 207 + 208 + async function validateRotationKey(): Promise<boolean> { 209 + if (!state.userDid || !state.rotationKey) { 210 + throw new Error("DID and rotation key are required"); 211 + } 212 + 213 + try { 214 + userRotationKeypair = await plcOps.getKeyPair(state.rotationKey.trim()); 215 + const { lastOperation } = await plcOps.getLastPlcOpFromPlc(state.userDid); 216 + const currentRotationKeys = lastOperation.rotationKeys || []; 217 + 218 + if (!currentRotationKeys.includes(userRotationKeypair.didPublicKey)) { 219 + state.rotationKeyDidKey = ""; 220 + return false; 221 + } 222 + 223 + state.rotationKeyDidKey = userRotationKeypair.didPublicKey; 224 + 225 + const pdsService = lastOperation.services?.atproto_pds; 226 + if (pdsService?.endpoint) { 227 + state.oldPdsUrl = pdsService.endpoint; 228 + console.log( 229 + "[offline-migration] Captured old PDS URL:", 230 + state.oldPdsUrl, 231 + ); 232 + } else { 233 + console.warn( 234 + "[offline-migration] No PDS service endpoint found in PLC document", 235 + ); 236 + console.log( 237 + "[offline-migration] PLC services:", 238 + JSON.stringify(lastOperation.services), 239 + ); 240 + } 241 + 242 + saveOfflineState(state); 243 + return true; 244 + } catch (e) { 245 + throw new Error(`Failed to parse rotation key: ${(e as Error).message}`); 246 + } 247 + } 248 + 249 + async function prepareTempCredentials(): Promise<string> { 250 + if (!userRotationKeypair) { 251 + throw new Error("Rotation key not validated"); 252 + } 253 + 254 + setProgress({ currentOperation: "Preparing temporary credentials..." }); 255 + 256 + tempVerificationKeypair = await Secp256k1PrivateKeyExportable 257 + .createKeypair(); 258 + const tempVerificationPublicKey = await tempVerificationKeypair 259 + .exportPublicKey("did"); 260 + 261 + const { lastOperation, base } = await plcOps.getLastPlcOpFromPlc( 262 + state.userDid, 263 + ); 264 + const prevCid = base.cid; 265 + 266 + setProgress({ currentOperation: "Updating DID document temporarily..." }); 267 + 268 + const localPdsUrl = globalThis.location.origin; 269 + await plcOps.signAndPublishNewOp( 270 + state.userDid, 271 + userRotationKeypair.keypair, 272 + lastOperation.alsoKnownAs || [], 273 + [userRotationKeypair.didPublicKey], 274 + localPdsUrl, 275 + tempVerificationPublicKey, 276 + prevCid, 277 + ); 278 + 279 + state.plcUpdatedTemporarily = true; 280 + saveOfflineState(state); 281 + 282 + const serverInfo = await loadLocalServerInfo(); 283 + const serviceAuthToken = await plcOps.createServiceAuthToken( 284 + state.userDid, 285 + serverInfo.did, 286 + tempVerificationKeypair as unknown as PrivateKey, 287 + "com.atproto.server.createAccount", 288 + ); 289 + 290 + return serviceAuthToken; 291 + } 292 + 293 + async function createPasswordAccount( 294 + serviceAuthToken: string, 295 + ): Promise<void> { 296 + setProgress({ currentOperation: "Creating account on new PDS..." }); 297 + 298 + const serverInfo = await loadLocalServerInfo(); 299 + const fullHandle = state.targetHandle.includes(".") 300 + ? state.targetHandle 301 + : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; 302 + 303 + const createResult = await api.createAccountWithServiceAuth( 304 + serviceAuthToken, 305 + { 306 + did: state.userDid, 307 + handle: fullHandle, 308 + email: state.targetEmail, 309 + password: state.targetPassword, 310 + inviteCode: state.inviteCode || undefined, 311 + }, 312 + ); 313 + 314 + state.targetHandle = fullHandle; 315 + state.localAccessToken = createResult.accessJwt; 316 + state.localRefreshToken = createResult.refreshJwt; 317 + setProgress({ repoExported: true }); 318 + } 319 + 320 + async function createPasskeyAccount(serviceAuthToken: string): Promise<void> { 321 + setProgress({ currentOperation: "Creating passkey account on new PDS..." }); 322 + 323 + const serverInfo = await loadLocalServerInfo(); 324 + const fullHandle = state.targetHandle.includes(".") 325 + ? state.targetHandle 326 + : `${state.targetHandle}.${serverInfo.availableUserDomains[0]}`; 327 + 328 + const createResult = await api.createPasskeyAccount({ 329 + did: state.userDid, 330 + handle: fullHandle, 331 + email: state.targetEmail, 332 + inviteCode: state.inviteCode || undefined, 333 + }, serviceAuthToken); 334 + 335 + state.targetHandle = fullHandle; 336 + state.passkeySetupToken = createResult.setupToken; 337 + setProgress({ repoExported: true }); 338 + saveOfflineState(state); 339 + } 340 + 341 + async function signFinalPlcOperation(): Promise<void> { 342 + if (!userRotationKeypair || !state.localAccessToken) { 343 + throw new Error("Prerequisites not met for PLC signing"); 344 + } 345 + 346 + setProgress({ currentOperation: "Finalizing DID document..." }); 347 + 348 + const { base } = await plcOps.getLastPlcOpFromPlc(state.userDid); 349 + const prevCid = base.cid; 350 + 351 + const credentials = await api.getRecommendedDidCredentials( 352 + state.localAccessToken, 353 + ); 354 + 355 + await plcOps.signPlcOperationWithCredentials( 356 + state.userDid, 357 + userRotationKeypair.keypair, 358 + { 359 + rotationKeys: credentials.rotationKeys, 360 + alsoKnownAs: credentials.alsoKnownAs, 361 + verificationMethods: credentials.verificationMethods, 362 + services: credentials.services, 363 + }, 364 + [userRotationKeypair.didPublicKey], 365 + prevCid, 366 + ); 367 + 368 + setProgress({ plcSigned: true }); 369 + } 370 + 371 + async function importRepository(): Promise<void> { 372 + if (!state.carFile || !state.localAccessToken) { 373 + throw new Error("CAR file and access token are required"); 374 + } 375 + 376 + setProgress({ currentOperation: "Importing repository..." }); 377 + await api.importRepo(state.localAccessToken, state.carFile); 378 + setProgress({ repoImported: true }); 379 + } 380 + 381 + async function migrateBlobs(): Promise<void> { 382 + if (!state.localAccessToken) { 383 + throw new Error("Access token required"); 384 + } 385 + 386 + const localClient = createLocalClient(); 387 + localClient.setAccessToken(state.localAccessToken); 388 + 389 + if (state.oldPdsUrl) { 390 + setProgress({ 391 + currentOperation: `Will fetch blobs from ${state.oldPdsUrl}`, 392 + }); 393 + } else { 394 + setProgress({ 395 + currentOperation: "No source PDS URL available for blob migration", 396 + }); 397 + } 398 + 399 + const sourceClient = state.oldPdsUrl 400 + ? new AtprotoClient(state.oldPdsUrl) 401 + : null; 402 + 403 + const result = await migrateBlobsUtil( 404 + localClient, 405 + sourceClient, 406 + state.userDid, 407 + setProgress, 408 + ); 409 + 410 + state.progress.blobsFailed = result.failed; 411 + state.progress.blobsTotal = result.total; 412 + state.progress.blobsMigrated = result.migrated; 413 + 414 + if (result.total === 0) { 415 + setProgress({ currentOperation: "No blobs to migrate" }); 416 + } else if (result.sourceUnreachable) { 417 + setProgress({ 418 + currentOperation: 419 + `Source PDS unreachable. ${result.failed.length} blobs could not be migrated.`, 420 + }); 421 + } else if (result.failed.length > 0) { 422 + setProgress({ 423 + currentOperation: 424 + `${result.migrated}/${result.total} blobs migrated. ${result.failed.length} failed.`, 425 + }); 426 + } else { 427 + setProgress({ 428 + currentOperation: `All ${result.migrated} blobs migrated successfully`, 429 + }); 430 + } 431 + } 432 + 433 + async function activateAccount(): Promise<void> { 434 + if (!state.localAccessToken) { 435 + throw new Error("Access token required"); 436 + } 437 + 438 + setProgress({ currentOperation: "Activating account..." }); 439 + await api.activateAccount(state.localAccessToken); 440 + setProgress({ activated: true }); 441 + } 442 + 443 + async function submitEmailVerifyToken(token: string): Promise<void> { 444 + state.emailVerifyToken = token; 445 + setError(null); 446 + 447 + try { 448 + await api.verifyMigrationEmail(token, state.targetEmail); 449 + 450 + if (state.authMethod === "passkey") { 451 + setStep("passkey-setup"); 452 + } else { 453 + const session = await api.createSession( 454 + state.targetEmail, 455 + state.targetPassword, 456 + ); 457 + state.localAccessToken = session.accessJwt; 458 + state.localRefreshToken = session.refreshJwt; 459 + saveOfflineState(state); 460 + 461 + setStep("plc-signing"); 462 + await signFinalPlcOperation(); 463 + 464 + setStep("finalizing"); 465 + await activateAccount(); 466 + 467 + cleanup(); 468 + setStep("success"); 469 + } 470 + } catch (e) { 471 + const err = e as Error & { error?: string }; 472 + setError(err.message || err.error || "Email verification failed"); 473 + } 474 + } 475 + 476 + async function resendEmailVerification(): Promise<void> { 477 + await api.resendMigrationVerification(state.targetEmail); 478 + } 479 + 480 + let checkingEmailVerification = false; 481 + 482 + async function checkEmailVerifiedAndProceed(): Promise<boolean> { 483 + if (checkingEmailVerification) return false; 484 + if (state.authMethod === "passkey") return false; 485 + 486 + checkingEmailVerification = true; 487 + try { 488 + const { verified } = await api.checkEmailVerified(state.targetEmail); 489 + if (!verified) return false; 490 + 491 + const session = await api.createSession( 492 + state.targetEmail, 493 + state.targetPassword, 494 + ); 495 + state.localAccessToken = session.accessJwt; 496 + state.localRefreshToken = session.refreshJwt; 497 + saveOfflineState(state); 498 + 499 + setStep("plc-signing"); 500 + await signFinalPlcOperation(); 501 + 502 + setStep("finalizing"); 503 + await activateAccount(); 504 + 505 + cleanup(); 506 + setStep("success"); 507 + return true; 508 + } catch { 509 + return false; 510 + } finally { 511 + checkingEmailVerification = false; 512 + } 513 + } 514 + 515 + async function startPasskeyRegistration(): Promise<{ options: unknown }> { 516 + if (!state.passkeySetupToken) { 517 + throw new Error("No passkey setup token"); 518 + } 519 + 520 + return api.startPasskeyRegistrationForSetup( 521 + state.userDid, 522 + state.passkeySetupToken, 523 + ); 524 + } 525 + 526 + async function registerPasskey(passkeyName?: string): Promise<void> { 527 + if (!state.passkeySetupToken) { 528 + throw new Error("No passkey setup token"); 529 + } 530 + 531 + if (!globalThis.PublicKeyCredential) { 532 + throw new Error("Passkeys are not supported in this browser"); 533 + } 534 + 535 + const { options } = await startPasskeyRegistration(); 536 + 537 + const publicKeyOptions = prepareWebAuthnCreationOptions( 538 + options as { publicKey: Record<string, unknown> }, 539 + ); 540 + const credential = await navigator.credentials.create({ 541 + publicKey: publicKeyOptions, 542 + }); 543 + 544 + if (!credential) { 545 + throw new Error("Passkey creation was cancelled"); 546 + } 547 + 548 + const publicKeyCredential = credential as PublicKeyCredential; 549 + const response = publicKeyCredential 550 + .response as AuthenticatorAttestationResponse; 551 + 552 + const credentialData = { 553 + id: publicKeyCredential.id, 554 + rawId: base64UrlEncode(publicKeyCredential.rawId), 555 + type: publicKeyCredential.type, 556 + response: { 557 + clientDataJSON: base64UrlEncode(response.clientDataJSON), 558 + attestationObject: base64UrlEncode(response.attestationObject), 559 + }, 560 + }; 561 + 562 + const result = await api.completePasskeySetup( 563 + state.userDid, 564 + state.passkeySetupToken, 565 + credentialData, 566 + passkeyName, 567 + ); 568 + 569 + state.generatedAppPassword = result.appPassword; 570 + state.generatedAppPasswordName = result.appPasswordName; 571 + 572 + const session = await api.createSession( 573 + state.targetEmail, 574 + result.appPassword, 575 + ); 576 + state.localAccessToken = session.accessJwt; 577 + state.localRefreshToken = session.refreshJwt; 578 + saveOfflineState(state); 579 + 580 + setStep("app-password"); 581 + } 582 + 583 + async function proceedFromAppPassword(): Promise<void> { 584 + setStep("plc-signing"); 585 + await signFinalPlcOperation(); 586 + 587 + setStep("finalizing"); 588 + await activateAccount(); 589 + 590 + cleanup(); 591 + setStep("success"); 592 + } 593 + 594 + function cleanup(): void { 595 + clearOfflineState(); 596 + userRotationKeypair = null; 597 + tempVerificationKeypair = null; 598 + state.rotationKey = ""; 599 + } 600 + 601 + async function runMigration(): Promise<void> { 602 + try { 603 + setStep("creating"); 604 + 605 + const serviceAuthToken = await prepareTempCredentials(); 606 + 607 + if (state.authMethod === "passkey") { 608 + await createPasskeyAccount(serviceAuthToken); 609 + } else { 610 + await createPasswordAccount(serviceAuthToken); 611 + } 612 + 613 + setStep("importing"); 614 + await importRepository(); 615 + 616 + setStep("migrating-blobs"); 617 + await migrateBlobs(); 618 + 619 + if ( 620 + state.progress.blobsTotal > 0 || state.progress.blobsFailed.length > 0 621 + ) { 622 + await new Promise((resolve) => setTimeout(resolve, 3000)); 623 + } 624 + 625 + setStep("email-verify"); 626 + } catch (e) { 627 + setError((e as Error).message); 628 + setStep("error"); 629 + } 630 + } 631 + 632 + function reset() { 633 + clearOfflineState(); 634 + userRotationKeypair = null; 635 + tempVerificationKeypair = null; 636 + state = { 637 + direction: "offline-inbound", 638 + step: "welcome", 639 + userDid: "", 640 + carFile: null, 641 + carFileName: "", 642 + carSizeBytes: 0, 643 + carNeedsReupload: false, 644 + rotationKey: "", 645 + rotationKeyDidKey: "", 646 + oldPdsUrl: null, 647 + targetHandle: "", 648 + targetEmail: "", 649 + targetPassword: "", 650 + inviteCode: "", 651 + authMethod: "password", 652 + localAccessToken: null, 653 + localRefreshToken: null, 654 + passkeySetupToken: null, 655 + generatedAppPassword: null, 656 + generatedAppPasswordName: null, 657 + emailVerifyToken: "", 658 + progress: createInitialProgress(), 659 + error: null, 660 + plcUpdatedTemporarily: false, 661 + }; 662 + localServerInfo = null; 663 + } 664 + 665 + function tryResume(): boolean { 666 + const stored = loadOfflineState(); 667 + if (!stored) return false; 668 + 669 + state.userDid = stored.userDid; 670 + state.carFileName = stored.carFileName; 671 + state.carSizeBytes = stored.carSizeBytes; 672 + state.rotationKeyDidKey = stored.rotationKeyDidKey; 673 + state.targetHandle = stored.targetHandle; 674 + state.targetEmail = stored.targetEmail; 675 + state.authMethod = stored.authMethod ?? "password"; 676 + state.passkeySetupToken = stored.passkeySetupToken ?? null; 677 + state.oldPdsUrl = stored.oldPdsUrl ?? null; 678 + state.plcUpdatedTemporarily = stored.plcUpdatedTemporarily ?? false; 679 + state.step = stored.step; 680 + state.progress.repoExported = stored.progress.accountCreated; 681 + state.progress.repoImported = stored.progress.repoImported; 682 + state.progress.plcSigned = stored.progress.plcSigned; 683 + state.progress.activated = stored.progress.activated; 684 + state.error = stored.lastError ?? null; 685 + 686 + if (stored.carFileName && stored.carSizeBytes > 0) { 687 + state.carNeedsReupload = true; 688 + } 689 + 690 + return true; 691 + } 692 + 693 + function getLocalSession(): 694 + | { accessJwt: string; did: string; handle: string } 695 + | null { 696 + if (!state.localAccessToken) return null; 697 + return { 698 + accessJwt: state.localAccessToken, 699 + did: state.userDid, 700 + handle: state.targetHandle, 701 + }; 702 + } 703 + 704 + return { 705 + get state() { 706 + return state; 707 + }, 708 + getLocalSession, 709 + setStep, 710 + setError, 711 + setProgress, 712 + loadLocalServerInfo, 713 + checkHandleAvailability, 714 + validateRotationKey, 715 + runMigration, 716 + submitEmailVerifyToken, 717 + resendEmailVerification, 718 + checkEmailVerifiedAndProceed, 719 + startPasskeyRegistration, 720 + registerPasskey, 721 + proceedFromAppPassword, 722 + reset, 723 + tryResume, 724 + clearOfflineState, 725 + setUserDid(did: string) { 726 + state.userDid = did; 727 + saveOfflineState(state); 728 + }, 729 + setCarFile(file: Uint8Array, fileName: string) { 730 + state.carFile = file; 731 + state.carFileName = fileName; 732 + state.carSizeBytes = file.length; 733 + state.carNeedsReupload = false; 734 + saveOfflineState(state); 735 + }, 736 + setRotationKey(key: string) { 737 + state.rotationKey = key; 738 + }, 739 + setTargetHandle(handle: string) { 740 + state.targetHandle = handle; 741 + saveOfflineState(state); 742 + }, 743 + setTargetEmail(email: string) { 744 + state.targetEmail = email; 745 + saveOfflineState(state); 746 + }, 747 + setTargetPassword(password: string) { 748 + state.targetPassword = password; 749 + }, 750 + setInviteCode(code: string) { 751 + state.inviteCode = code; 752 + }, 753 + setAuthMethod(method: AuthMethod) { 754 + state.authMethod = method; 755 + saveOfflineState(state); 756 + }, 757 + updateField<K extends keyof OfflineInboundMigrationState>( 758 + field: K, 759 + value: OfflineInboundMigrationState[K], 760 + ) { 761 + state[field] = value; 762 + saveOfflineState(state); 763 + }, 764 + }; 765 + }
+281
frontend/src/lib/migration/plc-ops.ts
··· 1 + import { 2 + defs, 3 + type IndexedEntry, 4 + normalizeOp, 5 + type Operation, 6 + } from "@atcute/did-plc"; 7 + import { 8 + P256PrivateKey, 9 + parsePrivateMultikey, 10 + Secp256k1PrivateKey, 11 + Secp256k1PrivateKeyExportable, 12 + } from "@atcute/crypto"; 13 + import * as CBOR from "@atcute/cbor"; 14 + import { fromBase16, toBase64Url } from "@atcute/multibase"; 15 + 16 + export type PrivateKey = P256PrivateKey | Secp256k1PrivateKey; 17 + 18 + export interface KeypairInfo { 19 + type: "private_key"; 20 + didPublicKey: `did:key:${string}`; 21 + keypair: PrivateKey; 22 + } 23 + 24 + export interface PlcService { 25 + type: string; 26 + endpoint: string; 27 + } 28 + 29 + export interface PlcOperationData { 30 + type: "plc_operation"; 31 + prev: string; 32 + alsoKnownAs: string[]; 33 + rotationKeys: string[]; 34 + services: Record<string, PlcService>; 35 + verificationMethods: Record<string, string>; 36 + sig?: string; 37 + } 38 + 39 + const jsonToB64Url = (obj: unknown): string => { 40 + const enc = new TextEncoder(); 41 + const json = JSON.stringify(obj); 42 + return toBase64Url(enc.encode(json)); 43 + }; 44 + 45 + export class PlcOps { 46 + private plcDirectoryUrl: string; 47 + 48 + constructor(plcDirectoryUrl = "https://plc.directory") { 49 + this.plcDirectoryUrl = plcDirectoryUrl; 50 + } 51 + 52 + async getPlcAuditLogs(did: string): Promise<IndexedEntry[]> { 53 + const response = await fetch(`${this.plcDirectoryUrl}/${did}/log/audit`); 54 + if (!response.ok) { 55 + throw new Error(`Failed to fetch PLC audit logs: ${response.status}`); 56 + } 57 + const json = await response.json(); 58 + return defs.indexedEntryLog.parse(json); 59 + } 60 + 61 + async getLastPlcOpFromPlc( 62 + did: string, 63 + ): Promise<{ lastOperation: Operation; base: IndexedEntry }> { 64 + const logs = await this.getPlcAuditLogs(did); 65 + const lastOp = logs.at(-1); 66 + if (!lastOp) { 67 + throw new Error("No PLC operations found for this DID"); 68 + } 69 + return { lastOperation: normalizeOp(lastOp.operation), base: lastOp }; 70 + } 71 + 72 + async getCurrentRotationKeysForUser(did: string): Promise<string[]> { 73 + const { lastOperation } = await this.getLastPlcOpFromPlc(did); 74 + return lastOperation.rotationKeys || []; 75 + } 76 + 77 + async createNewSecp256k1Keypair(): Promise< 78 + { privateKey: string; publicKey: `did:key:${string}` } 79 + > { 80 + const keypair = await Secp256k1PrivateKeyExportable.createKeypair(); 81 + const publicKey = await keypair.exportPublicKey("did"); 82 + const privateKey = await keypair.exportPrivateKey("multikey"); 83 + return { privateKey, publicKey }; 84 + } 85 + 86 + async getKeyPair( 87 + privateKeyString: string, 88 + type: "secp256k1" | "p256" = "secp256k1", 89 + ): Promise<KeypairInfo> { 90 + const HEX_REGEX = /^[0-9a-f]+$/i; 91 + const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/; 92 + let keypair: PrivateKey | undefined; 93 + 94 + const trimmed = privateKeyString.trim(); 95 + 96 + if (HEX_REGEX.test(trimmed) && trimmed.length === 64) { 97 + const privateKeyBytes = fromBase16(trimmed); 98 + if (type === "p256") { 99 + keypair = await P256PrivateKey.importRaw(privateKeyBytes); 100 + } else { 101 + keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); 102 + } 103 + } else if (MULTIKEY_REGEX.test(trimmed)) { 104 + const match = parsePrivateMultikey(trimmed); 105 + const privateKeyBytes = match.privateKeyBytes; 106 + if (match.type === "p256") { 107 + keypair = await P256PrivateKey.importRaw(privateKeyBytes); 108 + } else if (match.type === "secp256k1") { 109 + keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes); 110 + } else { 111 + throw new Error(`Unsupported key type: ${match.type}`); 112 + } 113 + } else { 114 + throw new Error( 115 + "Invalid key format. Expected 64-char hex or multikey format.", 116 + ); 117 + } 118 + 119 + if (!keypair) { 120 + throw new Error("Failed to parse private key"); 121 + } 122 + 123 + return { 124 + type: "private_key", 125 + didPublicKey: await keypair.exportPublicKey("did"), 126 + keypair, 127 + }; 128 + } 129 + 130 + async signAndPublishNewOp( 131 + did: string, 132 + signingRotationKey: PrivateKey, 133 + alsoKnownAs: string[], 134 + rotationKeys: string[], 135 + pds: string, 136 + verificationKey: string, 137 + prev: string, 138 + ): Promise<void> { 139 + const rotationKeysToUse = [...new Set(rotationKeys)]; 140 + if (rotationKeysToUse.length === 0) { 141 + throw new Error("No rotation keys provided"); 142 + } 143 + if (rotationKeysToUse.length > 5) { 144 + throw new Error("Maximum 5 rotation keys allowed"); 145 + } 146 + 147 + const operation: PlcOperationData = { 148 + type: "plc_operation", 149 + prev, 150 + alsoKnownAs, 151 + rotationKeys: rotationKeysToUse, 152 + services: { 153 + atproto_pds: { 154 + type: "AtprotoPersonalDataServer", 155 + endpoint: pds, 156 + }, 157 + }, 158 + verificationMethods: { 159 + atproto: verificationKey, 160 + }, 161 + }; 162 + 163 + const opBytes = CBOR.encode(operation); 164 + const sigBytes = await signingRotationKey.sign(opBytes); 165 + const signature = toBase64Url(sigBytes); 166 + 167 + const signedOperation = { 168 + ...operation, 169 + sig: signature, 170 + }; 171 + 172 + await this.pushPlcOperation(did, signedOperation); 173 + } 174 + 175 + async pushPlcOperation( 176 + did: string, 177 + operation: PlcOperationData, 178 + ): Promise<void> { 179 + const response = await fetch(`${this.plcDirectoryUrl}/${did}`, { 180 + method: "POST", 181 + headers: { 182 + "Content-Type": "application/json", 183 + }, 184 + body: JSON.stringify(operation), 185 + }); 186 + 187 + if (!response.ok) { 188 + const contentType = response.headers.get("content-type"); 189 + if (contentType?.includes("application/json")) { 190 + const json = await response.json(); 191 + if ( 192 + typeof json === "object" && json !== null && 193 + typeof json.message === "string" 194 + ) { 195 + throw new Error(json.message); 196 + } 197 + } 198 + throw new Error(`PLC directory returned HTTP ${response.status}`); 199 + } 200 + } 201 + 202 + async createServiceAuthToken( 203 + iss: string, 204 + aud: string, 205 + keypair: PrivateKey, 206 + lxm: string, 207 + ): Promise<string> { 208 + const iat = Math.floor(Date.now() / 1000); 209 + const exp = iat + 60; 210 + 211 + const jti = (() => { 212 + const bytes = new Uint8Array(16); 213 + crypto.getRandomValues(bytes); 214 + return Array.from(bytes) 215 + .map((b) => b.toString(16).padStart(2, "0")) 216 + .join(""); 217 + })(); 218 + 219 + const header = { typ: "JWT", alg: "ES256K" }; 220 + const payload = { iat, iss, aud, exp, lxm, jti }; 221 + 222 + const headerB64 = jsonToB64Url(header); 223 + const payloadB64 = jsonToB64Url(payload); 224 + const toSignStr = `${headerB64}.${payloadB64}`; 225 + 226 + const toSignBytes = new TextEncoder().encode(toSignStr); 227 + const sigBytes = await keypair.sign(toSignBytes); 228 + const sigB64 = toBase64Url(sigBytes); 229 + 230 + return `${toSignStr}.${sigB64}`; 231 + } 232 + 233 + async signPlcOperationWithCredentials( 234 + did: string, 235 + signingKey: PrivateKey, 236 + credentials: { 237 + rotationKeys?: string[]; 238 + alsoKnownAs?: string[]; 239 + verificationMethods?: Record<string, string>; 240 + services?: Record<string, PlcService>; 241 + }, 242 + additionalRotationKeys: string[], 243 + prevCid: string, 244 + ): Promise<void> { 245 + const rotationKeys = [ 246 + ...new Set([ 247 + ...(additionalRotationKeys || []), 248 + ...(credentials.rotationKeys || []), 249 + ]), 250 + ]; 251 + 252 + if (rotationKeys.length === 0) { 253 + throw new Error("No rotation keys provided"); 254 + } 255 + if (rotationKeys.length > 5) { 256 + throw new Error("Maximum 5 rotation keys allowed"); 257 + } 258 + 259 + const operation: PlcOperationData = { 260 + type: "plc_operation", 261 + prev: prevCid, 262 + alsoKnownAs: credentials.alsoKnownAs || [], 263 + rotationKeys, 264 + services: credentials.services || {}, 265 + verificationMethods: credentials.verificationMethods || {}, 266 + }; 267 + 268 + const opBytes = CBOR.encode(operation); 269 + const sigBytes = await signingKey.sign(opBytes); 270 + const signature = toBase64Url(sigBytes); 271 + 272 + const signedOperation = { 273 + ...operation, 274 + sig: signature, 275 + }; 276 + 277 + await this.pushPlcOperation(did, signedOperation); 278 + } 279 + } 280 + 281 + export const plcOps = new PlcOps();
+35 -21
frontend/src/lib/migration/types.ts
··· 13 13 | "success" 14 14 | "error"; 15 15 16 - export type AuthMethod = "password" | "passkey"; 17 - 18 - export type OutboundStep = 16 + export type OfflineInboundStep = 19 17 | "welcome" 20 - | "target-pds" 21 - | "new-account" 18 + | "provide-did" 19 + | "upload-car" 20 + | "provide-rotation-key" 21 + | "choose-handle" 22 22 | "review" 23 - | "migrating" 24 - | "plc-token" 23 + | "creating" 24 + | "importing" 25 + | "migrating-blobs" 26 + | "plc-signing" 27 + | "email-verify" 28 + | "passkey-setup" 29 + | "app-password" 25 30 | "finalizing" 26 31 | "success" 27 32 | "error"; 28 33 29 - export type MigrationDirection = "inbound" | "outbound"; 34 + export type AuthMethod = "password" | "passkey"; 35 + 36 + export type MigrationDirection = "inbound"; 30 37 31 38 export interface MigrationProgress { 32 39 repoExported: boolean; ··· 68 75 resumeToStep?: InboundStep; 69 76 } 70 77 71 - export interface OutboundMigrationState { 72 - direction: "outbound"; 73 - step: OutboundStep; 74 - localDid: string; 75 - localHandle: string; 76 - targetPdsUrl: string; 77 - targetPdsDid: string; 78 + export interface OfflineInboundMigrationState { 79 + direction: "offline-inbound"; 80 + step: OfflineInboundStep; 81 + userDid: string; 82 + carFile: Uint8Array | null; 83 + carFileName: string; 84 + carSizeBytes: number; 85 + carNeedsReupload: boolean; 86 + rotationKey: string; 87 + rotationKeyDidKey: string; 88 + oldPdsUrl: string | null; 78 89 targetHandle: string; 79 90 targetEmail: string; 80 91 targetPassword: string; 81 92 inviteCode: string; 82 - targetAccessToken: string | null; 83 - targetRefreshToken: string | null; 84 - serviceAuthToken: string | null; 85 - plcToken: string; 93 + authMethod: AuthMethod; 94 + localAccessToken: string | null; 95 + localRefreshToken: string | null; 96 + passkeySetupToken: string | null; 97 + generatedAppPassword: string | null; 98 + generatedAppPasswordName: string | null; 99 + emailVerifyToken: string; 86 100 progress: MigrationProgress; 87 101 error: string | null; 88 - targetServerInfo: ServerDescription | null; 102 + plcUpdatedTemporarily: boolean; 89 103 } 90 104 91 - export type MigrationState = InboundMigrationState | OutboundMigrationState; 105 + export type MigrationState = InboundMigrationState; 92 106 93 107 export interface StoredMigrationState { 94 108 version: 1;
+152 -98
frontend/src/locales/en.json
··· 17 17 "dashboard": "Dashboard", 18 18 "backToDashboard": "← Dashboard", 19 19 "copied": "Copied!", 20 - "copyToClipboard": "Copy to Clipboard" 20 + "copyToClipboard": "Copy to Clipboard", 21 + 22 + "verifying": "Verifying...", 23 + "saving": "Saving...", 24 + "creating": "Creating...", 25 + "updating": "Updating...", 26 + "sending": "Sending...", 27 + "authenticating": "Authenticating...", 28 + "checking": "Checking...", 29 + "redirecting": "Redirecting...", 30 + 31 + "signIn": "Sign In", 32 + "verify": "Verify", 33 + "remove": "Remove", 34 + "revoke": "Revoke", 35 + "resendCode": "Resend Code", 36 + "startOver": "Start Over", 37 + "tryAgain": "Try Again", 38 + 39 + "password": "Password", 40 + "email": "Email", 41 + "emailAddress": "Email Address", 42 + "handle": "Handle", 43 + "did": "DID", 44 + "verificationCode": "Verification Code", 45 + "inviteCode": "Invite Code", 46 + "newPassword": "New Password", 47 + "confirmPassword": "Confirm Password", 48 + 49 + "enterSixDigitCode": "Enter 6-digit code", 50 + "passwordHint": "At least 8 characters", 51 + "enterPassword": "Enter your password", 52 + "emailPlaceholder": "you@example.com", 53 + 54 + "verified": "Verified", 55 + "disabled": "Disabled", 56 + "available": "Available", 57 + "deactivated": "Deactivated", 58 + "unverified": "Unverified", 59 + 60 + "backToLogin": "Back to Login", 61 + "backToSettings": "Back to Settings", 62 + "alreadyHaveAccount": "Already have an account?", 63 + "createAccount": "Create account", 64 + 65 + "passwordsMismatch": "Passwords do not match", 66 + "passwordTooShort": "Password must be at least 8 characters" 21 67 }, 22 68 "login": { 23 69 "title": "Sign In", ··· 49 95 "codeLabel": "Verification Code", 50 96 "codePlaceholder": "Enter 6-digit code", 51 97 "verifyButton": "Verify Account", 52 - "verifying": "Verifying...", 53 - "resendButton": "Resend Code", 54 - "resending": "Resending...", 55 - "resent": "Verification code resent!", 56 - "backToLogin": "Back to Login" 98 + "resent": "Verification code resent!" 57 99 }, 58 100 "register": { 59 101 "title": "Create Account", ··· 124 166 "inviteCodePlaceholder": "Enter your invite code", 125 167 "inviteCodeRequired": "required", 126 168 "createButton": "Create Account", 127 - "creating": "Creating account...", 128 169 "alreadyHaveAccount": "Already have an account?", 129 170 "signIn": "Sign in", 130 171 "wantPasswordless": "Want passwordless security?", ··· 179 220 "navAdminDesc": "Server stats and admin operations", 180 221 "navDidDocument": "DID Document", 181 222 "navDidDocumentDesc": "Manage your DID document for external migrations", 223 + "navDidDocumentDescActive": "Edit your DID document settings", 224 + "navBackup": "Download Backup", 225 + "navBackupDesc": "Download your repository as a CAR file", 226 + "downloadingBackup": "Downloading...", 227 + "backupFailed": "Failed to download backup", 182 228 "migrated": "Migrated", 183 229 "migratedTitle": "Account Migrated", 184 230 "migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.", ··· 208 254 "serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.", 209 255 "currentPds": "Current PDS URL", 210 256 "save": "Save Changes", 211 - "saving": "Saving...", 212 257 "success": "DID document updated successfully", 213 258 "saveFailed": "Failed to save DID document", 214 259 "loadFailed": "Failed to load DID document", ··· 246 291 "yourDomain": "Your Domain", 247 292 "yourDomainPlaceholder": "example.com", 248 293 "verifyAndUpdate": "Verify & Update Handle", 249 - "verifying": "Verifying...", 250 294 "newHandle": "New Handle", 251 295 "newHandlePlaceholder": "yourhandle", 252 296 "changeHandleButton": "Change Handle", ··· 262 306 "exportData": "Export Data", 263 307 "exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.", 264 308 "downloadRepo": "Download Repository", 309 + "downloadBlobs": "Download Media", 265 310 "exporting": "Exporting...", 311 + "backups": { 312 + "title": "Backups", 313 + "description": "Your repository is automatically backed up daily. You can also create manual backups or restore from a previous backup.", 314 + "enableAutomatic": "Enable automatic backups", 315 + "enabled": "Automatic backups enabled", 316 + "disabled": "Automatic backups disabled", 317 + "toggleFailed": "Failed to update backup setting", 318 + "noBackups": "No backups available yet.", 319 + "blocks": "blocks", 320 + "download": "Download", 321 + "delete": "Delete", 322 + "createNow": "Create Backup Now", 323 + "created": "Backup created successfully", 324 + "createFailed": "Failed to create backup", 325 + "downloadFailed": "Failed to download backup", 326 + "deleted": "Backup deleted", 327 + "deleteFailed": "Failed to delete backup", 328 + "restoreTitle": "Restore from Backup", 329 + "restoreDescription": "Upload a CAR file to restore your repository. This will overwrite your current data.", 330 + "selectFile": "Select CAR file", 331 + "selectedFile": "Selected file", 332 + "restore": "Restore", 333 + "restoring": "Restoring...", 334 + "restored": "Repository restored successfully", 335 + "restoreFailed": "Failed to restore repository" 336 + }, 266 337 "deleteAccount": "Delete Account", 267 338 "deleteWarning": "This action is irreversible. All your data will be permanently deleted.", 268 339 "requestDeletion": "Request Account Deletion", ··· 291 362 "deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.", 292 363 "deletionFailed": "Failed to delete account", 293 364 "repoExported": "Repository exported successfully", 294 - "exportFailed": "Failed to export repository", 365 + "blobsExported": "Media files exported successfully", 366 + "noBlobsToExport": "No media files to export", 367 + "exportFailed": "Failed to export", 295 368 "confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone." 296 369 } 297 370 }, ··· 306 379 "noPasswords": "No app passwords yet", 307 380 "revoke": "Revoke", 308 381 "revoking": "Revoking...", 309 - "creating": "Creating...", 310 382 "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.", 311 383 "saveWarningTitle": "Important: Save this app password!", 312 384 "saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.", ··· 354 426 "used": "Used by @{handle}", 355 427 "disabled": "Disabled", 356 428 "usedBy": "Used by", 357 - "creating": "Creating...", 358 429 "disableConfirm": "Disable this invite code? It can no longer be used.", 359 430 "created": "Invite Code Created", 360 431 "copy": "Copy", ··· 482 553 "verifyButton": "Verify", 483 554 "verifyCodePlaceholder": "Enter verification code", 484 555 "submit": "Submit", 485 - "saving": "Saving...", 486 556 "savePreferences": "Save Preferences", 487 557 "preferencesSaved": "Communication preferences saved", 488 558 "verifiedSuccess": "{channel} verified successfully", ··· 521 591 "noCollectionsYet": "No collections yet. Create your first record to get started.", 522 592 "loadMore": "Load More", 523 593 "recordJson": "Record JSON", 524 - "saving": "Saving...", 525 594 "updateRecord": "Update Record", 526 595 "collectionNsid": "Collection (NSID)", 527 596 "recordKeyOptional": "Record Key (optional)", 528 597 "autoGenerated": "Auto-generated if empty (TID)", 529 598 "autoGeneratedHint": "Leave empty to auto-generate a TID-based key", 530 - "creating": "Creating...", 531 599 "demoPostText": "Hello from my PDS! This is my first post.", 532 600 "demoDisplayName": "Your Display Name", 533 601 "demoBio": "A short bio about yourself." ··· 551 619 "secondaryLight": "Secondary (Light Mode)", 552 620 "secondaryDark": "Secondary (Dark Mode)", 553 621 "configSaved": "Server configuration saved", 554 - "saving": "Saving...", 555 622 "saveConfig": "Save Configuration", 556 623 "serverStats": "Server Statistics", 557 624 "users": "Users", ··· 639 706 "title": "Two-Factor Authentication", 640 707 "subtitle": "Additional verification is required", 641 708 "usePasskey": "Use Passkey", 642 - "useTotp": "Use Authenticator App", 643 - "verifying": "Verifying..." 709 + "useTotp": "Use Authenticator App" 644 710 }, 645 711 "twoFactorCode": { 646 712 "title": "Two-Factor Authentication", 647 713 "subtitle": "A verification code has been sent to your {channel}. Enter the code below to continue.", 648 714 "codeLabel": "Verification Code", 649 715 "codePlaceholder": "Enter 6-digit code", 650 - "verify": "Verify", 651 - "verifying": "Verifying...", 652 716 "errors": { 653 717 "missingRequestUri": "Missing request_uri parameter", 654 718 "verificationFailed": "Verification failed", ··· 660 724 "title": "Enter Authenticator Code", 661 725 "subtitle": "Enter the 6-digit code from your authenticator app", 662 726 "codePlaceholder": "Enter 6-digit code", 663 - "verify": "Verify", 664 - "verifying": "Verifying...", 665 727 "useBackupCode": "Use backup code instead", 666 728 "backupCodePlaceholder": "Enter backup code", 667 729 "trustDevice": "Trust this device for 30 days", ··· 691 753 "codeLabel": "Verification Code", 692 754 "codeHelp": "Copy the entire code from your message, including dashes", 693 755 "verifyButton": "Verify Account", 694 - "verify": "Verify", 695 - "verifying": "Verifying...", 696 756 "pleaseWait": "Please wait...", 697 - "resendCode": "Resend Code", 698 - "resending": "Resending...", 699 - "sending": "Sending...", 700 757 "codeResent": "Verification code resent!", 701 758 "codeResentDetail": "Verification code sent! Check your inbox.", 702 - "backToLogin": "Back to Login", 703 - "backToSettings": "Back to Settings", 704 759 "verifyingAccount": "Verifying account: @{handle}", 705 760 "startOver": "Start over with a different account", 706 761 "noPending": "No pending verification found.", ··· 746 801 "resetButton": "Reset Password", 747 802 "resetting": "Resetting...", 748 803 "success": "Password reset successfully!", 749 - "backToLogin": "Back to Sign In", 750 804 "requestNewCode": "Request New Code", 751 805 "passwordsMismatch": "Passwords do not match", 752 806 "passwordLength": "Password must be at least 8 characters" ··· 790 844 "howItWorks": "How it works", 791 845 "howItWorksDetail": "We'll send a secure link to your registered notification channel. Click the link to set a temporary password. Then you can sign in and add a new passkey.", 792 846 "sendRecoveryLink": "Send Recovery Link", 793 - "sending": "Sending...", 794 - "backToLogin": "Back to Sign In" 847 + "sending": "Sending..." 795 848 }, 796 849 "registerPasskey": { 797 850 "title": "Create Passkey Account", ··· 814 867 "inviteCode": "Invite Code", 815 868 "inviteCodePlaceholder": "Enter your invite code", 816 869 "createButton": "Create Account", 817 - "creating": "Creating...", 818 870 "continue": "Continue", 819 871 "back": "Back", 820 872 "alreadyHaveAccount": "Already have an account?", ··· 911 963 "useTotp": "Use Authenticator", 912 964 "passwordPlaceholder": "Enter your password", 913 965 "totpPlaceholder": "Enter 6-digit code", 914 - "verify": "Verify", 915 - "verifying": "Verifying...", 916 966 "authenticating": "Authenticating...", 917 967 "passkeyPrompt": "Click the button below to authenticate with your passkey.", 918 968 "cancel": "Cancel" ··· 947 997 "handle": "Handle", 948 998 "emailOptional": "Email (optional)", 949 999 "yourAccessLevel": "Your Access Level", 950 - "creating": "Creating...", 951 1000 "createAccount": "Create Account", 952 1001 "createDelegatedAccountButton": "+ Create Delegated Account", 953 1002 "accountCreated": "Created delegated account: {handle}", ··· 1059 1108 "navDesc": "Move your account to or from another PDS", 1060 1109 "migrateHere": "Migrate Here", 1061 1110 "migrateHereDesc": "Move your existing AT Protocol account to this PDS from another server.", 1062 - "migrateAway": "Migrate Away", 1063 - "migrateAwayDesc": "Move your account from this PDS to another server.", 1064 - "loginRequired": "Login required", 1065 1111 "bringDid": "Bring your DID and identity", 1066 1112 "transferData": "Transfer all your data", 1067 1113 "keepFollowers": "Keep your followers", 1068 - "exportRepo": "Export your repository", 1069 - "transferToPds": "Transfer to new PDS", 1070 - "updateIdentity": "Update your identity", 1071 1114 "whatIsMigration": "What is account migration?", 1072 1115 "whatIsMigrationDesc": "Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes). Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.", 1073 1116 "beforeMigrate": "Before you migrate", ··· 1077 1120 "beforeMigrate4": "Your old PDS will be notified to deactivate your account", 1078 1121 "importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.", 1079 1122 "learnMore": "Learn more about migration risks", 1080 - "comingSoon": "Coming soon", 1123 + "offlineRestore": "Offline Restore", 1124 + "offlineRestoreDesc": "Restore from backup when your old PDS is unavailable.", 1125 + "offlineFeature1": "Use a CAR file backup", 1126 + "offlineFeature2": "Prove ownership with rotation key", 1127 + "offlineFeature3": "Recovery for shutdown servers", 1081 1128 "oauthCompleting": "Completing authentication...", 1082 1129 "oauthFailed": "Authentication Failed", 1083 1130 "tryAgain": "Try Again", ··· 1086 1133 "incomplete": "You have an incomplete migration in progress:", 1087 1134 "direction": "Direction", 1088 1135 "migratingHere": "Migrating here", 1089 - "migratingAway": "Migrating away", 1090 1136 "from": "From", 1091 1137 "to": "To", 1092 1138 "progress": "Progress", ··· 1229 1275 "error": { 1230 1276 "title": "Migration Error", 1231 1277 "desc": "An error occurred during migration.", 1232 - "startOver": "Start Over" 1278 + "startOver": "Start Over", 1279 + "unknown": "An unknown error occurred." 1233 1280 }, 1234 1281 "common": { 1235 1282 "back": "Back", ··· 1247 1294 "warning3": "Your old account will be deactivated after migration" 1248 1295 } 1249 1296 }, 1250 - "outbound": { 1297 + "offline": { 1251 1298 "welcome": { 1252 - "title": "Migrate Away from This PDS", 1253 - "desc": "Move your account to another Personal Data Server.", 1254 - "warning": "After migration, your account here will be deactivated.", 1255 - "didWebNotice": "did:web Migration Notice", 1256 - "didWebNoticeDesc": "Your account uses a did:web identifier ({did}). After migrating, this PDS will continue to serve your DID document pointing to the new PDS. Your identity will remain functional as long as this server is online.", 1257 - "understand": "I understand the risks and want to proceed" 1299 + "title": "Offline Restore", 1300 + "desc": "Restore your account when your old PDS is unavailable. This is for disaster recovery when you cannot contact your previous server.", 1301 + "warningTitle": "Advanced Recovery Method", 1302 + "warningDesc": "This method requires your rotation key private key. Only use this if your previous PDS has shut down or you cannot access it.", 1303 + "requirementsTitle": "You will need:", 1304 + "requirement1": "Your DID (did:plc:...)", 1305 + "requirement2": "A CAR file backup of your repository", 1306 + "requirement3": "Your rotation key (private key in hex, base58, or JWK format)", 1307 + "understand": "I understand this is for offline recovery only" 1258 1308 }, 1259 - "targetPds": { 1260 - "title": "Choose Target PDS", 1261 - "desc": "Enter the URL of the PDS you want to migrate to.", 1262 - "url": "PDS URL", 1263 - "urlPlaceholder": "https://pds.example.com", 1264 - "validate": "Validate & Continue", 1309 + "provideDid": { 1310 + "title": "Enter Your DID", 1311 + "desc": "Enter the DID of the account you want to restore.", 1312 + "label": "Your DID", 1313 + "hint": "Your decentralized identifier (e.g., did:plc:abc123...)" 1314 + }, 1315 + "uploadCar": { 1316 + "title": "Upload Repository Backup", 1317 + "desc": "Upload the CAR file containing your repository data.", 1318 + "label": "CAR File", 1319 + "hint": "This should be a .car file from a previous backup of your repository", 1320 + "reuploadWarningTitle": "CAR File Required", 1321 + "reuploadWarning": "Your session was restored, but you need to re-upload your CAR file. For security reasons, file contents are not stored between sessions." 1322 + }, 1323 + "rotationKey": { 1324 + "title": "Provide Rotation Key", 1325 + "desc": "Enter your rotation key to prove ownership of this DID.", 1326 + "securityWarningTitle": "Security Warning", 1327 + "securityWarning1": "Your rotation key is extremely sensitive - anyone with it can take over your identity", 1328 + "securityWarning2": "Only enter it on trusted devices and connections", 1329 + "securityWarning3": "The key will not be stored after migration", 1330 + "label": "Rotation Key", 1331 + "placeholder": "Paste your rotation key (hex, base58, or JWK)...", 1332 + "hint": "Supports 64-character hex, base58, or JWK format", 1333 + "valid": "Rotation key verified! You have control of this DID.", 1334 + "invalid": "This key is not a valid rotation key for this DID.", 1265 1335 "validating": "Validating...", 1266 - "connected": "Connected to {name}", 1267 - "inviteRequired": "Invite code required", 1268 - "privacyPolicy": "Privacy Policy", 1269 - "termsOfService": "Terms of Service" 1336 + "validate": "Validate Key" 1270 1337 }, 1271 - "newAccount": { 1272 - "title": "New Account Details", 1273 - "desc": "Set up your account on the new PDS.", 1274 - "handle": "Handle", 1275 - "availableDomains": "Available domains", 1276 - "email": "Email", 1277 - "password": "Password", 1278 - "confirmPassword": "Confirm Password", 1279 - "inviteCode": "Invite Code" 1338 + "chooseHandle": { 1339 + "migratingDid": "Restoring DID" 1280 1340 }, 1281 1341 "review": { 1282 - "title": "Review Migration", 1283 - "desc": "Please review and confirm your migration details.", 1284 - "currentHandle": "Current Handle", 1285 - "newHandle": "New Handle", 1286 - "sourcePds": "This PDS", 1287 - "targetPds": "Target PDS", 1288 - "confirm": "I confirm I want to migrate my account", 1289 - "startMigration": "Start Migration" 1342 + "desc": "Please confirm the details of your offline restoration.", 1343 + "carFile": "CAR File", 1344 + "rotationKey": "Rotation Key", 1345 + "warning": "After you click \"Start Migration\", your repository will be imported and your DID will be updated to point to this PDS.", 1346 + "plcWarningTitle": "Point of No Return", 1347 + "plcWarning": "Once you start, your DID document will be updated to point to this PDS. If something goes wrong, you can use your rotation key to recover, but you should complete the migration to avoid a broken identity state." 1290 1348 }, 1291 1349 "migrating": { 1292 - "title": "Migrating Your Account", 1293 - "desc": "Please wait while we transfer your data..." 1294 - }, 1295 - "plcToken": { 1296 - "title": "Verify Your Identity", 1297 - "desc": "A verification code has been sent to your email." 1350 + "title": "Restoring Account", 1351 + "desc": "Please wait while your account is being restored...", 1352 + "creating": "Creating account", 1353 + "importing": "Importing repository", 1354 + "plcSigning": "Signing identity update", 1355 + "activating": "Activating account" 1298 1356 }, 1299 - "finalizing": { 1300 - "title": "Finalizing Migration", 1301 - "desc": "Please wait while we complete the migration...", 1302 - "updatingForwarding": "Updating DID document forwarding..." 1357 + "blobs": { 1358 + "title": "Migrating Blobs", 1359 + "desc": "Attempting to recover images and media from your old PDS...", 1360 + "migrating": "Migrating blobs", 1361 + "failedTitle": "Some blobs could not be migrated", 1362 + "failedDesc": "{count} blobs could not be fetched from your old PDS. This may happen if the server is unreachable or the files were deleted.", 1363 + "sourceUnreachableTitle": "Source PDS Unreachable", 1364 + "sourceUnreachable": "Could not connect to your old PDS to fetch media files. This is common when migrating from a shut-down server. Your posts will work, but some images may be missing." 1303 1365 }, 1304 1366 "success": { 1305 - "title": "Migration Complete!", 1306 - "desc": "Your account has been successfully migrated to your new PDS.", 1307 - "newHandle": "New Handle", 1308 - "newPds": "New PDS", 1309 - "nextSteps": "Next Steps", 1310 - "nextSteps1": "Sign in to your new PDS", 1311 - "nextSteps2": "Update any apps with your new credentials", 1312 - "nextSteps3": "Your followers will automatically see your new location", 1313 - "loggingOut": "Logging you out in {seconds} seconds..." 1367 + "desc": "Your account has been successfully restored to this PDS." 1314 1368 } 1315 1369 }, 1316 1370 "progress": {
+154 -100
frontend/src/locales/fi.json
··· 17 17 "dashboard": "Hallintapaneeli", 18 18 "backToDashboard": "← Hallintapaneeli", 19 19 "copied": "Kopioitu!", 20 - "copyToClipboard": "Kopioi" 20 + "copyToClipboard": "Kopioi", 21 + 22 + "verifying": "Vahvistetaan...", 23 + "saving": "Tallennetaan...", 24 + "creating": "Luodaan...", 25 + "updating": "Päivitetään...", 26 + "sending": "Lähetetään...", 27 + "authenticating": "Todennetaan...", 28 + "checking": "Tarkistetaan...", 29 + "redirecting": "Ohjataan...", 30 + 31 + "signIn": "Kirjaudu sisään", 32 + "verify": "Vahvista", 33 + "remove": "Poista", 34 + "revoke": "Peruuta", 35 + "resendCode": "Lähetä koodi uudelleen", 36 + "startOver": "Aloita alusta", 37 + "tryAgain": "Yritä uudelleen", 38 + 39 + "password": "Salasana", 40 + "email": "Sähköposti", 41 + "emailAddress": "Sähköpostiosoite", 42 + "handle": "Käsittely", 43 + "did": "DID", 44 + "verificationCode": "Vahvistuskoodi", 45 + "inviteCode": "Kutsukoodi", 46 + "newPassword": "Uusi salasana", 47 + "confirmPassword": "Vahvista salasana", 48 + 49 + "enterSixDigitCode": "Syötä 6-numeroinen koodi", 50 + "passwordHint": "Vähintään 8 merkkiä", 51 + "enterPassword": "Syötä salasanasi", 52 + "emailPlaceholder": "sinä@esimerkki.com", 53 + 54 + "verified": "Vahvistettu", 55 + "disabled": "Poistettu käytöstä", 56 + "available": "Saatavilla", 57 + "deactivated": "Deaktivoitu", 58 + "unverified": "Vahvistamaton", 59 + 60 + "backToLogin": "Takaisin kirjautumiseen", 61 + "backToSettings": "Takaisin asetuksiin", 62 + "alreadyHaveAccount": "Onko sinulla jo tili?", 63 + "createAccount": "Luo tili", 64 + 65 + "passwordsMismatch": "Salasanat eivät täsmää", 66 + "passwordTooShort": "Salasanan on oltava vähintään 8 merkkiä" 21 67 }, 22 68 "login": { 23 69 "title": "Kirjaudu sisään", ··· 49 95 "codeLabel": "Vahvistuskoodi", 50 96 "codePlaceholder": "Syötä 6-numeroinen koodi", 51 97 "verifyButton": "Vahvista tili", 52 - "verifying": "Vahvistetaan...", 53 - "resendButton": "Lähetä koodi uudelleen", 54 - "resending": "Lähetetään uudelleen...", 55 - "resent": "Vahvistuskoodi lähetetty uudelleen!", 56 - "backToLogin": "Takaisin kirjautumiseen" 98 + "resent": "Vahvistuskoodi lähetetty uudelleen!" 57 99 }, 58 100 "register": { 59 101 "title": "Luo tili", ··· 124 166 "inviteCodePlaceholder": "Syötä kutsukoodisi", 125 167 "inviteCodeRequired": "vaaditaan", 126 168 "createButton": "Luo tili", 127 - "creating": "Luodaan tiliä...", 128 169 "alreadyHaveAccount": "Onko sinulla jo tili?", 129 170 "signIn": "Kirjaudu sisään", 130 171 "wantPasswordless": "Haluatko salasanattoman turvallisuuden?", ··· 179 220 "navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot", 180 221 "navDidDocument": "DID-dokumentti", 181 222 "navDidDocumentDesc": "Hallitse DID-dokumenttiasi ulkoisia siirtoja varten", 223 + "navDidDocumentDescActive": "Muokkaa DID-dokumentin asetuksia", 224 + "navBackup": "Lataa varmuuskopio", 225 + "navBackupDesc": "Lataa tietovarastosi CAR-tiedostona", 226 + "downloadingBackup": "Ladataan...", 227 + "backupFailed": "Varmuuskopion lataus epäonnistui", 182 228 "migrated": "Siirretty", 183 229 "migratedTitle": "Tili siirretty", 184 230 "migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isännöidään edelleen täällä, ja voit päivittää sen tulevia siirtoja varten.", ··· 208 254 "serviceEndpointDesc": "PDS, joka tällä hetkellä isännöi tilitietojasi. Päivitä tämä siirron yhteydessä.", 209 255 "currentPds": "Nykyinen PDS-URL", 210 256 "save": "Tallenna muutokset", 211 - "saving": "Tallennetaan...", 212 257 "success": "DID-dokumentti päivitetty onnistuneesti", 213 258 "saveFailed": "DID-dokumentin tallennus epäonnistui", 214 259 "loadFailed": "DID-dokumentin lataus epäonnistui", ··· 246 291 "yourDomain": "Verkkotunnuksesi", 247 292 "yourDomainPlaceholder": "esimerkki.fi", 248 293 "verifyAndUpdate": "Vahvista ja päivitä käyttäjänimi", 249 - "verifying": "Vahvistetaan...", 250 294 "newHandle": "Uusi käyttäjänimi", 251 295 "newHandlePlaceholder": "käyttäjänimesi", 252 296 "changeHandleButton": "Vaihda käyttäjänimi", ··· 262 306 "exportData": "Vie tiedot", 263 307 "exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tämä sisältää kaikki julkaisusi, tykkäyksesi, seuraamisesi ja muut tiedot.", 264 308 "downloadRepo": "Lataa tietovarasto", 309 + "downloadBlobs": "Lataa media", 265 310 "exporting": "Viedään...", 311 + "backups": { 312 + "title": "Varmuuskopiot", 313 + "description": "Tietovarastosi varmuuskopioidaan automaattisesti päivittäin. Voit myös luoda manuaalisia varmuuskopioita tai palauttaa aiemmasta varmuuskopiosta.", 314 + "enableAutomatic": "Ota automaattiset varmuuskopiot käyttöön", 315 + "enabled": "Automaattiset varmuuskopiot käytössä", 316 + "disabled": "Automaattiset varmuuskopiot pois käytöstä", 317 + "toggleFailed": "Varmuuskopioasetuksen päivitys epäonnistui", 318 + "noBackups": "Varmuuskopioita ei ole vielä saatavilla.", 319 + "blocks": "lohkoa", 320 + "download": "Lataa", 321 + "delete": "Poista", 322 + "createNow": "Luo varmuuskopio nyt", 323 + "created": "Varmuuskopio luotu onnistuneesti", 324 + "createFailed": "Varmuuskopion luonti epäonnistui", 325 + "downloadFailed": "Varmuuskopion lataus epäonnistui", 326 + "deleted": "Varmuuskopio poistettu", 327 + "deleteFailed": "Varmuuskopion poisto epäonnistui", 328 + "restoreTitle": "Palauta varmuuskopiosta", 329 + "restoreDescription": "Lataa CAR-tiedosto palauttaaksesi tietovarastosi. Tämä korvaa nykyiset tietosi.", 330 + "selectFile": "Valitse CAR-tiedosto", 331 + "selectedFile": "Valittu tiedosto", 332 + "restore": "Palauta", 333 + "restoring": "Palautetaan...", 334 + "restored": "Tietovarasto palautettu onnistuneesti", 335 + "restoreFailed": "Tietovaraston palautus epäonnistui" 336 + }, 266 337 "deleteAccount": "Poista tili", 267 338 "deleteWarning": "Tämä toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvästi.", 268 339 "requestDeletion": "Pyydä tilin poistoa", ··· 291 362 "deleteConfirmation": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua.", 292 363 "deletionFailed": "Tilin poisto epäonnistui", 293 364 "repoExported": "Tietovarasto viety", 294 - "exportFailed": "Tietovaraston vienti epäonnistui", 365 + "blobsExported": "Mediatiedostot viety", 366 + "noBlobsToExport": "Ei vietäviä mediatiedostoja", 367 + "exportFailed": "Vienti epäonnistui", 295 368 "confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua." 296 369 } 297 370 }, ··· 306 379 "noPasswords": "Ei vielä sovellusten salasanoja", 307 380 "revoke": "Peruuta", 308 381 "revoking": "Peruutetaan...", 309 - "creating": "Luodaan...", 310 382 "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.", 311 383 "saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!", 312 384 "saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.", ··· 354 426 "used": "Käyttänyt @{handle}", 355 427 "disabled": "Poistettu käytöstä", 356 428 "usedBy": "Käyttänyt", 357 - "creating": "Luodaan...", 358 429 "disableConfirm": "Poista tämä kutsukoodi käytöstä? Sitä ei voi enää käyttää.", 359 430 "created": "Kutsukoodi luotu", 360 431 "copy": "Kopioi", ··· 482 553 "verifyButton": "Vahvista", 483 554 "verifyCodePlaceholder": "Syötä vahvistuskoodi", 484 555 "submit": "Lähetä", 485 - "saving": "Tallennetaan...", 486 556 "savePreferences": "Tallenna asetukset", 487 557 "preferencesSaved": "Viestintäasetukset tallennettu", 488 558 "verifiedSuccess": "{channel} vahvistettu", ··· 521 591 "noCollectionsYet": "Ei vielä kokoelmia. Luo ensimmäinen tietueesi aloittaaksesi.", 522 592 "loadMore": "Lataa lisää", 523 593 "recordJson": "Tietueen JSON", 524 - "saving": "Tallennetaan...", 525 594 "updateRecord": "Päivitä tietue", 526 595 "collectionNsid": "Kokoelma (NSID)", 527 596 "recordKeyOptional": "Tietueavain (valinnainen)", 528 597 "autoGenerated": "Luodaan automaattisesti jos tyhjä (TID)", 529 598 "autoGeneratedHint": "Jätä tyhjäksi luodaksesi TID-pohjaisen avaimen automaattisesti", 530 - "creating": "Luodaan...", 531 599 "demoPostText": "Hei PDS:ltäni! Tämä on ensimmäinen julkaisuni.", 532 600 "demoDisplayName": "Näyttönimesi", 533 601 "demoBio": "Lyhyt kuvaus itsestäsi." ··· 548 616 "primaryLight": "Ensisijainen (vaalea tila)", 549 617 "primaryDark": "Ensisijainen (tumma tila)", 550 618 "configSaved": "Palvelinasetukset tallennettu", 551 - "saving": "Tallennetaan...", 552 619 "saveConfig": "Tallenna asetukset", 553 620 "serverStats": "Palvelintilastot", 554 621 "users": "Käyttäjät", ··· 639 706 "title": "Kaksivaiheinen tunnistautuminen", 640 707 "subtitle": "Lisävahvistus vaaditaan", 641 708 "usePasskey": "Käytä pääsyavainta", 642 - "useTotp": "Käytä todentajasovellusta", 643 - "verifying": "Vahvistetaan..." 709 + "useTotp": "Käytä todentajasovellusta" 644 710 }, 645 711 "twoFactorCode": { 646 712 "title": "Kaksivaiheinen tunnistautuminen", 647 713 "subtitle": "Vahvistuskoodi on lähetetty {channel}. Syötä koodi alla jatkaaksesi.", 648 714 "codeLabel": "Vahvistuskoodi", 649 715 "codePlaceholder": "Syötä 6-numeroinen koodi", 650 - "verify": "Vahvista", 651 - "verifying": "Vahvistetaan...", 652 716 "errors": { 653 717 "missingRequestUri": "Puuttuva request_uri-parametri", 654 718 "verificationFailed": "Vahvistus epäonnistui", ··· 660 724 "title": "Syötä todentajakoodi", 661 725 "subtitle": "Syötä 6-numeroinen koodi todentajasovelluksestasi", 662 726 "codePlaceholder": "Syötä 6-numeroinen koodi", 663 - "verify": "Vahvista", 664 - "verifying": "Vahvistetaan...", 665 727 "useBackupCode": "Käytä varakoodia sen sijaan", 666 728 "backupCodePlaceholder": "Syötä varakoodi", 667 729 "trustDevice": "Luota tähän laitteeseen 30 päivää", ··· 691 753 "codeLabel": "Vahvistuskoodi", 692 754 "codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat", 693 755 "verifyButton": "Vahvista tili", 694 - "verify": "Vahvista", 695 - "verifying": "Vahvistetaan...", 696 756 "pleaseWait": "Odota...", 697 - "sending": "Lähetetään...", 698 - "resendCode": "Lähetä koodi uudelleen", 699 - "resending": "Lähetetään uudelleen...", 700 757 "codeResent": "Vahvistuskoodi lähetetty uudelleen!", 701 758 "codeResentDetail": "Vahvistuskoodi lähetetty! Tarkista saapuneet-kansiosi.", 702 759 "verified": "Vahvistettu!", ··· 706 763 "identifierLabel": "Sähköposti tai tunniste", 707 764 "identifierPlaceholder": "sinä@esimerkki.fi", 708 765 "identifierHelp": "Sähköpostiosoite tai tunniste, johon koodi lähetettiin", 709 - "backToLogin": "Takaisin kirjautumiseen", 710 766 "verifyingAccount": "Vahvistetaan tiliä: @{handle}", 711 767 "startOver": "Aloita alusta toisella tilillä", 712 768 "noPending": "Odottavaa vahvistusta ei löytynyt.", 713 769 "noPendingInfo": "Jos loit tilin äskettäin ja sinun on vahvistettava se, sinun on ehkä luotava uusi tili. Jos olet jo vahvistanut tilisi, voit kirjautua sisään.", 714 770 "createAccount": "Luo tili", 715 771 "signIn": "Kirjaudu sisään", 716 - "backToSettings": "Takaisin asetuksiin", 717 772 "emailUpdateCodeHelp": "Koodi lähetettiin nykyiseen sähköpostiosoitteeseesi", 718 773 "emailUpdateFailed": "Sähköpostiosoitteen päivitys epäonnistui", 719 774 "emailUpdateRequiresAuth": "Sinun on kirjauduttava sisään päivittääksesi sähköpostiosoitteesi.", ··· 746 801 "resetButton": "Palauta salasana", 747 802 "resetting": "Palautetaan...", 748 803 "success": "Salasana palautettu!", 749 - "backToLogin": "Takaisin kirjautumiseen", 750 804 "requestNewCode": "Pyydä uusi koodi", 751 805 "passwordsMismatch": "Salasanat eivät täsmää", 752 806 "passwordLength": "Salasanan on oltava vähintään 8 merkkiä" ··· 790 844 "howItWorks": "Miten se toimii", 791 845 "howItWorksDetail": "Lähetämme suojatun linkin rekisteröityyn ilmoituskanavaasi. Klikkaa linkkiä asettaaksesi väliaikaisen salasanan. Sitten voit kirjautua sisään ja lisätä uuden pääsyavaimen.", 792 846 "sendRecoveryLink": "Lähetä palautuslinkki", 793 - "sending": "Lähetetään...", 794 - "backToLogin": "Takaisin kirjautumiseen" 847 + "sending": "Lähetetään..." 795 848 }, 796 849 "registerPasskey": { 797 850 "title": "Luo pääsyavaintili", ··· 812 865 "externalDid": "Sinun did:web", 813 866 "externalDidPlaceholder": "did:web:verkkotunnuksesi.fi", 814 867 "createButton": "Luo tili", 815 - "creating": "Luodaan...", 816 868 "alreadyHaveAccount": "Onko sinulla jo tili?", 817 869 "signIn": "Kirjaudu sisään", 818 870 "wantPassword": "Haluatko käyttää salasanaa?", ··· 911 963 "useTotp": "Käytä todentajaa", 912 964 "passwordPlaceholder": "Syötä salasanasi", 913 965 "totpPlaceholder": "Syötä 6-numeroinen koodi", 914 - "verify": "Vahvista", 915 - "verifying": "Vahvistetaan...", 916 966 "authenticating": "Todennetaan...", 917 967 "passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.", 918 968 "cancel": "Peruuta" ··· 967 1017 "handle": "Käyttäjänimi", 968 1018 "emailOptional": "Sähköposti (valinnainen)", 969 1019 "yourAccessLevel": "Käyttöoikeustasosi", 970 - "creating": "Luodaan...", 971 1020 "createAccount": "Luo tili", 972 1021 "createDelegatedAccountButton": "+ Luo delegoitu tili", 973 1022 "accountCreated": "Delegoitu tili luotu: {handle}", ··· 1059 1108 "navDesc": "Siirrä tilisi toiseen tai toisesta PDS:stä", 1060 1109 "migrateHere": "Siirrä tänne", 1061 1110 "migrateHereDesc": "Siirrä olemassa oleva AT Protocol -tilisi tähän PDS:ään toiselta palvelimelta.", 1062 - "migrateAway": "Siirrä pois", 1063 - "migrateAwayDesc": "Siirrä tilisi tästä PDS:stä toiselle palvelimelle.", 1064 - "loginRequired": "Kirjautuminen vaaditaan", 1065 1111 "bringDid": "Tuo DID ja identiteettisi", 1066 1112 "transferData": "Siirrä kaikki tietosi", 1067 1113 "keepFollowers": "Säilytä seuraajasi", 1068 - "exportRepo": "Vie tietovarastosi", 1069 - "transferToPds": "Siirrä uuteen PDS:ään", 1070 - "updateIdentity": "Päivitä identiteettisi", 1071 1114 "whatIsMigration": "Mikä on tilin siirto?", 1072 1115 "whatIsMigrationDesc": "Tilin siirto mahdollistaa AT Protocol -identiteettisi siirtämisen henkilökohtaisten datapalvelimien (PDS) välillä. DID (hajautettu tunniste) pysyy samana, joten seuraajasi ja sosiaaliset yhteytesi säilyvät.", 1073 1116 "beforeMigrate": "Ennen siirtoa", ··· 1077 1120 "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista", 1078 1121 "importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.", 1079 1122 "learnMore": "Lue lisää siirron riskeistä", 1080 - "comingSoon": "Tulossa pian", 1123 + "offlineRestore": "Offline-palautus", 1124 + "offlineRestoreDesc": "Palauta varmuuskopiosta, kun vanha PDS ei ole käytettävissä.", 1125 + "offlineFeature1": "Käytä CAR-tiedoston varmuuskopiota", 1126 + "offlineFeature2": "Todista omistajuus rotaatioavaimella", 1127 + "offlineFeature3": "Palautus suljetuille palvelimille", 1081 1128 "oauthCompleting": "Viimeistellään todennusta...", 1082 1129 "oauthFailed": "Todennus epäonnistui", 1083 1130 "tryAgain": "Yritä uudelleen", ··· 1086 1133 "incomplete": "Sinulla on keskeneräinen siirto:", 1087 1134 "direction": "Suunta", 1088 1135 "migratingHere": "Siirretään tänne", 1089 - "migratingAway": "Siirretään pois", 1090 1136 "from": "Mistä", 1091 1137 "to": "Minne", 1092 1138 "progress": "Edistyminen", ··· 1229 1275 "error": { 1230 1276 "title": "Siirtovirhe", 1231 1277 "desc": "Siirron aikana tapahtui virhe.", 1232 - "startOver": "Aloita alusta" 1278 + "startOver": "Aloita alusta", 1279 + "unknown": "Tuntematon virhe tapahtui." 1233 1280 }, 1234 1281 "common": { 1235 1282 "back": "Takaisin", ··· 1247 1294 "warning3": "Vanha tilisi deaktivoidaan siirron jälkeen" 1248 1295 } 1249 1296 }, 1250 - "outbound": { 1297 + "offline": { 1251 1298 "welcome": { 1252 - "title": "Siirrä pois tästä PDS:stä", 1253 - "desc": "Siirrä tilisi toiseen henkilökohtaiseen datapalvelimeen.", 1254 - "warning": "Siirron jälkeen tilisi täällä deaktivoidaan.", 1255 - "didWebNotice": "did:web-siirtoilmoitus", 1256 - "didWebNoticeDesc": "Tilisi käyttää did:web-tunnistetta ({did}). Siirron jälkeen tämä PDS jatkaa DID-dokumenttisi tarjoamista osoittaen uuteen PDS:ään. Identiteettisi toimii niin kauan kuin tämä palvelin on päällä.", 1257 - "understand": "Ymmärrän riskit ja haluan jatkaa" 1299 + "title": "Palauta varmuuskopiosta", 1300 + "desc": "Palauta tilisi CAR-tiedoston varmuuskopiolla ja rotaatioavaimella. Käytä tätä, kun edellinen PDS ei ole käytettävissä.", 1301 + "warningTitle": "Milloin käyttää tätä menetelmää", 1302 + "warningDesc": "Tämä offline-palautus on katastrofipalautukseen, kun vanha PDS on suljettu, tavoittamattomissa tai sinut on lukittu ulos. Jos vanha PDS on edelleen käytettävissä, käytä normaalia siirtoa.", 1303 + "requirementsTitle": "Tarvitset", 1304 + "requirement1": "CAR-tiedoston varmuuskopion tietovarastostasi", 1305 + "requirement2": "Rotaatioavaimesi (DID:n yksityinen avain)", 1306 + "requirement3": "DID:si (did:plc:xxx)", 1307 + "understand": "Ymmärrän ja haluan jatkaa" 1258 1308 }, 1259 - "targetPds": { 1260 - "title": "Valitse kohde-PDS", 1261 - "desc": "Syötä sen PDS:n URL, johon haluat siirtyä.", 1262 - "url": "PDS URL", 1263 - "urlPlaceholder": "https://pds.example.com", 1264 - "validate": "Vahvista ja jatka", 1265 - "validating": "Vahvistetaan...", 1266 - "connected": "Yhdistetty: {name}", 1267 - "inviteRequired": "Kutsukoodi vaaditaan", 1268 - "privacyPolicy": "Tietosuojakäytäntö", 1269 - "termsOfService": "Käyttöehdot" 1309 + "provideDid": { 1310 + "title": "Syötä DID:si", 1311 + "desc": "Syötä palautettavan tilin DID.", 1312 + "label": "DID:si", 1313 + "hint": "Hajautettu tunnistesi (esim. did:plc:abc123)" 1270 1314 }, 1271 - "newAccount": { 1272 - "title": "Uuden tilin tiedot", 1273 - "desc": "Määritä tilisi uudessa PDS:ssä.", 1274 - "handle": "Käyttäjätunnus", 1275 - "availableDomains": "Käytettävissä olevat verkkotunnukset", 1276 - "email": "Sähköposti", 1277 - "password": "Salasana", 1278 - "confirmPassword": "Vahvista salasana", 1279 - "inviteCode": "Kutsukoodi" 1315 + "uploadCar": { 1316 + "title": "Lataa CAR-tiedosto", 1317 + "desc": "Lataa tietovaraston varmuuskopiotiedostosi.", 1318 + "label": "CAR-tiedosto", 1319 + "hint": "Valitse .car-tiedosto varmuuskopiostasi", 1320 + "reuploadWarningTitle": "CAR-tiedosto vaaditaan", 1321 + "reuploadWarning": "Istuntosi palautettiin, mutta sinun täytyy ladata CAR-tiedostosi uudelleen. Turvallisuussyistä tiedostosisältöä ei tallenneta istuntojen välillä." 1280 1322 }, 1281 - "review": { 1282 - "title": "Tarkista siirto", 1283 - "desc": "Tarkista ja vahvista siirtotietosi.", 1284 - "currentHandle": "Nykyinen käyttäjätunnus", 1285 - "newHandle": "Uusi käyttäjätunnus", 1286 - "sourcePds": "Tämä PDS", 1287 - "targetPds": "Kohde-PDS", 1288 - "confirm": "Vahvistan haluavani siirtää tilini", 1289 - "startMigration": "Aloita siirto" 1323 + "rotationKey": { 1324 + "title": "Anna rotaatioavain", 1325 + "desc": "Anna rotaatioavaimesi todistaaksesi tämän DID:n omistajuuden.", 1326 + "securityWarningTitle": "Turvallisuusvaroitus", 1327 + "securityWarning1": "Rotaatioavaimesi on erittäin arkaluonteinen - kohtele sitä kuten pääsalasanaa", 1328 + "securityWarning2": "Syötä se vain luotetuilla laitteilla ja verkoilla", 1329 + "securityWarning3": "Tätä avainta ei tallenneta siirron jälkeen", 1330 + "label": "Rotaatioavain", 1331 + "placeholder": "Syötä yksityinen avain (hex, base58 tai JWK)", 1332 + "hint": "Yksityinen avain, joka vastaa yhtä DID-dokumentin rotaatioavaimista", 1333 + "valid": "Avain on kelvollinen ja vastaa DID:si rotaatioavainta", 1334 + "invalid": "Avain ei vastaa mitään DID-dokumentin rotaatioavainta", 1335 + "validating": "Vahvistetaan avainta...", 1336 + "validate": "Vahvista avain" 1290 1337 }, 1291 - "migrating": { 1292 - "title": "Siirretään tiliäsi", 1293 - "desc": "Odota, kun siirrämme tietojasi..." 1338 + "chooseHandle": { 1339 + "migratingDid": "Palautetaan DID" 1294 1340 }, 1295 - "plcToken": { 1296 - "title": "Vahvista henkilöllisyytesi", 1297 - "desc": "Vahvistuskoodi on lähetetty sähköpostiisi." 1341 + "review": { 1342 + "desc": "Tarkista offline-palautuksen tiedot.", 1343 + "carFile": "CAR-tiedosto", 1344 + "rotationKey": "Rotaatioavain", 1345 + "warning": "Kun aloitat palautuksen, identiteettisi päivitetään osoittamaan tähän PDS:ään. Tätä ei voi helposti perua.", 1346 + "plcWarningTitle": "Ei paluuta", 1347 + "plcWarning": "Kun aloitat, DID-dokumenttisi päivitetään osoittamaan tähän PDS:ään. Jos jokin menee pieleen, voit käyttää rotaatioavaintasi palautumiseen, mutta sinun tulisi suorittaa siirto loppuun välttääksesi rikkinäisen identiteettitilan." 1298 1348 }, 1299 - "finalizing": { 1300 - "title": "Viimeistellään siirtoa", 1301 - "desc": "Odota, kun viimeistelemme siirtoa...", 1302 - "updatingForwarding": "Päivitetään DID-dokumentin uudelleenohjausta..." 1349 + "migrating": { 1350 + "title": "Palautetaan tiliä", 1351 + "desc": "Odota, tiliäsi palautetaan...", 1352 + "creating": "Luodaan tili", 1353 + "importing": "Tuodaan tietovarastoa", 1354 + "plcSigning": "Päivitetään identiteettiä", 1355 + "activating": "Aktivoidaan tili" 1303 1356 }, 1304 1357 "success": { 1305 - "title": "Siirto valmis!", 1306 - "desc": "Tilisi on siirretty onnistuneesti uuteen PDS:ääsi.", 1307 - "newHandle": "Uusi käyttäjätunnus", 1308 - "newPds": "Uusi PDS", 1309 - "nextSteps": "Seuraavat vaiheet", 1310 - "nextSteps1": "Kirjaudu uuteen PDS:ääsi", 1311 - "nextSteps2": "Päivitä sovellukset uusilla tunnuksillasi", 1312 - "nextSteps3": "Seuraajasi näkevät automaattisesti uuden sijaintisi", 1313 - "loggingOut": "Kirjaudutaan ulos {seconds} sekunnin kuluttua..." 1358 + "desc": "Tilisi on palautettu onnistuneesti tähän PDS:ään." 1359 + }, 1360 + "blobs": { 1361 + "title": "Siirretään blob-tiedostoja", 1362 + "desc": "Yritetään palauttaa kuvia ja mediaa vanhasta PDS:stäsi...", 1363 + "migrating": "Siirretään blob-tiedostoja", 1364 + "failedTitle": "Joitain blob-tiedostoja ei voitu siirtää", 1365 + "failedDesc": "{count} blob-tiedostoa ei voitu hakea vanhasta PDS:stäsi. Tämä voi tapahtua, jos palvelin ei ole tavoitettavissa tai tiedostot on poistettu.", 1366 + "sourceUnreachableTitle": "Lähde-PDS ei tavoitettavissa", 1367 + "sourceUnreachable": "Ei voitu yhdistää vanhaan PDS:ääsi mediatiedostojen hakemiseksi. Tämä on yleistä siirrettäessä suljetulta palvelimelta. Julkaisusi toimivat, mutta joitain kuvia saattaa puuttua." 1314 1368 } 1315 1369 }, 1316 1370 "progress": {
+147 -100
frontend/src/locales/ja.json
··· 17 17 "dashboard": "ダッシュボード", 18 18 "backToDashboard": "← ダッシュボード", 19 19 "copied": "コピーしました!", 20 - "copyToClipboard": "クリップボードにコピー" 20 + "copyToClipboard": "クリップボードにコピー", 21 + "verifying": "確認中...", 22 + "saving": "保存中...", 23 + "creating": "作成中...", 24 + "updating": "更新中...", 25 + "sending": "送信中...", 26 + "authenticating": "認証中...", 27 + "checking": "確認中...", 28 + "redirecting": "リダイレクト中...", 29 + "signIn": "サインイン", 30 + "verify": "確認", 31 + "remove": "削除", 32 + "revoke": "取り消し", 33 + "resendCode": "コードを再送信", 34 + "startOver": "最初からやり直す", 35 + "tryAgain": "再試行", 36 + "password": "パスワード", 37 + "email": "メール", 38 + "emailAddress": "メールアドレス", 39 + "handle": "ハンドル", 40 + "did": "DID", 41 + "verificationCode": "確認コード", 42 + "inviteCode": "招待コード", 43 + "newPassword": "新しいパスワード", 44 + "confirmPassword": "パスワードを確認", 45 + "enterSixDigitCode": "6桁のコードを入力", 46 + "passwordHint": "8文字以上", 47 + "enterPassword": "パスワードを入力", 48 + "emailPlaceholder": "you@example.com", 49 + "verified": "確認済み", 50 + "disabled": "無効", 51 + "available": "利用可能", 52 + "deactivated": "非アクティブ", 53 + "unverified": "未確認", 54 + "backToLogin": "ログインに戻る", 55 + "backToSettings": "設定に戻る", 56 + "alreadyHaveAccount": "すでにアカウントをお持ちですか?", 57 + "createAccount": "アカウントを作成", 58 + "passwordsMismatch": "パスワードが一致しません", 59 + "passwordTooShort": "パスワードは8文字以上必要です" 21 60 }, 22 61 "login": { 23 62 "title": "サインイン", ··· 49 88 "codeLabel": "確認コード", 50 89 "codePlaceholder": "6桁のコードを入力", 51 90 "verifyButton": "確認する", 52 - "verifying": "確認中...", 53 - "resendButton": "コードを再送信", 54 - "resending": "送信中...", 55 - "resent": "確認コードを再送信しました!", 56 - "backToLogin": "ログインに戻る" 91 + "resent": "確認コードを再送信しました!" 57 92 }, 58 93 "register": { 59 94 "title": "アカウント作成", ··· 124 159 "inviteCodePlaceholder": "招待コードを入力", 125 160 "inviteCodeRequired": "必須", 126 161 "createButton": "アカウントを作成", 127 - "creating": "作成中...", 128 162 "alreadyHaveAccount": "すでにアカウントをお持ちですか?", 129 163 "signIn": "サインイン", 130 164 "wantPasswordless": "パスワードレスをご希望ですか?", ··· 179 213 "navAdminDesc": "サーバー統計と管理操作", 180 214 "navDidDocument": "DID ドキュメント", 181 215 "navDidDocumentDesc": "DID ドキュメントとキーを管理", 216 + "navDidDocumentDescActive": "DID ドキュメント設定を編集", 217 + "navBackup": "バックアップをダウンロード", 218 + "navBackupDesc": "リポジトリを CAR ファイルとしてダウンロード", 219 + "downloadingBackup": "ダウンロード中...", 220 + "backupFailed": "バックアップのダウンロードに失敗しました", 182 221 "migrated": "移行済み", 183 222 "migratedTitle": "アカウント移行済み", 184 223 "migratedMessage": "アカウントは {pds} に移行されました。DID ドキュメントは引き続きここでホストされています。", ··· 208 247 "serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。", 209 248 "currentPds": "現在のPDS URL", 210 249 "save": "変更を保存", 211 - "saving": "保存中...", 212 250 "success": "DID ドキュメントを更新しました", 213 251 "saveFailed": "DIDドキュメントの保存に失敗しました", 214 252 "loadFailed": "DIDドキュメントの読み込みに失敗しました", ··· 246 284 "yourDomain": "ドメイン", 247 285 "yourDomainPlaceholder": "example.com", 248 286 "verifyAndUpdate": "確認してハンドルを更新", 249 - "verifying": "確認中...", 250 287 "newHandle": "新しいハンドル", 251 288 "newHandlePlaceholder": "yourhandle", 252 289 "changeHandleButton": "ハンドルを変更", ··· 262 299 "exportData": "データエクスポート", 263 300 "exportDataDescription": "リポジトリ全体を CAR(Content Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。", 264 301 "downloadRepo": "リポジトリをダウンロード", 302 + "downloadBlobs": "メディアをダウンロード", 265 303 "exporting": "エクスポート中...", 304 + "backups": { 305 + "title": "バックアップ", 306 + "description": "リポジトリは毎日自動的にバックアップされます。手動でバックアップを作成したり、以前のバックアップから復元することもできます。", 307 + "enableAutomatic": "自動バックアップを有効にする", 308 + "enabled": "自動バックアップが有効です", 309 + "disabled": "自動バックアップが無効です", 310 + "toggleFailed": "バックアップ設定の更新に失敗しました", 311 + "noBackups": "バックアップはまだありません。", 312 + "blocks": "ブロック", 313 + "download": "ダウンロード", 314 + "delete": "削除", 315 + "createNow": "今すぐバックアップを作成", 316 + "created": "バックアップが正常に作成されました", 317 + "createFailed": "バックアップの作成に失敗しました", 318 + "downloadFailed": "バックアップのダウンロードに失敗しました", 319 + "deleted": "バックアップが削除されました", 320 + "deleteFailed": "バックアップの削除に失敗しました", 321 + "restoreTitle": "バックアップから復元", 322 + "restoreDescription": "CARファイルをアップロードしてリポジトリを復元します。現在のデータは上書きされます。", 323 + "selectFile": "CARファイルを選択", 324 + "selectedFile": "選択されたファイル", 325 + "restore": "復元", 326 + "restoring": "復元中...", 327 + "restored": "リポジトリが正常に復元されました", 328 + "restoreFailed": "リポジトリの復元に失敗しました" 329 + }, 266 330 "deleteAccount": "アカウント削除", 267 331 "deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。", 268 332 "requestDeletion": "アカウント削除をリクエスト", ··· 291 355 "deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。", 292 356 "deletionFailed": "アカウントの削除に失敗しました", 293 357 "repoExported": "リポジトリをエクスポートしました", 294 - "exportFailed": "リポジトリのエクスポートに失敗しました", 358 + "blobsExported": "メディアファイルをエクスポートしました", 359 + "noBlobsToExport": "エクスポートするメディアファイルがありません", 360 + "exportFailed": "エクスポートに失敗しました", 295 361 "confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。" 296 362 } 297 363 }, ··· 306 372 "noPasswords": "アプリパスワードはまだありません", 307 373 "revoke": "取り消す", 308 374 "revoking": "取り消し中...", 309 - "creating": "作成中...", 310 375 "revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。", 311 376 "saveWarningTitle": "重要: このアプリパスワードを保存してください!", 312 377 "saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。", ··· 354 419 "used": "@{handle} が使用済み", 355 420 "disabled": "無効", 356 421 "usedBy": "使用者", 357 - "creating": "作成中...", 358 422 "disableConfirm": "この招待コードを無効にしますか?使用できなくなります。", 359 423 "created": "招待コードを作成しました", 360 424 "copy": "コピー", ··· 482 546 "verifyButton": "確認", 483 547 "verifyCodePlaceholder": "確認コードを入力", 484 548 "submit": "送信", 485 - "saving": "保存中...", 486 549 "savePreferences": "設定を保存", 487 550 "preferencesSaved": "連絡設定を保存しました", 488 551 "verifiedSuccess": "{channel} を確認しました", ··· 521 584 "noCollectionsYet": "コレクションがまだありません。最初のレコードを作成して開始しましょう。", 522 585 "loadMore": "さらに読み込む", 523 586 "recordJson": "レコード JSON", 524 - "saving": "保存中...", 525 587 "updateRecord": "レコードを更新", 526 588 "collectionNsid": "コレクション (NSID)", 527 589 "recordKeyOptional": "レコードキー(任意)", 528 590 "autoGenerated": "空白で自動生成 (TID)", 529 591 "autoGeneratedHint": "空白にすると TID ベースのキーが自動生成されます", 530 - "creating": "作成中...", 531 592 "demoPostText": "こんにちは、私の PDS からの初投稿です!", 532 593 "demoDisplayName": "表示名", 533 594 "demoBio": "自己紹介を書いてください。" ··· 548 609 "primaryLight": "プライマリ(ライトモード)", 549 610 "primaryDark": "プライマリ(ダークモード)", 550 611 "configSaved": "サーバー設定を保存しました", 551 - "saving": "保存中...", 552 612 "saveConfig": "設定を保存", 553 613 "serverStats": "サーバー統計", 554 614 "users": "ユーザー", ··· 639 699 "title": "二要素認証", 640 700 "subtitle": "追加の確認が必要です", 641 701 "usePasskey": "パスキーを使用", 642 - "useTotp": "認証アプリを使用", 643 - "verifying": "確認中..." 702 + "useTotp": "認証アプリを使用" 644 703 }, 645 704 "twoFactorCode": { 646 705 "title": "二要素認証", 647 706 "subtitle": "{channel} に確認コードを送信しました。以下にコードを入力して続行してください。", 648 707 "codeLabel": "確認コード", 649 708 "codePlaceholder": "6桁のコードを入力", 650 - "verify": "確認", 651 - "verifying": "確認中...", 652 709 "errors": { 653 710 "missingRequestUri": "request_uri パラメータがありません", 654 711 "verificationFailed": "確認に失敗しました", ··· 660 717 "title": "認証コードを入力", 661 718 "subtitle": "認証アプリの6桁のコードを入力", 662 719 "codePlaceholder": "6桁のコードを入力", 663 - "verify": "確認", 664 - "verifying": "確認中...", 665 720 "useBackupCode": "バックアップコードを使用", 666 721 "backupCodePlaceholder": "バックアップコードを入力", 667 722 "trustDevice": "このデバイスを30日間信頼する", ··· 691 746 "codeLabel": "確認コード", 692 747 "codeHelp": "ダッシュを含む完全なコードをメッセージからコピーしてください", 693 748 "verifyButton": "アカウントを確認", 694 - "verify": "確認", 695 - "verifying": "確認中...", 696 749 "pleaseWait": "お待ちください...", 697 - "sending": "送信中...", 698 - "resendCode": "コードを再送信", 699 - "resending": "送信中...", 700 750 "codeResent": "確認コードを再送信しました!", 701 751 "codeResentDetail": "確認コードを送信しました!受信トレイを確認してください。", 702 752 "verified": "確認完了!", ··· 706 756 "identifierLabel": "メールまたは識別子", 707 757 "identifierPlaceholder": "you@example.com", 708 758 "identifierHelp": "コードが送信されたメールアドレスまたは識別子", 709 - "backToLogin": "ログインに戻る", 710 759 "verifyingAccount": "確認中のアカウント: @{handle}", 711 760 "startOver": "別のアカウントでやり直す", 712 761 "noPending": "保留中の確認が見つかりません。", 713 762 "noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。", 714 763 "createAccount": "アカウントを作成", 715 764 "signIn": "サインイン", 716 - "backToSettings": "設定に戻る", 717 765 "emailUpdateCodeHelp": "コードは現在のメールアドレスに送信されました", 718 766 "emailUpdateFailed": "メールアドレスの更新に失敗しました", 719 767 "emailUpdateRequiresAuth": "メールアドレスを更新するにはサインインが必要です。", ··· 746 794 "resetButton": "パスワードをリセット", 747 795 "resetting": "リセット中...", 748 796 "success": "パスワードをリセットしました!", 749 - "backToLogin": "サインインに戻る", 750 797 "requestNewCode": "新しいコードをリクエスト", 751 798 "passwordsMismatch": "パスワードが一致しません", 752 799 "passwordLength": "パスワードは8文字以上である必要があります" ··· 790 837 "howItWorks": "仕組み", 791 838 "howItWorksDetail": "登録された通知チャンネルに安全なリンクを送信します。リンクをクリックして一時パスワードを設定します。その後サインインして新しいパスキーを追加できます。", 792 839 "sendRecoveryLink": "復旧リンクを送信", 793 - "sending": "送信中...", 794 - "backToLogin": "サインインに戻る" 840 + "sending": "送信中..." 795 841 }, 796 842 "registerPasskey": { 797 843 "title": "パスキーアカウントを作成", ··· 812 858 "externalDid": "あなたの did:web", 813 859 "externalDidPlaceholder": "did:web:yourdomain.com", 814 860 "createButton": "アカウントを作成", 815 - "creating": "作成中...", 816 861 "alreadyHaveAccount": "すでにアカウントをお持ちですか?", 817 862 "signIn": "サインイン", 818 863 "wantPassword": "パスワードを使用しますか?", ··· 911 956 "useTotp": "認証アプリを使用", 912 957 "passwordPlaceholder": "パスワードを入力", 913 958 "totpPlaceholder": "6桁のコードを入力", 914 - "verify": "確認", 915 - "verifying": "確認中...", 916 959 "authenticating": "認証中...", 917 960 "passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。", 918 961 "cancel": "キャンセル" ··· 985 1028 "createAccount": "アカウントを作成", 986 1029 "createDelegatedAccount": "委任アカウントを作成", 987 1030 "createDelegatedAccountButton": "+ 委任アカウントを作成", 988 - "creating": "作成中...", 989 1031 "emailOptional": "メール(任意)", 990 1032 "failedToAddController": "コントローラーの追加に失敗しました", 991 1033 "failedToCreateAccount": "委任アカウントの作成に失敗しました", ··· 1059 1101 "navDesc": "別のPDSへ、または別のPDSからアカウントを移動", 1060 1102 "migrateHere": "ここに移行", 1061 1103 "migrateHereDesc": "既存のAT ProtocolアカウントをこのPDSに移動します。", 1062 - "migrateAway": "別の場所に移行", 1063 - "migrateAwayDesc": "このPDSから別のサーバーにアカウントを移動します。", 1064 - "loginRequired": "ログインが必要です", 1065 1104 "bringDid": "DIDとアイデンティティを持ち込む", 1066 1105 "transferData": "すべてのデータを転送", 1067 1106 "keepFollowers": "フォロワーを維持", 1068 - "exportRepo": "リポジトリをエクスポート", 1069 - "transferToPds": "新しいPDSに転送", 1070 - "updateIdentity": "アイデンティティを更新", 1071 1107 "whatIsMigration": "アカウント移行とは?", 1072 1108 "whatIsMigrationDesc": "アカウント移行により、AT Protocolアイデンティティをパーソナルデータサーバー(PDS)間で移動できます。DID(分散型識別子)は変わらないため、フォロワーやソーシャルコネクションは維持されます。", 1073 1109 "beforeMigrate": "移行前の確認事項", ··· 1077 1113 "beforeMigrate4": "古いPDSにアカウントの無効化が通知されます", 1078 1114 "importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。", 1079 1115 "learnMore": "移行のリスクについて詳しく", 1080 - "comingSoon": "近日公開", 1116 + "offlineRestore": "オフライン復元", 1117 + "offlineRestoreDesc": "旧PDSが利用できない場合にバックアップから復元します。", 1118 + "offlineFeature1": "CARファイルバックアップを使用", 1119 + "offlineFeature2": "ローテーションキーで所有権を証明", 1120 + "offlineFeature3": "シャットダウンしたサーバーの復旧", 1081 1121 "oauthCompleting": "認証を完了しています...", 1082 1122 "oauthFailed": "認証に失敗しました", 1083 1123 "tryAgain": "再試行", ··· 1086 1126 "incomplete": "未完了の移行があります:", 1087 1127 "direction": "方向", 1088 1128 "migratingHere": "ここに移行中", 1089 - "migratingAway": "別の場所に移行中", 1090 1129 "from": "移行元", 1091 1130 "to": "移行先", 1092 1131 "progress": "進行状況", ··· 1229 1268 "error": { 1230 1269 "title": "移行エラー", 1231 1270 "desc": "移行中にエラーが発生しました。", 1232 - "startOver": "最初からやり直す" 1271 + "startOver": "最初からやり直す", 1272 + "unknown": "不明なエラーが発生しました。" 1233 1273 }, 1234 1274 "common": { 1235 1275 "back": "戻る", ··· 1247 1287 "warning3": "移行後、古いアカウントは無効化されます" 1248 1288 } 1249 1289 }, 1250 - "outbound": { 1290 + "offline": { 1251 1291 "welcome": { 1252 - "title": "このPDSから移行", 1253 - "desc": "アカウントを別のパーソナルデータサーバーに移動します。", 1254 - "warning": "移行後、ここでのアカウントは無効化されます。", 1255 - "didWebNotice": "did:web移行のお知らせ", 1256 - "didWebNoticeDesc": "あなたのアカウントはdid:web識別子({did})を使用しています。移行後、このPDSは新しいPDSを指すDIDドキュメントを引き続き提供します。このサーバーがオンラインである限り、アイデンティティは機能し続けます。", 1257 - "understand": "リスクを理解し、続行します" 1292 + "title": "バックアップから復元", 1293 + "desc": "CARファイルバックアップとローテーションキーを使用してアカウントを復元します。以前のPDSが利用できない場合に使用してください。", 1294 + "warningTitle": "この方法を使用するタイミング", 1295 + "warningDesc": "このオフライン復元は、古いPDSがシャットダウンした、アクセスできない、またはロックアウトされた場合の災害復旧用です。古いPDSがまだ利用可能な場合は、代わりに標準の移行を使用してください。", 1296 + "requirementsTitle": "必要なもの", 1297 + "requirement1": "リポジトリのCARファイルバックアップ", 1298 + "requirement2": "ローテーションキー(DIDの秘密鍵)", 1299 + "requirement3": "あなたのDID (did:plc:xxx)", 1300 + "understand": "理解し、続行します" 1258 1301 }, 1259 - "targetPds": { 1260 - "title": "移行先PDSを選択", 1261 - "desc": "移行先のPDSのURLを入力してください。", 1262 - "url": "PDS URL", 1263 - "urlPlaceholder": "https://pds.example.com", 1264 - "validate": "検証して続行", 1265 - "validating": "検証中...", 1266 - "connected": "{name}に接続しました", 1267 - "inviteRequired": "招待コードが必要です", 1268 - "privacyPolicy": "プライバシーポリシー", 1269 - "termsOfService": "利用規約" 1302 + "provideDid": { 1303 + "title": "DIDを入力", 1304 + "desc": "復元するアカウントのDIDを入力してください。", 1305 + "label": "あなたのDID", 1306 + "hint": "分散型識別子(例:did:plc:abc123)" 1270 1307 }, 1271 - "newAccount": { 1272 - "title": "新しいアカウントの詳細", 1273 - "desc": "新しいPDSでアカウントを設定します。", 1274 - "handle": "ハンドル", 1275 - "availableDomains": "利用可能なドメイン", 1276 - "email": "メール", 1277 - "password": "パスワード", 1278 - "confirmPassword": "パスワードを確認", 1279 - "inviteCode": "招待コード" 1308 + "uploadCar": { 1309 + "title": "CARファイルをアップロード", 1310 + "desc": "リポジトリバックアップファイルをアップロードしてください。", 1311 + "label": "CARファイル", 1312 + "hint": "バックアップから.carファイルを選択", 1313 + "reuploadWarningTitle": "CARファイルが必要です", 1314 + "reuploadWarning": "セッションは復元されましたが、CARファイルを再アップロードする必要があります。セキュリティ上の理由から、ファイルの内容はセッション間で保存されません。" 1280 1315 }, 1281 - "review": { 1282 - "title": "移行の確認", 1283 - "desc": "移行の詳細を確認してください。", 1284 - "currentHandle": "現在のハンドル", 1285 - "newHandle": "新しいハンドル", 1286 - "sourcePds": "このPDS", 1287 - "targetPds": "移行先PDS", 1288 - "confirm": "アカウントを移行することを確認します", 1289 - "startMigration": "移行を開始" 1316 + "rotationKey": { 1317 + "title": "ローテーションキーを提供", 1318 + "desc": "このDIDの所有権を証明するためにローテーションキーを入力してください。", 1319 + "securityWarningTitle": "セキュリティ警告", 1320 + "securityWarning1": "ローテーションキーは非常に機密性が高いです - マスターパスワードのように扱ってください", 1321 + "securityWarning2": "信頼できるデバイスとネットワークでのみ入力してください", 1322 + "securityWarning3": "このキーは移行完了後に保存されません", 1323 + "label": "ローテーションキー", 1324 + "placeholder": "秘密鍵を入力(hex、base58、またはJWK)", 1325 + "hint": "DIDドキュメントのローテーションキーの1つに対応する秘密鍵", 1326 + "valid": "キーは有効で、DIDのローテーションキーと一致します", 1327 + "invalid": "キーはDIDドキュメントのどのローテーションキーとも一致しません", 1328 + "validating": "キーを検証中...", 1329 + "validate": "キーを検証" 1290 1330 }, 1291 - "migrating": { 1292 - "title": "アカウントを移行中", 1293 - "desc": "データを転送しています..." 1331 + "chooseHandle": { 1332 + "migratingDid": "DIDを復元中" 1294 1333 }, 1295 - "plcToken": { 1296 - "title": "本人確認", 1297 - "desc": "確認コードがメールに送信されました。" 1334 + "review": { 1335 + "desc": "オフライン復元の詳細を確認してください。", 1336 + "carFile": "CARファイル", 1337 + "rotationKey": "ローテーションキー", 1338 + "warning": "復元を開始すると、アイデンティティがこのPDSを指すように更新されます。これは簡単に元に戻すことができません。", 1339 + "plcWarningTitle": "引き返せないポイント", 1340 + "plcWarning": "開始すると、DIDドキュメントがこのPDSを指すように更新されます。問題が発生した場合はローテーションキーを使用して回復できますが、壊れたアイデンティティ状態を避けるために移行を完了する必要があります。" 1298 1341 }, 1299 - "finalizing": { 1300 - "title": "移行を完了中", 1301 - "desc": "移行を完了しています...", 1302 - "updatingForwarding": "DIDドキュメントの転送先を更新中..." 1342 + "migrating": { 1343 + "title": "アカウントを復元中", 1344 + "desc": "アカウントを復元しています...", 1345 + "creating": "アカウントを作成中", 1346 + "importing": "リポジトリをインポート中", 1347 + "plcSigning": "アイデンティティを更新中", 1348 + "activating": "アカウントをアクティベート中" 1303 1349 }, 1304 1350 "success": { 1305 - "title": "移行完了!", 1306 - "desc": "アカウントは新しいPDSに正常に移行されました。", 1307 - "newHandle": "新しいハンドル", 1308 - "newPds": "新しいPDS", 1309 - "nextSteps": "次のステップ", 1310 - "nextSteps1": "新しいPDSにサインイン", 1311 - "nextSteps2": "アプリの認証情報を更新", 1312 - "nextSteps3": "フォロワーは自動的に新しい場所を確認できます", 1313 - "loggingOut": "{seconds}秒後にログアウトします..." 1351 + "desc": "アカウントはこのPDSに正常に復元されました。" 1352 + }, 1353 + "blobs": { 1354 + "title": "Blobを移行中", 1355 + "desc": "古いPDSから画像とメディアの復元を試みています...", 1356 + "migrating": "Blobを移行中", 1357 + "failedTitle": "一部のBlobを移行できませんでした", 1358 + "failedDesc": "{count}個のBlobを古いPDSから取得できませんでした。サーバーに接続できないか、ファイルが削除された可能性があります。", 1359 + "sourceUnreachableTitle": "ソースPDSに接続できません", 1360 + "sourceUnreachable": "古いPDSに接続してメディアファイルを取得できませんでした。シャットダウンしたサーバーからの移行ではよくあることです。投稿は機能しますが、一部の画像が欠落する可能性があります。" 1314 1361 } 1315 1362 }, 1316 1363 "progress": {
+147 -100
frontend/src/locales/ko.json
··· 17 17 "dashboard": "대시보드", 18 18 "backToDashboard": "← 대시보드", 19 19 "copied": "복사됨!", 20 - "copyToClipboard": "클립보드에 복사" 20 + "copyToClipboard": "클립보드에 복사", 21 + "verifying": "확인 중...", 22 + "saving": "저장 중...", 23 + "creating": "생성 중...", 24 + "updating": "업데이트 중...", 25 + "sending": "전송 중...", 26 + "authenticating": "인증 중...", 27 + "checking": "확인 중...", 28 + "redirecting": "리디렉션 중...", 29 + "signIn": "로그인", 30 + "verify": "확인", 31 + "remove": "삭제", 32 + "revoke": "취소", 33 + "resendCode": "코드 재전송", 34 + "startOver": "처음부터 다시", 35 + "tryAgain": "다시 시도", 36 + "password": "비밀번호", 37 + "email": "이메일", 38 + "emailAddress": "이메일 주소", 39 + "handle": "핸들", 40 + "did": "DID", 41 + "verificationCode": "인증 코드", 42 + "inviteCode": "초대 코드", 43 + "newPassword": "새 비밀번호", 44 + "confirmPassword": "비밀번호 확인", 45 + "enterSixDigitCode": "6자리 코드 입력", 46 + "passwordHint": "8자 이상", 47 + "enterPassword": "비밀번호를 입력하세요", 48 + "emailPlaceholder": "you@example.com", 49 + "verified": "인증됨", 50 + "disabled": "비활성화됨", 51 + "available": "사용 가능", 52 + "deactivated": "비활성화됨", 53 + "unverified": "미인증", 54 + "backToLogin": "로그인으로 돌아가기", 55 + "backToSettings": "설정으로 돌아가기", 56 + "alreadyHaveAccount": "이미 계정이 있으신가요?", 57 + "createAccount": "계정 만들기", 58 + "passwordsMismatch": "비밀번호가 일치하지 않습니다", 59 + "passwordTooShort": "비밀번호는 8자 이상이어야 합니다" 21 60 }, 22 61 "login": { 23 62 "title": "로그인", ··· 49 88 "codeLabel": "인증 코드", 50 89 "codePlaceholder": "6자리 코드 입력", 51 90 "verifyButton": "계정 인증", 52 - "verifying": "인증 중...", 53 - "resendButton": "코드 다시 보내기", 54 - "resending": "전송 중...", 55 - "resent": "인증 코드를 다시 보냈습니다!", 56 - "backToLogin": "로그인으로 돌아가기" 91 + "resent": "인증 코드를 다시 보냈습니다!" 57 92 }, 58 93 "register": { 59 94 "title": "계정 만들기", ··· 124 159 "inviteCodePlaceholder": "초대 코드 입력", 125 160 "inviteCodeRequired": "필수", 126 161 "createButton": "계정 만들기", 127 - "creating": "계정 생성 중...", 128 162 "alreadyHaveAccount": "이미 계정이 있으신가요?", 129 163 "signIn": "로그인", 130 164 "wantPasswordless": "비밀번호 없는 보안을 원하시나요?", ··· 179 213 "navAdminDesc": "서버 통계 및 관리 작업", 180 214 "navDidDocument": "DID 문서", 181 215 "navDidDocumentDesc": "DID 문서 및 키 관리", 216 + "navDidDocumentDescActive": "DID 문서 설정 편집", 217 + "navBackup": "백업 다운로드", 218 + "navBackupDesc": "저장소를 CAR 파일로 다운로드", 219 + "downloadingBackup": "다운로드 중...", 220 + "backupFailed": "백업 다운로드 실패", 182 221 "migrated": "마이그레이션됨", 183 222 "migratedTitle": "계정 마이그레이션됨", 184 223 "migratedMessage": "계정이 {pds}로 마이그레이션되었습니다. DID 문서는 여전히 여기에서 호스팅됩니다.", ··· 208 247 "serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.", 209 248 "currentPds": "현재 PDS URL", 210 249 "save": "변경사항 저장", 211 - "saving": "저장 중...", 212 250 "success": "DID 문서가 업데이트되었습니다", 213 251 "saveFailed": "DID 문서 저장에 실패했습니다", 214 252 "loadFailed": "DID 문서 로드에 실패했습니다", ··· 246 284 "yourDomain": "도메인", 247 285 "yourDomainPlaceholder": "example.com", 248 286 "verifyAndUpdate": "확인 후 핸들 업데이트", 249 - "verifying": "확인 중...", 250 287 "newHandle": "새 핸들", 251 288 "newHandlePlaceholder": "yourhandle", 252 289 "changeHandleButton": "핸들 변경", ··· 262 299 "exportData": "데이터 내보내기", 263 300 "exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.", 264 301 "downloadRepo": "저장소 다운로드", 302 + "downloadBlobs": "미디어 다운로드", 265 303 "exporting": "내보내기 중...", 304 + "backups": { 305 + "title": "백업", 306 + "description": "자동 백업을 관리하고 계정 데이터를 복원하세요. 백업에는 모든 기록과 blob이 포함됩니다.", 307 + "enableAutomatic": "자동 백업", 308 + "enabled": "활성화됨", 309 + "disabled": "비활성화됨", 310 + "toggleFailed": "백업 설정 변경 실패", 311 + "noBackups": "아직 백업이 없습니다", 312 + "blocks": "블록", 313 + "download": "다운로드", 314 + "delete": "삭제", 315 + "createNow": "지금 백업 생성", 316 + "created": "백업이 생성되었습니다", 317 + "createFailed": "백업 생성 실패", 318 + "downloadFailed": "백업 다운로드 실패", 319 + "deleted": "백업이 삭제되었습니다", 320 + "deleteFailed": "백업 삭제 실패", 321 + "restoreTitle": "백업에서 복원", 322 + "restoreDescription": "이전에 내보낸 CAR 파일에서 계정 데이터를 복원합니다. 이렇게 하면 현재 저장소가 업로드한 백업으로 교체됩니다.", 323 + "selectFile": "CAR 파일 선택", 324 + "selectedFile": "선택된 파일", 325 + "restore": "백업 복원", 326 + "restoring": "복원 중...", 327 + "restored": "백업이 성공적으로 복원되었습니다", 328 + "restoreFailed": "백업 복원 실패" 329 + }, 266 330 "deleteAccount": "계정 삭제", 267 331 "deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.", 268 332 "requestDeletion": "계정 삭제 요청", ··· 291 355 "deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", 292 356 "deletionFailed": "계정 삭제에 실패했습니다", 293 357 "repoExported": "저장소를 내보냈습니다", 294 - "exportFailed": "저장소 내보내기에 실패했습니다", 358 + "blobsExported": "미디어 파일을 내보냈습니다", 359 + "noBlobsToExport": "내보낼 미디어 파일이 없습니다", 360 + "exportFailed": "내보내기에 실패했습니다", 295 361 "confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." 296 362 } 297 363 }, ··· 306 372 "noPasswords": "앱 비밀번호가 아직 없습니다", 307 373 "revoke": "취소", 308 374 "revoking": "취소 중...", 309 - "creating": "생성 중...", 310 375 "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.", 311 376 "saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!", 312 377 "saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.", ··· 354 419 "used": "@{handle}이(가) 사용함", 355 420 "disabled": "비활성화됨", 356 421 "usedBy": "사용자", 357 - "creating": "생성 중...", 358 422 "disableConfirm": "이 초대 코드를 비활성화하시겠습니까? 더 이상 사용할 수 없습니다.", 359 423 "created": "초대 코드가 생성되었습니다", 360 424 "copy": "복사", ··· 482 546 "verifyButton": "인증", 483 547 "verifyCodePlaceholder": "인증 코드 입력", 484 548 "submit": "제출", 485 - "saving": "저장 중...", 486 549 "savePreferences": "설정 저장", 487 550 "preferencesSaved": "통신 설정이 저장되었습니다", 488 551 "verifiedSuccess": "{channel} 인증 완료", ··· 521 584 "noCollectionsYet": "컬렉션이 아직 없습니다. 첫 번째 레코드를 만들어 시작하세요.", 522 585 "loadMore": "더 불러오기", 523 586 "recordJson": "레코드 JSON", 524 - "saving": "저장 중...", 525 587 "updateRecord": "레코드 업데이트", 526 588 "collectionNsid": "컬렉션 (NSID)", 527 589 "recordKeyOptional": "레코드 키 (선택사항)", 528 590 "autoGenerated": "비워두면 자동 생성 (TID)", 529 591 "autoGeneratedHint": "비워두면 TID 기반 키가 자동 생성됩니다", 530 - "creating": "생성 중...", 531 592 "demoPostText": "안녕하세요, 제 PDS에서 보내는 첫 번째 게시물입니다!", 532 593 "demoDisplayName": "표시 이름", 533 594 "demoBio": "간단한 자기소개를 작성하세요." ··· 548 609 "primaryLight": "기본 (라이트 모드)", 549 610 "primaryDark": "기본 (다크 모드)", 550 611 "configSaved": "서버 설정이 저장되었습니다", 551 - "saving": "저장 중...", 552 612 "saveConfig": "설정 저장", 553 613 "serverStats": "서버 통계", 554 614 "users": "사용자", ··· 639 699 "title": "2단계 인증", 640 700 "subtitle": "추가 확인이 필요합니다", 641 701 "usePasskey": "패스키 사용", 642 - "useTotp": "인증 앱 사용", 643 - "verifying": "확인 중..." 702 + "useTotp": "인증 앱 사용" 644 703 }, 645 704 "twoFactorCode": { 646 705 "title": "2단계 인증", 647 706 "subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 코드를 입력하여 계속하세요.", 648 707 "codeLabel": "인증 코드", 649 708 "codePlaceholder": "6자리 코드 입력", 650 - "verify": "확인", 651 - "verifying": "확인 중...", 652 709 "errors": { 653 710 "missingRequestUri": "request_uri 매개변수가 없습니다", 654 711 "verificationFailed": "인증에 실패했습니다", ··· 660 717 "title": "인증 코드 입력", 661 718 "subtitle": "인증 앱의 6자리 코드를 입력하세요", 662 719 "codePlaceholder": "6자리 코드 입력", 663 - "verify": "확인", 664 - "verifying": "확인 중...", 665 720 "useBackupCode": "백업 코드 사용", 666 721 "backupCodePlaceholder": "백업 코드 입력", 667 722 "trustDevice": "이 기기를 30일간 신뢰", ··· 691 746 "codeLabel": "인증 코드", 692 747 "codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요", 693 748 "verifyButton": "계정 인증", 694 - "verify": "인증", 695 - "verifying": "인증 중...", 696 749 "pleaseWait": "잠시 기다려 주세요...", 697 - "sending": "전송 중...", 698 - "resendCode": "코드 다시 보내기", 699 - "resending": "전송 중...", 700 750 "codeResent": "인증 코드를 다시 보냈습니다!", 701 751 "codeResentDetail": "인증 코드가 전송되었습니다! 받은 편지함을 확인하세요.", 702 752 "verified": "인증 완료!", ··· 706 756 "identifierLabel": "이메일 또는 식별자", 707 757 "identifierPlaceholder": "you@example.com", 708 758 "identifierHelp": "코드가 전송된 이메일 주소 또는 식별자", 709 - "backToLogin": "로그인으로 돌아가기", 710 759 "verifyingAccount": "인증 중인 계정: @{handle}", 711 760 "startOver": "다른 계정으로 다시 시작", 712 761 "noPending": "보류 중인 인증이 없습니다.", 713 762 "noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.", 714 763 "createAccount": "계정 만들기", 715 764 "signIn": "로그인", 716 - "backToSettings": "설정으로 돌아가기", 717 765 "emailUpdateCodeHelp": "코드가 현재 이메일 주소로 전송되었습니다", 718 766 "emailUpdateFailed": "이메일 주소 업데이트 실패", 719 767 "emailUpdateRequiresAuth": "이메일 주소를 업데이트하려면 로그인해야 합니다.", ··· 746 794 "resetButton": "비밀번호 재설정", 747 795 "resetting": "재설정 중...", 748 796 "success": "비밀번호가 재설정되었습니다!", 749 - "backToLogin": "로그인으로 돌아가기", 750 797 "requestNewCode": "새 코드 요청", 751 798 "passwordsMismatch": "비밀번호가 일치하지 않습니다", 752 799 "passwordLength": "비밀번호는 8자 이상이어야 합니다" ··· 790 837 "howItWorks": "작동 방식", 791 838 "howItWorksDetail": "등록된 알림 채널로 보안 링크를 보냅니다. 링크를 클릭하여 임시 비밀번호를 설정합니다. 그런 다음 로그인하여 새 패스키를 추가할 수 있습니다.", 792 839 "sendRecoveryLink": "복구 링크 보내기", 793 - "sending": "전송 중...", 794 - "backToLogin": "로그인으로 돌아가기" 840 + "sending": "전송 중..." 795 841 }, 796 842 "registerPasskey": { 797 843 "title": "패스키 계정 만들기", ··· 812 858 "externalDid": "귀하의 did:web", 813 859 "externalDidPlaceholder": "did:web:yourdomain.com", 814 860 "createButton": "계정 만들기", 815 - "creating": "생성 중...", 816 861 "alreadyHaveAccount": "이미 계정이 있으신가요?", 817 862 "signIn": "로그인", 818 863 "wantPassword": "비밀번호를 사용하시겠습니까?", ··· 911 956 "useTotp": "인증 앱 사용", 912 957 "passwordPlaceholder": "비밀번호 입력", 913 958 "totpPlaceholder": "6자리 코드 입력", 914 - "verify": "확인", 915 - "verifying": "확인 중...", 916 959 "authenticating": "인증 중...", 917 960 "passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.", 918 961 "cancel": "취소" ··· 985 1028 "createAccount": "계정 생성", 986 1029 "createDelegatedAccount": "위임 계정 생성", 987 1030 "createDelegatedAccountButton": "+ 위임 계정 생성", 988 - "creating": "생성 중...", 989 1031 "emailOptional": "이메일 (선택사항)", 990 1032 "failedToAddController": "컨트롤러 추가에 실패했습니다", 991 1033 "failedToCreateAccount": "위임 계정 생성에 실패했습니다", ··· 1059 1101 "navDesc": "다른 PDS로 또는 다른 PDS에서 계정 이동", 1060 1102 "migrateHere": "여기로 마이그레이션", 1061 1103 "migrateHereDesc": "기존 AT Protocol 계정을 다른 서버에서 이 PDS로 이동합니다.", 1062 - "migrateAway": "다른 곳으로 마이그레이션", 1063 - "migrateAwayDesc": "이 PDS에서 다른 서버로 계정을 이동합니다.", 1064 - "loginRequired": "로그인 필요", 1065 1104 "bringDid": "DID와 아이덴티티 가져오기", 1066 1105 "transferData": "모든 데이터 전송", 1067 1106 "keepFollowers": "팔로워 유지", 1068 - "exportRepo": "저장소 내보내기", 1069 - "transferToPds": "새 PDS로 전송", 1070 - "updateIdentity": "아이덴티티 업데이트", 1071 1107 "whatIsMigration": "계정 마이그레이션이란?", 1072 1108 "whatIsMigrationDesc": "계정 마이그레이션을 통해 AT Protocol 아이덴티티를 개인 데이터 서버(PDS) 간에 이동할 수 있습니다. DID(분산 식별자)는 동일하게 유지되므로 팔로워와 소셜 연결이 보존됩니다.", 1073 1109 "beforeMigrate": "마이그레이션 전 확인사항", ··· 1077 1113 "beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다", 1078 1114 "importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.", 1079 1115 "learnMore": "마이그레이션 위험에 대해 자세히 알아보기", 1080 - "comingSoon": "곧 출시 예정", 1116 + "offlineRestore": "오프라인 복원", 1117 + "offlineRestoreDesc": "이전 PDS를 사용할 수 없을 때 백업에서 복원합니다.", 1118 + "offlineFeature1": "CAR 파일 백업 사용", 1119 + "offlineFeature2": "회전 키로 소유권 증명", 1120 + "offlineFeature3": "종료된 서버 복구", 1081 1121 "oauthCompleting": "인증 완료 중...", 1082 1122 "oauthFailed": "인증 실패", 1083 1123 "tryAgain": "다시 시도", ··· 1086 1126 "incomplete": "완료되지 않은 마이그레이션이 있습니다:", 1087 1127 "direction": "방향", 1088 1128 "migratingHere": "여기로 마이그레이션 중", 1089 - "migratingAway": "다른 곳으로 마이그레이션 중", 1090 1129 "from": "출발지", 1091 1130 "to": "목적지", 1092 1131 "progress": "진행 상황", ··· 1229 1268 "error": { 1230 1269 "title": "마이그레이션 오류", 1231 1270 "desc": "마이그레이션 중 오류가 발생했습니다.", 1232 - "startOver": "처음부터 다시 시작" 1271 + "startOver": "처음부터 다시 시작", 1272 + "unknown": "알 수 없는 오류가 발생했습니다." 1233 1273 }, 1234 1274 "common": { 1235 1275 "back": "뒤로", ··· 1247 1287 "warning3": "마이그레이션 후 이전 계정은 비활성화됩니다" 1248 1288 } 1249 1289 }, 1250 - "outbound": { 1290 + "offline": { 1251 1291 "welcome": { 1252 - "title": "이 PDS에서 마이그레이션", 1253 - "desc": "계정을 다른 개인 데이터 서버로 이동합니다.", 1254 - "warning": "마이그레이션 후 이 PDS에서 계정이 비활성화됩니다.", 1255 - "didWebNotice": "did:web 마이그레이션 알림", 1256 - "didWebNoticeDesc": "귀하의 계정은 did:web 식별자({did})를 사용합니다. 마이그레이션 후 이 PDS는 새 PDS를 가리키는 DID 문서를 계속 제공합니다. 이 서버가 온라인인 한 아이덴티티는 계속 작동합니다.", 1257 - "understand": "위험을 이해하고 계속 진행합니다" 1292 + "title": "백업에서 복원", 1293 + "desc": "CAR 파일 백업과 회전 키를 사용하여 계정을 복원합니다. 이전 PDS를 사용할 수 없을 때 사용하세요.", 1294 + "warningTitle": "이 방법을 사용해야 할 때", 1295 + "warningDesc": "이 오프라인 복원은 이전 PDS가 종료되었거나, 접근할 수 없거나, 잠긴 경우의 재해 복구용입니다. 이전 PDS가 여전히 사용 가능하면 표준 마이그레이션을 사용하세요.", 1296 + "requirementsTitle": "필요한 것", 1297 + "requirement1": "저장소의 CAR 파일 백업", 1298 + "requirement2": "회전 키 (DID의 개인 키)", 1299 + "requirement3": "당신의 DID (did:plc:xxx)", 1300 + "understand": "이해하고 계속 진행합니다" 1258 1301 }, 1259 - "targetPds": { 1260 - "title": "대상 PDS 선택", 1261 - "desc": "마이그레이션할 PDS의 URL을 입력하세요.", 1262 - "url": "PDS URL", 1263 - "urlPlaceholder": "https://pds.example.com", 1264 - "validate": "확인 및 계속", 1265 - "validating": "확인 중...", 1266 - "connected": "{name}에 연결됨", 1267 - "inviteRequired": "초대 코드 필요", 1268 - "privacyPolicy": "개인정보 처리방침", 1269 - "termsOfService": "서비스 약관" 1302 + "provideDid": { 1303 + "title": "DID 입력", 1304 + "desc": "복원할 계정의 DID를 입력하세요.", 1305 + "label": "당신의 DID", 1306 + "hint": "분산 식별자 (예: did:plc:abc123)" 1270 1307 }, 1271 - "newAccount": { 1272 - "title": "새 계정 세부 정보", 1273 - "desc": "새 PDS에서 계정을 설정합니다.", 1274 - "handle": "핸들", 1275 - "availableDomains": "사용 가능한 도메인", 1276 - "email": "이메일", 1277 - "password": "비밀번호", 1278 - "confirmPassword": "비밀번호 확인", 1279 - "inviteCode": "초대 코드" 1308 + "uploadCar": { 1309 + "title": "CAR 파일 업로드", 1310 + "desc": "저장소 백업 파일을 업로드하세요.", 1311 + "label": "CAR 파일", 1312 + "hint": "백업에서 .car 파일을 선택하세요", 1313 + "reuploadWarningTitle": "CAR 파일 필요", 1314 + "reuploadWarning": "세션이 복원되었지만 CAR 파일을 다시 업로드해야 합니다. 보안상의 이유로 파일 내용은 세션 간에 저장되지 않습니다." 1280 1315 }, 1281 - "review": { 1282 - "title": "마이그레이션 검토", 1283 - "desc": "마이그레이션 세부 정보를 검토하고 확인하세요.", 1284 - "currentHandle": "현재 핸들", 1285 - "newHandle": "새 핸들", 1286 - "sourcePds": "이 PDS", 1287 - "targetPds": "대상 PDS", 1288 - "confirm": "계정 마이그레이션을 확인합니다", 1289 - "startMigration": "마이그레이션 시작" 1316 + "rotationKey": { 1317 + "title": "회전 키 제공", 1318 + "desc": "이 DID의 소유권을 증명하기 위해 회전 키를 입력하세요.", 1319 + "securityWarningTitle": "보안 경고", 1320 + "securityWarning1": "회전 키는 매우 민감합니다 - 마스터 비밀번호처럼 취급하세요", 1321 + "securityWarning2": "신뢰할 수 있는 장치와 네트워크에서만 입력하세요", 1322 + "securityWarning3": "이 키는 마이그레이션 완료 후 저장되지 않습니다", 1323 + "label": "회전 키", 1324 + "placeholder": "개인 키 입력 (hex, base58 또는 JWK)", 1325 + "hint": "DID 문서의 회전 키 중 하나에 해당하는 개인 키", 1326 + "valid": "키가 유효하고 DID의 회전 키와 일치합니다", 1327 + "invalid": "키가 DID 문서의 어떤 회전 키와도 일치하지 않습니다", 1328 + "validating": "키 검증 중...", 1329 + "validate": "키 검증" 1290 1330 }, 1291 - "migrating": { 1292 - "title": "계정 마이그레이션 중", 1293 - "desc": "데이터를 전송하는 중입니다..." 1331 + "chooseHandle": { 1332 + "migratingDid": "DID 복원 중" 1294 1333 }, 1295 - "plcToken": { 1296 - "title": "신원 확인", 1297 - "desc": "이메일로 인증 코드가 전송되었습니다." 1334 + "review": { 1335 + "desc": "오프라인 복원 세부 정보를 확인하세요.", 1336 + "carFile": "CAR 파일", 1337 + "rotationKey": "회전 키", 1338 + "warning": "복원을 시작하면 아이덴티티가 이 PDS를 가리키도록 업데이트됩니다. 이것은 쉽게 되돌릴 수 없습니다.", 1339 + "plcWarningTitle": "되돌릴 수 없는 지점", 1340 + "plcWarning": "시작하면 DID 문서가 이 PDS를 가리키도록 업데이트됩니다. 문제가 발생하면 회전 키를 사용하여 복구할 수 있지만, 손상된 아이덴티티 상태를 피하려면 마이그레이션을 완료해야 합니다." 1298 1341 }, 1299 - "finalizing": { 1300 - "title": "마이그레이션 완료 중", 1301 - "desc": "마이그레이션을 완료하는 중입니다...", 1302 - "updatingForwarding": "DID 문서 포워딩 업데이트 중..." 1342 + "migrating": { 1343 + "title": "계정 복원 중", 1344 + "desc": "계정을 복원하는 중입니다...", 1345 + "creating": "계정 생성 중", 1346 + "importing": "저장소 가져오는 중", 1347 + "plcSigning": "아이덴티티 업데이트 중", 1348 + "activating": "계정 활성화 중" 1303 1349 }, 1304 1350 "success": { 1305 - "title": "마이그레이션 완료!", 1306 - "desc": "계정이 새 PDS로 성공적으로 마이그레이션되었습니다.", 1307 - "newHandle": "새 핸들", 1308 - "newPds": "새 PDS", 1309 - "nextSteps": "다음 단계", 1310 - "nextSteps1": "새 PDS에 로그인", 1311 - "nextSteps2": "새 인증 정보로 앱 업데이트", 1312 - "nextSteps3": "팔로워가 자동으로 새 위치를 확인할 수 있습니다", 1313 - "loggingOut": "{seconds}초 후 로그아웃됩니다..." 1351 + "desc": "계정이 이 PDS에 성공적으로 복원되었습니다." 1352 + }, 1353 + "blobs": { 1354 + "title": "Blob 마이그레이션 중", 1355 + "desc": "이전 PDS에서 이미지와 미디어를 복구하는 중...", 1356 + "migrating": "Blob 마이그레이션 중", 1357 + "failedTitle": "일부 Blob을 마이그레이션할 수 없음", 1358 + "failedDesc": "{count}개의 Blob을 이전 PDS에서 가져올 수 없습니다. 서버에 연결할 수 없거나 파일이 삭제되었을 수 있습니다.", 1359 + "sourceUnreachableTitle": "원본 PDS에 연결할 수 없음", 1360 + "sourceUnreachable": "이전 PDS에 연결하여 미디어 파일을 가져올 수 없습니다. 종료된 서버에서 마이그레이션할 때 흔히 발생합니다. 게시물은 작동하지만 일부 이미지가 누락될 수 있습니다." 1314 1361 } 1315 1362 }, 1316 1363 "progress": {
+147 -100
frontend/src/locales/sv.json
··· 17 17 "dashboard": "Kontrollpanel", 18 18 "backToDashboard": "← Kontrollpanel", 19 19 "copied": "Kopierat!", 20 - "copyToClipboard": "Kopiera" 20 + "copyToClipboard": "Kopiera", 21 + "verifying": "Verifierar...", 22 + "saving": "Sparar...", 23 + "creating": "Skapar...", 24 + "updating": "Uppdaterar...", 25 + "sending": "Skickar...", 26 + "authenticating": "Autentiserar...", 27 + "checking": "Kontrollerar...", 28 + "redirecting": "Omdirigerar...", 29 + "signIn": "Logga in", 30 + "verify": "Verifiera", 31 + "remove": "Ta bort", 32 + "revoke": "Återkalla", 33 + "resendCode": "Skicka kod igen", 34 + "startOver": "Börja om", 35 + "tryAgain": "Försök igen", 36 + "password": "Lösenord", 37 + "email": "E-post", 38 + "emailAddress": "E-postadress", 39 + "handle": "Användarnamn", 40 + "did": "DID", 41 + "verificationCode": "Verifieringskod", 42 + "inviteCode": "Inbjudningskod", 43 + "newPassword": "Nytt lösenord", 44 + "confirmPassword": "Bekräfta lösenord", 45 + "enterSixDigitCode": "Ange 6-siffrig kod", 46 + "passwordHint": "Minst 8 tecken", 47 + "enterPassword": "Ange ditt lösenord", 48 + "emailPlaceholder": "du@exempel.se", 49 + "verified": "Verifierad", 50 + "disabled": "Inaktiverad", 51 + "available": "Tillgänglig", 52 + "deactivated": "Avaktiverad", 53 + "unverified": "Overifierad", 54 + "backToLogin": "Tillbaka till inloggning", 55 + "backToSettings": "Tillbaka till inställningar", 56 + "alreadyHaveAccount": "Har du redan ett konto?", 57 + "createAccount": "Skapa konto", 58 + "passwordsMismatch": "Lösenorden matchar inte", 59 + "passwordTooShort": "Lösenordet måste vara minst 8 tecken" 21 60 }, 22 61 "login": { 23 62 "title": "Logga in", ··· 49 88 "codeLabel": "Verifieringskod", 50 89 "codePlaceholder": "Ange 6-siffrig kod", 51 90 "verifyButton": "Verifiera konto", 52 - "verifying": "Verifierar...", 53 - "resendButton": "Skicka kod igen", 54 - "resending": "Skickar igen...", 55 - "resent": "Verifieringskod skickad igen!", 56 - "backToLogin": "Tillbaka till inloggning" 91 + "resent": "Verifieringskod skickad igen!" 57 92 }, 58 93 "register": { 59 94 "title": "Skapa konto", ··· 124 159 "inviteCodePlaceholder": "Ange din inbjudningskod", 125 160 "inviteCodeRequired": "krävs", 126 161 "createButton": "Skapa konto", 127 - "creating": "Skapar konto...", 128 162 "alreadyHaveAccount": "Har du redan ett konto?", 129 163 "signIn": "Logga in", 130 164 "wantPasswordless": "Vill du ha lösenordsfri säkerhet?", ··· 179 213 "navAdminDesc": "Serverstatistik och administratörsoperationer", 180 214 "navDidDocument": "DID-dokument", 181 215 "navDidDocumentDesc": "Hantera ditt DID-dokument och nycklar", 216 + "navDidDocumentDescActive": "Redigera dina DID-dokumentinställningar", 217 + "navBackup": "Ladda ner säkerhetskopia", 218 + "navBackupDesc": "Ladda ner ditt dataförvar som en CAR-fil", 219 + "downloadingBackup": "Laddar ner...", 220 + "backupFailed": "Kunde inte ladda ner säkerhetskopia", 182 221 "migrated": "Flyttad", 183 222 "migratedTitle": "Konto flyttat", 184 223 "migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande här.", ··· 208 247 "serviceEndpointDesc": "PDS som för närvarande lagrar din kontodata. Uppdatera detta vid migrering.", 209 248 "currentPds": "Nuvarande PDS-URL", 210 249 "save": "Spara ändringar", 211 - "saving": "Sparar...", 212 250 "success": "DID-dokumentet har uppdaterats", 213 251 "saveFailed": "Kunde inte spara DID-dokument", 214 252 "loadFailed": "Kunde inte ladda DID-dokument", ··· 246 284 "yourDomain": "Din domän", 247 285 "yourDomainPlaceholder": "exempel.se", 248 286 "verifyAndUpdate": "Verifiera och uppdatera användarnamn", 249 - "verifying": "Verifierar...", 250 287 "newHandle": "Nytt användarnamn", 251 288 "newHandlePlaceholder": "dittanvändarnamn", 252 289 "changeHandleButton": "Ändra användarnamn", ··· 262 299 "exportData": "Exportera data", 263 300 "exportDataDescription": "Ladda ner hela ditt arkiv som en CAR-fil (Content Addressable Archive). Detta inkluderar alla dina inlägg, gillanden, följningar och annan data.", 264 301 "downloadRepo": "Ladda ner arkiv", 302 + "downloadBlobs": "Ladda ner media", 265 303 "exporting": "Exporterar...", 304 + "backups": { 305 + "title": "Säkerhetskopior", 306 + "description": "Hantera automatiska säkerhetskopior och återställ din kontodata. Säkerhetskopior inkluderar alla poster och blobbar.", 307 + "enableAutomatic": "Automatiska säkerhetskopior", 308 + "enabled": "Aktiverad", 309 + "disabled": "Inaktiverad", 310 + "toggleFailed": "Kunde inte ändra säkerhetskopieringsinställning", 311 + "noBackups": "Inga säkerhetskopior ännu", 312 + "blocks": "block", 313 + "download": "Ladda ner", 314 + "delete": "Radera", 315 + "createNow": "Skapa säkerhetskopia nu", 316 + "created": "Säkerhetskopia skapad", 317 + "createFailed": "Kunde inte skapa säkerhetskopia", 318 + "downloadFailed": "Kunde inte ladda ner säkerhetskopia", 319 + "deleted": "Säkerhetskopia raderad", 320 + "deleteFailed": "Kunde inte radera säkerhetskopia", 321 + "restoreTitle": "Återställ från säkerhetskopia", 322 + "restoreDescription": "Återställ din kontodata från en tidigare exporterad CAR-fil. Detta ersätter ditt nuvarande dataförvar med den uppladdade säkerhetskopian.", 323 + "selectFile": "Välj CAR-fil", 324 + "selectedFile": "Vald fil", 325 + "restore": "Återställ säkerhetskopia", 326 + "restoring": "Återställer...", 327 + "restored": "Säkerhetskopia återställd", 328 + "restoreFailed": "Kunde inte återställa säkerhetskopia" 329 + }, 266 330 "deleteAccount": "Radera konto", 267 331 "deleteWarning": "Denna åtgärd är oåterkallelig. All din data kommer att raderas permanent.", 268 332 "requestDeletion": "Begär kontoradering", ··· 291 355 "deleteConfirmation": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras.", 292 356 "deletionFailed": "Kunde inte radera kontot", 293 357 "repoExported": "Arkiv exporterat", 294 - "exportFailed": "Kunde inte exportera arkiv", 358 + "blobsExported": "Mediafiler exporterade", 359 + "noBlobsToExport": "Inga mediafiler att exportera", 360 + "exportFailed": "Export misslyckades", 295 361 "confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras." 296 362 } 297 363 }, ··· 306 372 "noPasswords": "Inga applösenord ännu", 307 373 "revoke": "Återkalla", 308 374 "revoking": "Återkallar...", 309 - "creating": "Skapar...", 310 375 "revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.", 311 376 "saveWarningTitle": "Viktigt: Spara detta applösenord!", 312 377 "saveWarningMessage": "Detta lösenord krävs för att logga in i appar som inte stöder passkeys eller OAuth. Du ser det bara en gång.", ··· 354 419 "used": "Använd av @{handle}", 355 420 "disabled": "Inaktiverad", 356 421 "usedBy": "Använd av", 357 - "creating": "Skapar...", 358 422 "disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte längre användas.", 359 423 "created": "Inbjudningskod skapad", 360 424 "copy": "Kopiera", ··· 482 546 "verifyButton": "Verifiera", 483 547 "verifyCodePlaceholder": "Ange verifieringskod", 484 548 "submit": "Skicka", 485 - "saving": "Sparar...", 486 549 "savePreferences": "Spara inställningar", 487 550 "preferencesSaved": "Kommunikationsinställningar sparade", 488 551 "verifiedSuccess": "{channel} verifierad", ··· 521 584 "noCollectionsYet": "Inga samlingar ännu. Skapa din första post för att komma igång.", 522 585 "loadMore": "Ladda fler", 523 586 "recordJson": "Post-JSON", 524 - "saving": "Sparar...", 525 587 "updateRecord": "Uppdatera post", 526 588 "collectionNsid": "Samling (NSID)", 527 589 "recordKeyOptional": "Postnyckel (valfri)", 528 590 "autoGenerated": "Genereras automatiskt om tom (TID)", 529 591 "autoGeneratedHint": "Lämna tom för att automatiskt generera en TID-baserad nyckel", 530 - "creating": "Skapar...", 531 592 "demoPostText": "Hej från min PDS! Detta är mitt första inlägg.", 532 593 "demoDisplayName": "Ditt visningsnamn", 533 594 "demoBio": "En kort presentation om dig själv." ··· 548 609 "primaryLight": "Primär (ljust läge)", 549 610 "primaryDark": "Primär (mörkt läge)", 550 611 "configSaved": "Serverkonfiguration sparad", 551 - "saving": "Sparar...", 552 612 "saveConfig": "Spara konfiguration", 553 613 "serverStats": "Serverstatistik", 554 614 "users": "Användare", ··· 639 699 "title": "Tvåfaktorsautentisering", 640 700 "subtitle": "Ytterligare verifiering krävs", 641 701 "usePasskey": "Använd nyckel", 642 - "useTotp": "Använd autentiseringsapp", 643 - "verifying": "Verifierar..." 702 + "useTotp": "Använd autentiseringsapp" 644 703 }, 645 704 "twoFactorCode": { 646 705 "title": "Tvåfaktorsautentisering", 647 706 "subtitle": "En verifieringskod har skickats till din {channel}. Ange koden nedan för att fortsätta.", 648 707 "codeLabel": "Verifieringskod", 649 708 "codePlaceholder": "Ange 6-siffrig kod", 650 - "verify": "Verifiera", 651 - "verifying": "Verifierar...", 652 709 "errors": { 653 710 "missingRequestUri": "Saknar request_uri-parameter", 654 711 "verificationFailed": "Verifiering misslyckades", ··· 660 717 "title": "Ange autentiseringskod", 661 718 "subtitle": "Ange den 6-siffriga koden från din autentiseringsapp", 662 719 "codePlaceholder": "Ange 6-siffrig kod", 663 - "verify": "Verifiera", 664 - "verifying": "Verifierar...", 665 720 "useBackupCode": "Använd reservkod istället", 666 721 "backupCodePlaceholder": "Ange reservkod", 667 722 "trustDevice": "Lita på denna enhet i 30 dagar", ··· 691 746 "codeLabel": "Verifieringskod", 692 747 "codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck", 693 748 "verifyButton": "Verifiera konto", 694 - "verify": "Verifiera", 695 - "verifying": "Verifierar...", 696 749 "pleaseWait": "Vänta...", 697 - "sending": "Skickar...", 698 - "resendCode": "Skicka kod igen", 699 - "resending": "Skickar igen...", 700 750 "codeResent": "Verifieringskod skickad igen!", 701 751 "codeResentDetail": "Verifieringskod skickad! Kontrollera din inkorg.", 702 752 "verified": "Verifierad!", ··· 706 756 "identifierLabel": "E-post eller identifierare", 707 757 "identifierPlaceholder": "du@exempel.se", 708 758 "identifierHelp": "E-postadressen eller identifieraren koden skickades till", 709 - "backToLogin": "Tillbaka till inloggning", 710 759 "verifyingAccount": "Verifierar konto: @{handle}", 711 760 "startOver": "Börja om med ett annat konto", 712 761 "noPending": "Ingen väntande verifiering hittades.", 713 762 "noPendingInfo": "Om du nyligen skapade ett konto och behöver verifiera det kan du behöva skapa ett nytt konto. Om du redan verifierat ditt konto kan du logga in.", 714 763 "createAccount": "Skapa konto", 715 764 "signIn": "Logga in", 716 - "backToSettings": "Tillbaka till inställningar", 717 765 "emailUpdateCodeHelp": "Koden skickades till din nuvarande e-postadress", 718 766 "emailUpdateFailed": "Kunde inte uppdatera e-postadress", 719 767 "emailUpdateRequiresAuth": "Du måste vara inloggad för att uppdatera din e-postadress.", ··· 746 794 "resetButton": "Återställ lösenord", 747 795 "resetting": "Återställer...", 748 796 "success": "Lösenord återställt!", 749 - "backToLogin": "Tillbaka till inloggning", 750 797 "requestNewCode": "Begär ny kod", 751 798 "passwordsMismatch": "Lösenorden matchar inte", 752 799 "passwordLength": "Lösenordet måste vara minst 8 tecken" ··· 790 837 "howItWorks": "Så fungerar det", 791 838 "howItWorksDetail": "Vi skickar en säker länk till din registrerade meddelandekanal. Klicka på länken för att ställa in ett tillfälligt lösenord. Sedan kan du logga in och lägga till en ny nyckel.", 792 839 "sendRecoveryLink": "Skicka återställningslänk", 793 - "sending": "Skickar...", 794 - "backToLogin": "Tillbaka till inloggning" 840 + "sending": "Skickar..." 795 841 }, 796 842 "registerPasskey": { 797 843 "title": "Skapa nyckelkonto", ··· 812 858 "externalDid": "Din did:web", 813 859 "externalDidPlaceholder": "did:web:dindomän.se", 814 860 "createButton": "Skapa konto", 815 - "creating": "Skapar...", 816 861 "alreadyHaveAccount": "Har du redan ett konto?", 817 862 "signIn": "Logga in", 818 863 "wantPassword": "Vill du använda ett lösenord?", ··· 911 956 "useTotp": "Använd autentiserare", 912 957 "passwordPlaceholder": "Ange ditt lösenord", 913 958 "totpPlaceholder": "Ange 6-siffrig kod", 914 - "verify": "Verifiera", 915 - "verifying": "Verifierar...", 916 959 "authenticating": "Autentiserar...", 917 960 "passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.", 918 961 "cancel": "Avbryt" ··· 985 1028 "createAccount": "Skapa konto", 986 1029 "createDelegatedAccount": "Skapa delegerat konto", 987 1030 "createDelegatedAccountButton": "+ Skapa delegerat konto", 988 - "creating": "Skapar...", 989 1031 "emailOptional": "E-post (valfritt)", 990 1032 "failedToAddController": "Kunde inte lägga till kontrollant", 991 1033 "failedToCreateAccount": "Kunde inte skapa delegerat konto", ··· 1059 1101 "navDesc": "Flytta ditt konto till eller från en annan PDS", 1060 1102 "migrateHere": "Flytta hit", 1061 1103 "migrateHereDesc": "Flytta ditt befintliga AT Protocol-konto till denna PDS från en annan server.", 1062 - "migrateAway": "Flytta bort", 1063 - "migrateAwayDesc": "Flytta ditt konto från denna PDS till en annan server.", 1064 - "loginRequired": "Inloggning krävs", 1065 1104 "bringDid": "Ta med din DID och identitet", 1066 1105 "transferData": "Överför all din data", 1067 1106 "keepFollowers": "Behåll dina följare", 1068 - "exportRepo": "Exportera ditt arkiv", 1069 - "transferToPds": "Överför till ny PDS", 1070 - "updateIdentity": "Uppdatera din identitet", 1071 1107 "whatIsMigration": "Vad är kontoflyttning?", 1072 1108 "whatIsMigrationDesc": "Kontoflyttning låter dig flytta din AT Protocol-identitet mellan personliga dataservrar (PDS). Din DID (decentraliserad identifierare) förblir densamma, så dina följare och sociala kopplingar bevaras.", 1073 1109 "beforeMigrate": "Innan du flyttar", ··· 1077 1113 "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering", 1078 1114 "importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.", 1079 1115 "learnMore": "Läs mer om flyttningsrisker", 1080 - "comingSoon": "Kommer snart", 1116 + "offlineRestore": "Offline-återställning", 1117 + "offlineRestoreDesc": "Återställ från backup när din gamla PDS inte är tillgänglig.", 1118 + "offlineFeature1": "Använd en CAR-fil backup", 1119 + "offlineFeature2": "Bevisa ägande med rotationsnyckel", 1120 + "offlineFeature3": "Återställning för nedstängda servrar", 1081 1121 "oauthCompleting": "Slutför autentisering...", 1082 1122 "oauthFailed": "Autentisering misslyckades", 1083 1123 "tryAgain": "Försök igen", ··· 1086 1126 "incomplete": "Du har en ofullständig flytt pågående:", 1087 1127 "direction": "Riktning", 1088 1128 "migratingHere": "Flyttar hit", 1089 - "migratingAway": "Flyttar bort", 1090 1129 "from": "Från", 1091 1130 "to": "Till", 1092 1131 "progress": "Framsteg", ··· 1229 1268 "error": { 1230 1269 "title": "Flyttfel", 1231 1270 "desc": "Ett fel uppstod under flytten.", 1232 - "startOver": "Börja om" 1271 + "startOver": "Börja om", 1272 + "unknown": "Ett okänt fel uppstod." 1233 1273 }, 1234 1274 "common": { 1235 1275 "back": "Tillbaka", ··· 1247 1287 "warning3": "Ditt gamla konto kommer att inaktiveras efter flytten" 1248 1288 } 1249 1289 }, 1250 - "outbound": { 1290 + "offline": { 1251 1291 "welcome": { 1252 - "title": "Flytta från denna PDS", 1253 - "desc": "Flytta ditt konto till en annan personlig dataserver.", 1254 - "warning": "Efter flytten kommer ditt konto här att inaktiveras.", 1255 - "didWebNotice": "did:web-flyttmeddelande", 1256 - "didWebNoticeDesc": "Ditt konto använder en did:web-identifierare ({did}). Efter flytten kommer denna PDS att fortsätta servera ditt DID-dokument som pekar till den nya PDS. Din identitet kommer att fungera så länge denna server är online.", 1257 - "understand": "Jag förstår riskerna och vill fortsätta" 1292 + "title": "Återställ från backup", 1293 + "desc": "Återställ ditt konto med en CAR-fil backup och rotationsnyckel. Använd detta när din tidigare PDS inte är tillgänglig.", 1294 + "warningTitle": "När du ska använda denna metod", 1295 + "warningDesc": "Denna offline-återställning är för katastrofåterställning när din gamla PDS har stängts ner, är oåtkomlig eller du blev utelåst. Om din gamla PDS fortfarande är tillgänglig, använd standardflytten istället.", 1296 + "requirementsTitle": "Du behöver", 1297 + "requirement1": "En CAR-fil backup av ditt arkiv", 1298 + "requirement2": "Din rotationsnyckel (privat nyckel för ditt DID)", 1299 + "requirement3": "Ditt DID (did:plc:xxx)", 1300 + "understand": "Jag förstår och vill fortsätta" 1258 1301 }, 1259 - "targetPds": { 1260 - "title": "Välj mål-PDS", 1261 - "desc": "Ange URL:en för PDS du vill flytta till.", 1262 - "url": "PDS URL", 1263 - "urlPlaceholder": "https://pds.example.com", 1264 - "validate": "Validera och fortsätt", 1265 - "validating": "Validerar...", 1266 - "connected": "Ansluten till {name}", 1267 - "inviteRequired": "Inbjudningskod krävs", 1268 - "privacyPolicy": "Integritetspolicy", 1269 - "termsOfService": "Användarvillkor" 1302 + "provideDid": { 1303 + "title": "Ange ditt DID", 1304 + "desc": "Ange DID för kontot du vill återställa.", 1305 + "label": "Ditt DID", 1306 + "hint": "Din decentraliserade identifierare (t.ex. did:plc:abc123)" 1270 1307 }, 1271 - "newAccount": { 1272 - "title": "Nya kontouppgifter", 1273 - "desc": "Konfigurera ditt konto på den nya PDS.", 1274 - "handle": "Användarnamn", 1275 - "availableDomains": "Tillgängliga domäner", 1276 - "email": "E-post", 1277 - "password": "Lösenord", 1278 - "confirmPassword": "Bekräfta lösenord", 1279 - "inviteCode": "Inbjudningskod" 1308 + "uploadCar": { 1309 + "title": "Ladda upp CAR-fil", 1310 + "desc": "Ladda upp din arkiv-backupfil.", 1311 + "label": "CAR-fil", 1312 + "hint": "Välj .car-filen från din backup", 1313 + "reuploadWarningTitle": "CAR-fil krävs", 1314 + "reuploadWarning": "Din session har återställts, men du måste ladda upp din CAR-fil igen. Av säkerhetsskäl lagras inte filinnehåll mellan sessioner." 1280 1315 }, 1281 - "review": { 1282 - "title": "Granska flytt", 1283 - "desc": "Granska och bekräfta dina flyttdetaljer.", 1284 - "currentHandle": "Nuvarande användarnamn", 1285 - "newHandle": "Nytt användarnamn", 1286 - "sourcePds": "Denna PDS", 1287 - "targetPds": "Mål-PDS", 1288 - "confirm": "Jag bekräftar att jag vill flytta mitt konto", 1289 - "startMigration": "Starta flytt" 1316 + "rotationKey": { 1317 + "title": "Ange rotationsnyckel", 1318 + "desc": "Ange din rotationsnyckel för att bevisa ägande av detta DID.", 1319 + "securityWarningTitle": "Säkerhetsvarning", 1320 + "securityWarning1": "Din rotationsnyckel är extremt känslig - behandla den som ett huvudlösenord", 1321 + "securityWarning2": "Ange den endast på betrodda enheter och nätverk", 1322 + "securityWarning3": "Denna nyckel kommer inte att lagras efter att flytten slutförts", 1323 + "label": "Rotationsnyckel", 1324 + "placeholder": "Ange privat nyckel (hex, base58 eller JWK)", 1325 + "hint": "Den privata nyckeln som motsvarar en av rotationsnycklarna i ditt DID-dokument", 1326 + "valid": "Nyckeln är giltig och matchar en rotationsnyckel i ditt DID", 1327 + "invalid": "Nyckeln matchar inte någon rotationsnyckel i ditt DID-dokument", 1328 + "validating": "Validerar nyckel...", 1329 + "validate": "Validera nyckel" 1290 1330 }, 1291 - "migrating": { 1292 - "title": "Flyttar ditt konto", 1293 - "desc": "Vänta medan vi överför din data..." 1331 + "chooseHandle": { 1332 + "migratingDid": "Återställer DID" 1294 1333 }, 1295 - "plcToken": { 1296 - "title": "Verifiera din identitet", 1297 - "desc": "En verifieringskod har skickats till din e-post." 1334 + "review": { 1335 + "desc": "Granska dina offline-återställningsuppgifter.", 1336 + "carFile": "CAR-fil", 1337 + "rotationKey": "Rotationsnyckel", 1338 + "warning": "När du startar återställningen kommer din identitet att uppdateras för att peka på denna PDS. Detta kan inte enkelt ångras.", 1339 + "plcWarningTitle": "Ingen återvändo", 1340 + "plcWarning": "När du startar kommer ditt DID-dokument att uppdateras för att peka på denna PDS. Om något går fel kan du använda din rotationsnyckel för att återställa, men du bör slutföra flytten för att undvika ett trasigt identitetstillstånd." 1298 1341 }, 1299 - "finalizing": { 1300 - "title": "Slutför flytt", 1301 - "desc": "Vänta medan vi slutför flytten...", 1302 - "updatingForwarding": "Uppdaterar DID-dokumentvidarebefordran..." 1342 + "migrating": { 1343 + "title": "Återställer konto", 1344 + "desc": "Vänta medan ditt konto återställs...", 1345 + "creating": "Skapar konto", 1346 + "importing": "Importerar arkiv", 1347 + "plcSigning": "Uppdaterar identitet", 1348 + "activating": "Aktiverar konto" 1303 1349 }, 1304 1350 "success": { 1305 - "title": "Flytt klar!", 1306 - "desc": "Ditt konto har framgångsrikt flyttats till din nya PDS.", 1307 - "newHandle": "Nytt användarnamn", 1308 - "newPds": "Ny PDS", 1309 - "nextSteps": "Nästa steg", 1310 - "nextSteps1": "Logga in på din nya PDS", 1311 - "nextSteps2": "Uppdatera dina appar med nya uppgifter", 1312 - "nextSteps3": "Dina följare kommer automatiskt se din nya plats", 1313 - "loggingOut": "Loggar ut om {seconds} sekunder..." 1351 + "desc": "Ditt konto har framgångsrikt återställts till denna PDS." 1352 + }, 1353 + "blobs": { 1354 + "title": "Flyttar blobbar", 1355 + "desc": "Försöker återställa bilder och media från din gamla PDS...", 1356 + "migrating": "Flyttar blobbar", 1357 + "failedTitle": "Vissa blobbar kunde inte flyttas", 1358 + "failedDesc": "{count} blobbar kunde inte hämtas från din gamla PDS. Detta kan hända om servern är otillgänglig eller om filerna raderades.", 1359 + "sourceUnreachableTitle": "Käll-PDS otillgänglig", 1360 + "sourceUnreachable": "Kunde inte ansluta till din gamla PDS för att hämta mediafiler. Detta är vanligt vid flytt från en nedstängd server. Dina inlägg kommer att fungera, men vissa bilder kan saknas." 1314 1361 } 1315 1362 }, 1316 1363 "progress": {
+147 -100
frontend/src/locales/zh.json
··· 17 17 "dashboard": "控制台", 18 18 "backToDashboard": "← 返回控制台", 19 19 "copied": "已复制!", 20 - "copyToClipboard": "复制" 20 + "copyToClipboard": "复制", 21 + "verifying": "验证中...", 22 + "saving": "保存中...", 23 + "creating": "创建中...", 24 + "updating": "更新中...", 25 + "sending": "发送中...", 26 + "authenticating": "认证中...", 27 + "checking": "检查中...", 28 + "redirecting": "跳转中...", 29 + "signIn": "登录", 30 + "verify": "验证", 31 + "remove": "移除", 32 + "revoke": "撤销", 33 + "resendCode": "重新发送验证码", 34 + "startOver": "重新开始", 35 + "tryAgain": "重试", 36 + "password": "密码", 37 + "email": "邮箱", 38 + "emailAddress": "邮箱地址", 39 + "handle": "用户名", 40 + "did": "DID", 41 + "verificationCode": "验证码", 42 + "inviteCode": "邀请码", 43 + "newPassword": "新密码", 44 + "confirmPassword": "确认密码", 45 + "enterSixDigitCode": "输入6位验证码", 46 + "passwordHint": "至少8个字符", 47 + "enterPassword": "请输入密码", 48 + "emailPlaceholder": "you@example.com", 49 + "verified": "已验证", 50 + "disabled": "已禁用", 51 + "available": "可用", 52 + "deactivated": "已停用", 53 + "unverified": "未验证", 54 + "backToLogin": "返回登录", 55 + "backToSettings": "返回设置", 56 + "alreadyHaveAccount": "已有账户?", 57 + "createAccount": "立即注册", 58 + "passwordsMismatch": "密码不匹配", 59 + "passwordTooShort": "密码至少需要8个字符" 21 60 }, 22 61 "login": { 23 62 "title": "登录", ··· 49 88 "codeLabel": "验证码", 50 89 "codePlaceholder": "输入6位验证码", 51 90 "verifyButton": "验证账户", 52 - "verifying": "验证中...", 53 - "resendButton": "重新发送验证码", 54 - "resending": "发送中...", 55 - "resent": "验证码已重新发送!", 56 - "backToLogin": "返回登录" 91 + "resent": "验证码已重新发送!" 57 92 }, 58 93 "register": { 59 94 "title": "创建账户", ··· 124 159 "inviteCodePlaceholder": "输入您的邀请码", 125 160 "inviteCodeRequired": "必填", 126 161 "createButton": "创建账户", 127 - "creating": "正在创建...", 128 162 "alreadyHaveAccount": "已有账户?", 129 163 "signIn": "立即登录", 130 164 "wantPasswordless": "想要无密码登录?", ··· 179 213 "navAdminDesc": "服务器统计和管理操作", 180 214 "navDidDocument": "DID 文档", 181 215 "navDidDocumentDesc": "管理您的 DID 文档和密钥", 216 + "navDidDocumentDescActive": "编辑您的 DID 文档设置", 217 + "navBackup": "下载备份", 218 + "navBackupDesc": "将您的存储库下载为 CAR 文件", 219 + "downloadingBackup": "下载中...", 220 + "backupFailed": "下载备份失败", 182 221 "migrated": "已迁移", 183 222 "migratedTitle": "账户已迁移", 184 223 "migratedMessage": "您的账户已迁移到 {pds}。您的 DID 文档仍在此处托管。", ··· 208 247 "serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。", 209 248 "currentPds": "当前 PDS URL", 210 249 "save": "保存更改", 211 - "saving": "保存中...", 212 250 "success": "DID 文档已更新", 213 251 "saveFailed": "保存 DID 文档失败", 214 252 "loadFailed": "加载 DID 文档失败", ··· 246 284 "yourDomain": "您的域名", 247 285 "yourDomainPlaceholder": "example.com", 248 286 "verifyAndUpdate": "验证并更新用户名", 249 - "verifying": "验证中...", 250 287 "newHandle": "新用户名", 251 288 "newHandlePlaceholder": "yourhandle", 252 289 "changeHandleButton": "更改用户名", ··· 262 299 "exportData": "导出数据", 263 300 "exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。", 264 301 "downloadRepo": "下载数据", 302 + "downloadBlobs": "下载媒体文件", 265 303 "exporting": "导出中...", 304 + "backups": { 305 + "title": "备份", 306 + "description": "管理自动备份并恢复账户数据。备份包括所有记录和文件。", 307 + "enableAutomatic": "自动备份", 308 + "enabled": "已启用", 309 + "disabled": "已禁用", 310 + "toggleFailed": "更改备份设置失败", 311 + "noBackups": "暂无备份", 312 + "blocks": "块", 313 + "download": "下载", 314 + "delete": "删除", 315 + "createNow": "立即创建备份", 316 + "created": "备份已创建", 317 + "createFailed": "创建备份失败", 318 + "downloadFailed": "下载备份失败", 319 + "deleted": "备份已删除", 320 + "deleteFailed": "删除备份失败", 321 + "restoreTitle": "从备份恢复", 322 + "restoreDescription": "从之前导出的 CAR 文件恢复账户数据。这将用上传的备份替换当前的存储库。", 323 + "selectFile": "选择 CAR 文件", 324 + "selectedFile": "已选文件", 325 + "restore": "恢复备份", 326 + "restoring": "恢复中...", 327 + "restored": "备份恢复成功", 328 + "restoreFailed": "备份恢复失败" 329 + }, 266 330 "deleteAccount": "删除账户", 267 331 "deleteWarning": "此操作不可逆。您的所有数据将被永久删除。", 268 332 "requestDeletion": "请求删除账户", ··· 291 355 "deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。", 292 356 "deletionFailed": "账户删除失败", 293 357 "repoExported": "数据导出成功", 294 - "exportFailed": "数据导出失败", 358 + "blobsExported": "媒体文件导出成功", 359 + "noBlobsToExport": "没有可导出的媒体文件", 360 + "exportFailed": "导出失败", 295 361 "confirmDelete": "您确定要删除账户吗?此操作无法撤销。" 296 362 } 297 363 }, ··· 306 372 "noPasswords": "暂无应用专用密码", 307 373 "revoke": "撤销", 308 374 "revoking": "撤销中...", 309 - "creating": "创建中...", 310 375 "revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。", 311 376 "saveWarningTitle": "重要:请保存此应用专用密码!", 312 377 "saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。", ··· 354 419 "used": "已被 @{handle} 使用", 355 420 "disabled": "已禁用", 356 421 "usedBy": "使用者", 357 - "creating": "创建中...", 358 422 "disableConfirm": "禁用此邀请码?它将无法再被使用。", 359 423 "created": "邀请码已创建", 360 424 "copy": "复制", ··· 482 546 "verifyButton": "验证", 483 547 "verifyCodePlaceholder": "输入验证码", 484 548 "submit": "提交", 485 - "saving": "保存中...", 486 549 "savePreferences": "保存偏好设置", 487 550 "preferencesSaved": "通讯偏好已保存", 488 551 "verifiedSuccess": "{channel} 验证成功", ··· 521 584 "noCollectionsYet": "暂无集合。创建您的第一条记录开始使用。", 522 585 "loadMore": "加载更多", 523 586 "recordJson": "记录 JSON", 524 - "saving": "保存中...", 525 587 "updateRecord": "更新记录", 526 588 "collectionNsid": "集合 (NSID)", 527 589 "recordKeyOptional": "记录键(可选)", 528 590 "autoGenerated": "留空自动生成 (TID)", 529 591 "autoGeneratedHint": "留空将自动生成基于 TID 的键", 530 - "creating": "创建中...", 531 592 "demoPostText": "你好,这是我的第一条帖子!来自我的 PDS。", 532 593 "demoDisplayName": "你的显示名称", 533 594 "demoBio": "写一段简短的自我介绍。" ··· 551 612 "secondaryLight": "副色(浅色模式)", 552 613 "secondaryDark": "副色(深色模式)", 553 614 "configSaved": "服务器配置已保存", 554 - "saving": "保存中...", 555 615 "saveConfig": "保存配置", 556 616 "serverStats": "服务器统计", 557 617 "users": "用户", ··· 639 699 "title": "双重身份验证", 640 700 "subtitle": "需要额外验证", 641 701 "usePasskey": "使用通行密钥", 642 - "useTotp": "使用身份验证器", 643 - "verifying": "验证中..." 702 + "useTotp": "使用身份验证器" 644 703 }, 645 704 "twoFactorCode": { 646 705 "title": "双重身份验证", 647 706 "subtitle": "验证码已发送到您的 {channel}。请在下方输入验证码继续。", 648 707 "codeLabel": "验证码", 649 708 "codePlaceholder": "输入6位验证码", 650 - "verify": "验证", 651 - "verifying": "验证中...", 652 709 "errors": { 653 710 "missingRequestUri": "缺少 request_uri 参数", 654 711 "verificationFailed": "验证失败", ··· 660 717 "title": "输入验证码", 661 718 "subtitle": "请输入身份验证器应用中的6位验证码", 662 719 "codePlaceholder": "输入6位验证码", 663 - "verify": "验证", 664 - "verifying": "验证中...", 665 720 "useBackupCode": "使用备用验证码", 666 721 "backupCodePlaceholder": "输入备用验证码", 667 722 "trustDevice": "信任此设备30天", ··· 691 746 "codeLabel": "验证码", 692 747 "codeHelp": "复制消息中的完整验证码,包括横线", 693 748 "verifyButton": "验证账户", 694 - "verify": "验证", 695 - "verifying": "验证中...", 696 749 "pleaseWait": "请稍候...", 697 - "resendCode": "重新发送验证码", 698 - "resending": "发送中...", 699 - "sending": "发送中...", 700 750 "codeResent": "验证码已重新发送!", 701 751 "codeResentDetail": "验证码已发送!请查收。", 702 - "backToLogin": "返回登录", 703 752 "verifyingAccount": "正在验证账户:@{handle}", 704 753 "startOver": "使用其他账户重新开始", 705 754 "noPending": "未找到待验证的账户", ··· 713 762 "identifierLabel": "邮箱或标识符", 714 763 "identifierPlaceholder": "you@example.com", 715 764 "identifierHelp": "接收验证码的邮箱地址或标识符", 716 - "backToSettings": "返回设置", 717 765 "emailUpdateCodeHelp": "验证码已发送到您当前的邮箱地址", 718 766 "emailUpdateFailed": "更新邮箱地址失败", 719 767 "emailUpdateRequiresAuth": "您需要登录才能更新邮箱地址。", ··· 746 794 "resetButton": "重置密码", 747 795 "resetting": "重置中...", 748 796 "success": "密码重置成功!", 749 - "backToLogin": "返回登录", 750 797 "requestNewCode": "重新获取验证码", 751 798 "passwordsMismatch": "两次输入的密码不一致", 752 799 "passwordLength": "密码至少需要8位字符" ··· 790 837 "howItWorks": "如何恢复", 791 838 "howItWorksDetail": "我们将向您注册的通知渠道发送安全链接。点击链接设置临时密码,然后您就可以登录并添加新的通行密钥。", 792 839 "sendRecoveryLink": "发送恢复链接", 793 - "sending": "发送中...", 794 - "backToLogin": "返回登录" 840 + "sending": "发送中..." 795 841 }, 796 842 "registerPasskey": { 797 843 "title": "创建通行密钥账户", ··· 814 860 "inviteCode": "邀请码", 815 861 "inviteCodePlaceholder": "输入您的邀请码", 816 862 "createButton": "创建账户", 817 - "creating": "创建中...", 818 863 "continue": "继续", 819 864 "back": "返回", 820 865 "alreadyHaveAccount": "已有账户?", ··· 911 956 "useTotp": "使用身份验证器", 912 957 "passwordPlaceholder": "输入您的密码", 913 958 "totpPlaceholder": "输入6位验证码", 914 - "verify": "验证", 915 - "verifying": "验证中...", 916 959 "authenticating": "正在验证...", 917 960 "passkeyPrompt": "点击下方按钮使用通行密钥进行验证。", 918 961 "cancel": "取消" ··· 986 1029 "createAccount": "创建账户", 987 1030 "createDelegatedAccount": "创建委托账户", 988 1031 "createDelegatedAccountButton": "+ 创建委托账户", 989 - "creating": "创建中...", 990 1032 "emailOptional": "邮箱(可选)", 991 1033 "failedToAddController": "添加控制者失败", 992 1034 "failedToCreateAccount": "创建委托账户失败", ··· 1059 1101 "navDesc": "将您的账户移至其他PDS或从其他PDS移入", 1060 1102 "migrateHere": "迁移到此处", 1061 1103 "migrateHereDesc": "将您现有的AT Protocol账户从其他服务器移至此PDS。", 1062 - "migrateAway": "迁移离开", 1063 - "migrateAwayDesc": "将您的账户从此PDS移至其他服务器。", 1064 - "loginRequired": "需要登录", 1065 1104 "bringDid": "携带您的DID和身份", 1066 1105 "transferData": "转移所有数据", 1067 1106 "keepFollowers": "保留您的关注者", 1068 - "exportRepo": "导出您的存储库", 1069 - "transferToPds": "转移到新PDS", 1070 - "updateIdentity": "更新您的身份", 1071 1107 "whatIsMigration": "什么是账户迁移?", 1072 1108 "whatIsMigrationDesc": "账户迁移允许您在个人数据服务器(PDS)之间移动AT Protocol身份。您的DID(去中心化标识符)保持不变,因此您的关注者和社交连接得以保留。", 1073 1109 "beforeMigrate": "迁移前须知", ··· 1077 1113 "beforeMigrate4": "您的旧PDS将收到账户停用通知", 1078 1114 "importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。", 1079 1115 "learnMore": "了解更多迁移风险", 1080 - "comingSoon": "即将推出", 1116 + "offlineRestore": "离线恢复", 1117 + "offlineRestoreDesc": "当旧 PDS 不可用时从备份恢复。", 1118 + "offlineFeature1": "使用 CAR 文件备份", 1119 + "offlineFeature2": "使用轮换密钥证明所有权", 1120 + "offlineFeature3": "用于已关闭服务器的恢复", 1081 1121 "oauthCompleting": "正在完成身份验证...", 1082 1122 "oauthFailed": "身份验证失败", 1083 1123 "tryAgain": "重试", ··· 1086 1126 "incomplete": "您有一个未完成的迁移:", 1087 1127 "direction": "方向", 1088 1128 "migratingHere": "正在迁移到此处", 1089 - "migratingAway": "正在迁移离开", 1090 1129 "from": "从", 1091 1130 "to": "到", 1092 1131 "progress": "进度", ··· 1229 1268 "error": { 1230 1269 "title": "迁移错误", 1231 1270 "desc": "迁移过程中发生错误。", 1232 - "startOver": "重新开始" 1271 + "startOver": "重新开始", 1272 + "unknown": "发生未知错误。" 1233 1273 }, 1234 1274 "common": { 1235 1275 "back": "返回", ··· 1247 1287 "warning3": "迁移后您的旧账户将被停用" 1248 1288 } 1249 1289 }, 1250 - "outbound": { 1290 + "offline": { 1251 1291 "welcome": { 1252 - "title": "从此PDS迁移离开", 1253 - "desc": "将您的账户移至另一个个人数据服务器。", 1254 - "warning": "迁移后,您在此处的账户将被停用。", 1255 - "didWebNotice": "did:web迁移通知", 1256 - "didWebNoticeDesc": "您的账户使用did:web标识符({did})。迁移后,此PDS将继续提供指向新PDS的DID文档。只要此服务器在线,您的身份将继续有效。", 1257 - "understand": "我了解风险并希望继续" 1292 + "title": "从备份恢复", 1293 + "desc": "使用 CAR 文件备份和轮换密钥恢复您的账户。当您的旧 PDS 不可用时使用此方法。", 1294 + "warningTitle": "何时使用此方法", 1295 + "warningDesc": "此离线恢复用于灾难恢复,当您的旧 PDS 已关闭、无法访问或您被锁定时使用。如果您的旧 PDS 仍然可用,请使用标准迁移。", 1296 + "requirementsTitle": "您需要", 1297 + "requirement1": "您的存储库的 CAR 文件备份", 1298 + "requirement2": "您的轮换密钥(DID 的私钥)", 1299 + "requirement3": "您的 DID (did:plc:xxx)", 1300 + "understand": "我了解并希望继续" 1258 1301 }, 1259 - "targetPds": { 1260 - "title": "选择目标PDS", 1261 - "desc": "输入您要迁移到的PDS的URL。", 1262 - "url": "PDS URL", 1263 - "urlPlaceholder": "https://pds.example.com", 1264 - "validate": "验证并继续", 1265 - "validating": "验证中...", 1266 - "connected": "已连接到 {name}", 1267 - "inviteRequired": "需要邀请码", 1268 - "privacyPolicy": "隐私政策", 1269 - "termsOfService": "服务条款" 1302 + "provideDid": { 1303 + "title": "输入您的 DID", 1304 + "desc": "输入您要恢复的账户的 DID。", 1305 + "label": "您的 DID", 1306 + "hint": "您的去中心化标识符(例如 did:plc:abc123)" 1270 1307 }, 1271 - "newAccount": { 1272 - "title": "新账户详情", 1273 - "desc": "在新PDS上设置您的账户。", 1274 - "handle": "用户名", 1275 - "availableDomains": "可用域名", 1276 - "email": "邮箱", 1277 - "password": "密码", 1278 - "confirmPassword": "确认密码", 1279 - "inviteCode": "邀请码" 1308 + "uploadCar": { 1309 + "title": "上传 CAR 文件", 1310 + "desc": "上传您的存储库备份文件。", 1311 + "label": "CAR 文件", 1312 + "hint": "从您的备份中选择 .car 文件", 1313 + "reuploadWarningTitle": "需要 CAR 文件", 1314 + "reuploadWarning": "您的会话已恢复,但您需要重新上传 CAR 文件。出于安全原因,文件内容不会在会话之间保存。" 1280 1315 }, 1281 - "review": { 1282 - "title": "检查迁移", 1283 - "desc": "请检查并确认您的迁移详情。", 1284 - "currentHandle": "当前用户名", 1285 - "newHandle": "新用户名", 1286 - "sourcePds": "此PDS", 1287 - "targetPds": "目标PDS", 1288 - "confirm": "我确认要迁移我的账户", 1289 - "startMigration": "开始迁移" 1316 + "rotationKey": { 1317 + "title": "提供轮换密钥", 1318 + "desc": "输入您的轮换密钥以证明此 DID 的所有权。", 1319 + "securityWarningTitle": "安全警告", 1320 + "securityWarning1": "您的轮换密钥极为敏感 - 请像对待主密码一样对待它", 1321 + "securityWarning2": "仅在受信任的设备和网络上输入", 1322 + "securityWarning3": "迁移完成后此密钥不会被存储", 1323 + "label": "轮换密钥", 1324 + "placeholder": "输入私钥(hex、base58 或 JWK)", 1325 + "hint": "与您的 DID 文档中的轮换密钥之一对应的私钥", 1326 + "valid": "密钥有效并匹配您的 DID 中的轮换密钥", 1327 + "invalid": "密钥与您的 DID 文档中的任何轮换密钥都不匹配", 1328 + "validating": "验证密钥...", 1329 + "validate": "验证密钥" 1290 1330 }, 1291 - "migrating": { 1292 - "title": "正在迁移您的账户", 1293 - "desc": "请稍候,正在转移您的数据..." 1331 + "chooseHandle": { 1332 + "migratingDid": "恢复 DID" 1294 1333 }, 1295 - "plcToken": { 1296 - "title": "验证您的身份", 1297 - "desc": "验证码已发送到您的邮箱。" 1334 + "review": { 1335 + "desc": "检查您的离线恢复详情。", 1336 + "carFile": "CAR 文件", 1337 + "rotationKey": "轮换密钥", 1338 + "warning": "开始恢复后,您的身份将更新为指向此 PDS。此操作无法轻易撤销。", 1339 + "plcWarningTitle": "不可逆转点", 1340 + "plcWarning": "一旦开始,您的 DID 文档将更新为指向此 PDS。如果出现问题,您可以使用轮换密钥恢复,但您应该完成迁移以避免身份状态损坏。" 1298 1341 }, 1299 - "finalizing": { 1300 - "title": "正在完成迁移", 1301 - "desc": "请稍候,正在完成迁移...", 1302 - "updatingForwarding": "正在更新DID文档转发..." 1342 + "migrating": { 1343 + "title": "恢复账户", 1344 + "desc": "请稍候,正在恢复您的账户...", 1345 + "creating": "创建账户", 1346 + "importing": "导入存储库", 1347 + "plcSigning": "更新身份", 1348 + "activating": "激活账户" 1303 1349 }, 1304 1350 "success": { 1305 - "title": "迁移完成!", 1306 - "desc": "您的账户已成功迁移到新PDS。", 1307 - "newHandle": "新用户名", 1308 - "newPds": "新PDS", 1309 - "nextSteps": "后续步骤", 1310 - "nextSteps1": "登录到您的新PDS", 1311 - "nextSteps2": "使用新凭据更新您的应用", 1312 - "nextSteps3": "您的关注者将自动看到您的新位置", 1313 - "loggingOut": "{seconds}秒后退出登录..." 1351 + "desc": "您的账户已成功恢复到此 PDS。" 1352 + }, 1353 + "blobs": { 1354 + "title": "迁移 Blob", 1355 + "desc": "正在尝试从您的旧 PDS 恢复图片和媒体...", 1356 + "migrating": "正在迁移 blob", 1357 + "failedTitle": "部分 blob 无法迁移", 1358 + "failedDesc": "{count} 个 blob 无法从您的旧 PDS 获取。这可能是因为服务器无法访问或文件已被删除。", 1359 + "sourceUnreachableTitle": "源 PDS 无法访问", 1360 + "sourceUnreachable": "无法连接到您的旧 PDS 来获取媒体文件。从已关闭的服务器迁移时这很常见。您的帖子将正常工作,但部分图片可能会丢失。" 1314 1361 } 1315 1362 }, 1316 1363 "progress": {
+1 -1
frontend/src/routes/ActAs.svelte
··· 37 37 38 38 try { 39 39 const response = await fetch( 40 - `/xrpc/com.tranquil.delegation.listControlledAccounts`, 40 + `/xrpc/_delegation.listControlledAccounts`, 41 41 { 42 42 headers: { 'Authorization': `Bearer ${auth.session!.accessJwt}` } 43 43 }
+1 -1
frontend/src/routes/Admin.svelte
··· 435 435 <div class="message success">{$_('admin.configSaved')}</div> 436 436 {/if} 437 437 <button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}> 438 - {serverConfigLoading ? $_('admin.saving') : $_('admin.saveConfig')} 438 + {serverConfigLoading ? $_('common.saving') : $_('admin.saveConfig')} 439 439 </button> 440 440 </form> 441 441 </section>
+1 -1
frontend/src/routes/AppPasswords.svelte
··· 155 155 </div> 156 156 </div> 157 157 <button type="submit" disabled={creating || !newPasswordName.trim()}> 158 - {creating ? $_('appPasswords.creating') : $_('common.create')} 158 + {creating ? $_('common.creating') : $_('common.create')} 159 159 </button> 160 160 </form> 161 161 </section>
+1 -1
frontend/src/routes/Comms.svelte
··· 341 341 342 342 <div class="actions"> 343 343 <button type="submit" disabled={saving}> 344 - {saving ? $_('comms.saving') : $_('comms.savePreferences')} 344 + {saving ? $_('common.saving') : $_('comms.savePreferences')} 345 345 </button> 346 346 </div> 347 347 </form>
+7 -7
frontend/src/routes/Controllers.svelte
··· 75 75 async function loadControllers() { 76 76 if (!auth.session) return 77 77 try { 78 - const response = await fetch('/xrpc/com.tranquil.delegation.listControllers', { 78 + const response = await fetch('/xrpc/_delegation.listControllers', { 79 79 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 80 80 }) 81 81 if (response.ok) { ··· 90 90 async function loadControlledAccounts() { 91 91 if (!auth.session) return 92 92 try { 93 - const response = await fetch('/xrpc/com.tranquil.delegation.listControlledAccounts', { 93 + const response = await fetch('/xrpc/_delegation.listControlledAccounts', { 94 94 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 95 95 }) 96 96 if (response.ok) { ··· 104 104 105 105 async function loadScopePresets() { 106 106 try { 107 - const response = await fetch('/xrpc/com.tranquil.delegation.getScopePresets') 107 + const response = await fetch('/xrpc/_delegation.getScopePresets') 108 108 if (response.ok) { 109 109 const data = await response.json() 110 110 scopePresets = data.presets || [] ··· 121 121 success = null 122 122 123 123 try { 124 - const response = await fetch('/xrpc/com.tranquil.delegation.addController', { 124 + const response = await fetch('/xrpc/_delegation.addController', { 125 125 method: 'POST', 126 126 headers: { 127 127 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 159 159 success = null 160 160 161 161 try { 162 - const response = await fetch('/xrpc/com.tranquil.delegation.removeController', { 162 + const response = await fetch('/xrpc/_delegation.removeController', { 163 163 method: 'POST', 164 164 headers: { 165 165 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 188 188 success = null 189 189 190 190 try { 191 - const response = await fetch('/xrpc/com.tranquil.delegation.createDelegatedAccount', { 191 + const response = await fetch('/xrpc/_delegation.createDelegatedAccount', { 192 192 method: 'POST', 193 193 headers: { 194 194 'Authorization': `Bearer ${auth.session.accessJwt}`, ··· 407 407 {$_('common.cancel')} 408 408 </button> 409 409 <button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}> 410 - {creatingDelegated ? $_('delegation.creating') : $_('delegation.createAccount')} 410 + {creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')} 411 411 </button> 412 412 </div> 413 413 </div>
+21
frontend/src/routes/Dashboard.svelte
··· 10 10 let switching = $state(false) 11 11 let inviteCodesEnabled = $state(false) 12 12 13 + const isDidWeb = $derived(auth.session?.did?.startsWith('did:web:') ?? false) 14 + 13 15 onMount(async () => { 14 16 try { 15 17 const serverInfo = await api.describeServer() ··· 176 178 <h3>{$_('dashboard.navSecurity')}</h3> 177 179 <p>{$_('dashboard.navSecurityDesc')}</p> 178 180 </a> 181 + <a href="#/settings" class="nav-card"> 182 + <h3>{$_('dashboard.navSettings')}</h3> 183 + <p>{$_('dashboard.navSettingsDesc')}</p> 184 + </a> 179 185 <a href="#/migrate" class="nav-card"> 180 186 <h3>{$_('dashboard.navMigrateAgain')}</h3> 181 187 <p>{$_('dashboard.navMigrateAgainDesc')}</p> ··· 215 221 <h3>{$_('dashboard.navDelegation')}</h3> 216 222 <p>{$_('dashboard.navDelegationDesc')}</p> 217 223 </a> 224 + {#if isDidWeb} 225 + <a href="#/did-document" class="nav-card did-web-card"> 226 + <h3>{$_('dashboard.navDidDocument')}</h3> 227 + <p>{$_('dashboard.navDidDocumentDescActive')}</p> 228 + </a> 229 + {/if} 218 230 <a href="#/migrate" class="nav-card"> 219 231 <h3>{$_('migration.navTitle')}</h3> 220 232 <p>{$_('migration.navDesc')}</p> ··· 503 515 504 516 .nav-card.migrated-card h3 { 505 517 color: var(--info-text, #0369a1); 518 + } 519 + 520 + .nav-card.did-web-card { 521 + border-color: var(--accent); 522 + background: linear-gradient(135deg, var(--bg-card) 0%, var(--accent-muted) 100%); 523 + } 524 + 525 + .nav-card.did-web-card:hover { 526 + box-shadow: 0 2px 12px var(--accent-muted); 506 527 } 507 528 </style>
+1 -1
frontend/src/routes/DelegationAudit.svelte
··· 41 41 42 42 try { 43 43 const response = await fetch( 44 - `/xrpc/com.tranquil.delegation.getAuditLog?limit=${limit}&offset=${offset}`, 44 + `/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`, 45 45 { 46 46 headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` } 47 47 }
+1 -1
frontend/src/routes/DidDocumentEditor.svelte
··· 230 230 231 231 <div class="actions"> 232 232 <button onclick={handleSave} disabled={saving}> 233 - {saving ? $_('didEditor.saving') : $_('didEditor.save')} 233 + {saving ? $_('common.saving') : $_('common.save')} 234 234 </button> 235 235 </div> 236 236 {/if}
+5
frontend/src/routes/Home.svelte
··· 183 183 <h3>Delegate without sharing passwords</h3> 184 184 <p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p> 185 185 </div> 186 + 187 + <div class="feature"> 188 + <h3>Automatic backups</h3> 189 + <p>Your repository is backed up daily to object storage. Download any backup or restore with one click. You own your data, even if the worst happens.</p> 190 + </div> 186 191 </div> 187 192 188 193 <h2>Everything in one place</h2>
+1 -1
frontend/src/routes/InviteCodes.svelte
··· 111 111 {#if auth.session?.isAdmin} 112 112 <section class="create-section"> 113 113 <button onclick={handleCreate} disabled={creating}> 114 - {creating ? $_('inviteCodes.creating') : $_('inviteCodes.createNew')} 114 + {creating ? $_('common.creating') : $_('inviteCodes.createNew')} 115 115 </button> 116 116 </section> 117 117 {/if}
+3 -3
frontend/src/routes/Login.svelte
··· 107 107 </div> 108 108 <div class="actions"> 109 109 <button type="submit" disabled={submitting || !verificationCode.trim()}> 110 - {submitting ? $_('verification.verifying') : $_('verification.verifyButton')} 110 + {submitting ? $_('common.verifying') : $_('common.verify')} 111 111 </button> 112 112 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 113 - {resendingCode ? $_('verification.resending') : $_('verification.resendButton')} 113 + {resendingCode ? $_('common.sending') : $_('common.resendCode')} 114 114 </button> 115 115 <button type="button" class="tertiary" onclick={backToLogin}> 116 - {$_('verification.backToLogin')} 116 + {$_('common.backToLogin')} 117 117 </button> 118 118 </div> 119 119 </form>
+63 -69
frontend/src/routes/Migration.svelte
··· 1 1 <script lang="ts"> 2 - import { getAuthState, logout, setSession } from '../lib/auth.svelte' 2 + import { setSession } from '../lib/auth.svelte' 3 3 import { navigate } from '../lib/router.svelte' 4 4 import { _ } from '../lib/i18n' 5 5 import { 6 6 createInboundMigrationFlow, 7 - createOutboundMigrationFlow, 7 + createOfflineInboundMigrationFlow, 8 8 hasPendingMigration, 9 + hasPendingOfflineMigration, 9 10 getResumeInfo, 11 + getOfflineResumeInfo, 10 12 clearMigrationState, 13 + clearOfflineState, 11 14 loadMigrationState, 12 15 } from '../lib/migration' 13 16 import InboundWizard from '../components/migration/InboundWizard.svelte' 14 - import OutboundWizard from '../components/migration/OutboundWizard.svelte' 17 + import OfflineInboundWizard from '../components/migration/OfflineInboundWizard.svelte' 15 18 16 - const auth = getAuthState() 17 - 18 - type Direction = 'select' | 'inbound' | 'outbound' 19 + type Direction = 'select' | 'inbound' | 'offline-inbound' 19 20 let direction = $state<Direction>('select') 20 21 let showResumeModal = $state(false) 21 22 let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null) ··· 23 24 let oauthLoading = $state(false) 24 25 25 26 let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null) 26 - let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null) 27 + let offlineFlow = $state<ReturnType<typeof createOfflineInboundMigrationFlow> | null>(null) 27 28 let oauthCallbackProcessed = $state(false) 28 29 29 30 $effect(() => { ··· 66 67 const urlParams = new URLSearchParams(window.location.search) 67 68 const hasOAuthCallback = urlParams.has('code') || urlParams.has('error') 68 69 69 - if (!hasOAuthCallback && hasPendingMigration()) { 70 - resumeInfo = getResumeInfo() 71 - if (resumeInfo) { 72 - const stored = loadMigrationState() 73 - if (stored) { 74 - if (stored.direction === 'inbound') { 75 - direction = 'inbound' 76 - inboundFlow = createInboundMigrationFlow() 77 - inboundFlow.resumeFromState(stored) 70 + if (!hasOAuthCallback) { 71 + if (hasPendingMigration()) { 72 + resumeInfo = getResumeInfo() 73 + if (resumeInfo) { 74 + if (resumeInfo.step === 'success') { 75 + clearMigrationState() 76 + resumeInfo = null 78 77 } else { 79 - direction = 'outbound' 80 - outboundFlow = createOutboundMigrationFlow() 78 + const stored = loadMigrationState() 79 + if (stored && stored.direction === 'inbound') { 80 + direction = 'inbound' 81 + inboundFlow = createInboundMigrationFlow() 82 + inboundFlow.resumeFromState(stored) 83 + } 81 84 } 82 85 } 86 + } else if (hasPendingOfflineMigration()) { 87 + const offlineInfo = getOfflineResumeInfo() 88 + if (offlineInfo && offlineInfo.step === 'success') { 89 + clearOfflineState() 90 + } else { 91 + direction = 'offline-inbound' 92 + offlineFlow = createOfflineInboundMigrationFlow() 93 + offlineFlow.tryResume() 94 + } 83 95 } 84 96 } 85 97 ··· 88 100 inboundFlow = createInboundMigrationFlow() 89 101 } 90 102 91 - function selectOutbound() { 92 - if (!auth.session) { 93 - navigate('/login') 94 - return 95 - } 96 - direction = 'outbound' 97 - outboundFlow = createOutboundMigrationFlow() 98 - outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle) 103 + function selectOfflineInbound() { 104 + direction = 'offline-inbound' 105 + offlineFlow = createOfflineInboundMigrationFlow() 99 106 } 100 107 101 108 function handleResume() { ··· 108 115 direction = 'inbound' 109 116 inboundFlow = createInboundMigrationFlow() 110 117 inboundFlow.resumeFromState(stored) 111 - } else { 112 - if (!auth.session) { 113 - navigate('/login') 114 - return 115 - } 116 - direction = 'outbound' 117 - outboundFlow = createOutboundMigrationFlow() 118 - outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle) 119 118 } 120 119 } 121 120 ··· 130 129 inboundFlow.reset() 131 130 inboundFlow = null 132 131 } 133 - if (outboundFlow) { 134 - outboundFlow.reset() 135 - outboundFlow = null 132 + if (offlineFlow) { 133 + offlineFlow.reset() 134 + offlineFlow = null 136 135 } 137 136 direction = 'select' 138 137 } ··· 150 149 navigate('/dashboard') 151 150 } 152 151 153 - async function handleOutboundComplete() { 154 - await logout() 155 - navigate('/login') 152 + function handleOfflineComplete() { 153 + const session = offlineFlow?.getLocalSession() 154 + if (session) { 155 + setSession({ 156 + did: session.did, 157 + handle: session.handle, 158 + accessJwt: session.accessJwt, 159 + refreshJwt: '', 160 + }) 161 + } 162 + navigate('/dashboard') 156 163 } 157 164 </script> 158 165 ··· 165 172 <div class="resume-details"> 166 173 <div class="detail-row"> 167 174 <span class="label">{$_('migration.resume.direction')}:</span> 168 - <span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span> 175 + <span class="value">{$_('migration.resume.migratingHere')}</span> 169 176 </div> 170 177 {#if resumeInfo.sourceHandle} 171 178 <div class="detail-row"> ··· 212 219 213 220 <div class="direction-cards"> 214 221 <button class="direction-card ghost" onclick={selectInbound}> 215 - <div class="card-icon">↓</div> 216 222 <h2>{$_('migration.migrateHere')}</h2> 217 223 <p>{$_('migration.migrateHereDesc')}</p> 218 224 <ul class="features"> ··· 222 228 </ul> 223 229 </button> 224 230 225 - <button class="direction-card ghost" onclick={selectOutbound} disabled> 226 - <div class="card-icon">↑</div> 227 - <h2>{$_('migration.migrateAway')}</h2> 228 - <p>{$_('migration.migrateAwayDesc')}</p> 231 + <button class="direction-card ghost offline-card" onclick={selectOfflineInbound}> 232 + <h2>{$_('migration.offlineRestore')}</h2> 233 + <p>{$_('migration.offlineRestoreDesc')}</p> 229 234 <ul class="features"> 230 - <li>{$_('migration.exportRepo')}</li> 231 - <li>{$_('migration.transferToPds')}</li> 232 - <li>{$_('migration.updateIdentity')}</li> 235 + <li>{$_('migration.offlineFeature1')}</li> 236 + <li>{$_('migration.offlineFeature2')}</li> 237 + <li>{$_('migration.offlineFeature3')}</li> 233 238 </ul> 234 - <p class="login-required">{$_('migration.comingSoon')}</p> 235 239 </button> 236 240 </div> 237 241 ··· 263 267 onComplete={handleInboundComplete} 264 268 /> 265 269 266 - {:else if direction === 'outbound' && outboundFlow} 267 - <OutboundWizard 268 - flow={outboundFlow} 270 + {:else if direction === 'offline-inbound' && offlineFlow} 271 + <OfflineInboundWizard 272 + flow={offlineFlow} 269 273 onBack={handleBack} 270 - onComplete={handleOutboundComplete} 274 + onComplete={handleOfflineComplete} 271 275 /> 272 276 {/if} 273 277 </div> ··· 302 306 } 303 307 304 308 .direction-card { 309 + display: flex; 310 + flex-direction: column; 311 + align-items: stretch; 305 312 background: var(--bg-secondary); 306 313 border: 1px solid var(--border); 307 314 border-radius: var(--radius-xl); ··· 322 329 cursor: not-allowed; 323 330 } 324 331 325 - .card-icon { 326 - font-size: var(--text-3xl); 327 - margin-bottom: var(--space-4); 328 - color: var(--accent); 329 - } 330 - 331 332 .direction-card h2 { 332 333 margin: 0 0 var(--space-3) 0; 333 334 font-size: var(--text-xl); ··· 349 350 350 351 .features li { 351 352 margin-bottom: var(--space-2); 352 - } 353 - 354 - .login-required { 355 - color: var(--warning-text); 356 - font-weight: var(--font-medium); 357 - margin-top: var(--space-4); 358 353 } 359 354 360 355 .info-section { ··· 402 397 } 403 398 404 399 .warning-box a { 405 - display: block; 406 - margin-top: var(--space-3); 407 - color: var(--accent); 400 + display: inline; 401 + margin-top: var(--space-2); 408 402 } 409 403 410 404 .modal-overlay {
+1 -1
frontend/src/routes/OAuth2FA.svelte
··· 105 105 {$_('common.cancel')} 106 106 </button> 107 107 <button type="submit" class="submit-btn" disabled={submitting || code.trim().length !== 6}> 108 - {submitting ? $_('oauth.twoFactorCode.verifying') : $_('oauth.twoFactorCode.verify')} 108 + {submitting ? $_('common.verifying') : $_('common.verify')} 109 109 </button> 110 110 </div> 111 111 </form>
+1 -1
frontend/src/routes/OAuthConsent.svelte
··· 171 171 <h1>{$_('oauth.error.title')}</h1> 172 172 <div class="error">{error}</div> 173 173 <button type="button" onclick={() => navigate('/login')}> 174 - {$_('verify.backToLogin')} 174 + {$_('common.backToLogin')} 175 175 </button> 176 176 </div> 177 177 {:else if consentData}
+1 -1
frontend/src/routes/OAuthTotp.svelte
··· 121 121 {$_('common.cancel')} 122 122 </button> 123 123 <button type="submit" class="submit-btn" disabled={submitting || !canSubmit}> 124 - {submitting ? $_('oauth.totp.verifying') : $_('oauth.totp.verify')} 124 + {submitting ? $_('common.verifying') : $_('common.verify')} 125 125 </button> 126 126 </div> 127 127 </form>
+3 -3
frontend/src/routes/Register.svelte
··· 145 145 case 'info': return $_('register.subtitle') 146 146 case 'key-choice': return $_('register.subtitleKeyChoice') 147 147 case 'initial-did-doc': return $_('register.subtitleInitialDidDoc') 148 - case 'creating': return $_('register.creating') 148 + case 'creating': return $_('common.creating') 149 149 case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } }) 150 150 case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc') 151 151 case 'activating': return $_('register.subtitleActivating') ··· 375 375 {/if} 376 376 377 377 <button type="submit" disabled={flow.state.submitting}> 378 - {flow.state.submitting ? $_('register.creating') : $_('register.createButton')} 378 + {flow.state.submitting ? $_('common.creating') : $_('register.createButton')} 379 379 </button> 380 380 </form> 381 381 ··· 413 413 /> 414 414 415 415 {:else if flow.state.step === 'creating'} 416 - <p class="loading">{$_('register.creating')}</p> 416 + <p class="loading">{$_('common.creating')}</p> 417 417 418 418 {:else if flow.state.step === 'verify'} 419 419 <VerificationStep {flow} />
+1 -1
frontend/src/routes/RegisterPasskey.svelte
··· 408 408 </div> 409 409 410 410 <button type="submit" disabled={flow.state.submitting}> 411 - {flow.state.submitting ? $_('registerPasskey.creating') : $_('registerPasskey.continue')} 411 + {flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')} 412 412 </button> 413 413 </form> 414 414
+2 -2
frontend/src/routes/RepoExplorer.svelte
··· 417 417 </div> 418 418 <div class="actions"> 419 419 <button type="submit" class="primary" disabled={saving || !!jsonError}> 420 - {saving ? $_('repoExplorer.saving') : $_('repoExplorer.updateRecord')} 420 + {saving ? $_('common.saving') : $_('repoExplorer.updateRecord')} 421 421 </button> 422 422 <button type="button" class="danger" onclick={handleDelete} disabled={saving}> 423 423 {$_('common.delete')} ··· 464 464 </div> 465 465 <div class="actions"> 466 466 <button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}> 467 - {saving ? $_('repoExplorer.creating') : $_('repoExplorer.createRecord')} 467 + {saving ? $_('common.creating') : $_('repoExplorer.createRecord')} 468 468 </button> 469 469 <button type="button" class="secondary" onclick={goBack}> 470 470 {$_('common.cancel')}
+2 -2
frontend/src/routes/RequestPasskeyRecovery.svelte
··· 36 36 <h1>{$_('requestPasskeyRecovery.successTitle')}</h1> 37 37 <p class="subtitle">{$_('requestPasskeyRecovery.successMessage')}</p> 38 38 <p class="info-text">{$_('requestPasskeyRecovery.successInfo')}</p> 39 - <button onclick={() => navigate('/login')}>{$_('requestPasskeyRecovery.backToLogin')}</button> 39 + <button onclick={() => navigate('/login')}>{$_('common.backToLogin')}</button> 40 40 </div> 41 41 {:else} 42 42 <h1>{$_('requestPasskeyRecovery.title')}</h1> ··· 71 71 {/if} 72 72 73 73 <p class="link-text"> 74 - <a href="#/login">{$_('requestPasskeyRecovery.backToLogin')}</a> 74 + <a href="#/login">{$_('common.backToLogin')}</a> 75 75 </p> 76 76 </div> 77 77
+1 -1
frontend/src/routes/ResetPassword.svelte
··· 141 141 {/if} 142 142 143 143 <p class="link-text"> 144 - <a href="#/login">{$_('resetPassword.backToLogin')}</a> 144 + <a href="#/login">{$_('common.backToLogin')}</a> 145 145 </p> 146 146 </div> 147 147
+341 -3
frontend/src/routes/Settings.svelte
··· 40 40 let deleteToken = $state('') 41 41 let deleteTokenSent = $state(false) 42 42 let exportLoading = $state(false) 43 + let exportBlobsLoading = $state(false) 43 44 let passwordLoading = $state(false) 44 45 let currentPassword = $state('') 45 46 let newPassword = $state('') ··· 173 174 exportLoading = false 174 175 } 175 176 } 177 + async function handleExportBlobs() { 178 + if (!auth.session) return 179 + exportBlobsLoading = true 180 + message = null 181 + try { 182 + const response = await fetch('/xrpc/_backup.exportBlobs', { 183 + headers: { 184 + 'Authorization': `Bearer ${auth.session.accessJwt}` 185 + } 186 + }) 187 + if (!response.ok) { 188 + const err = await response.json().catch(() => ({ message: 'Export failed' })) 189 + throw new Error(err.message || 'Export failed') 190 + } 191 + const blob = await response.blob() 192 + if (blob.size === 0) { 193 + showMessage('success', $_('settings.messages.noBlobsToExport')) 194 + return 195 + } 196 + const url = URL.createObjectURL(blob) 197 + const a = document.createElement('a') 198 + a.href = url 199 + a.download = `${auth.session.handle}-blobs.zip` 200 + document.body.appendChild(a) 201 + a.click() 202 + document.body.removeChild(a) 203 + URL.revokeObjectURL(url) 204 + showMessage('success', $_('settings.messages.blobsExported')) 205 + } catch (e) { 206 + showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed')) 207 + } finally { 208 + exportBlobsLoading = false 209 + } 210 + } 211 + 212 + interface BackupInfo { 213 + id: string 214 + repoRev: string 215 + repoRootCid: string 216 + blockCount: number 217 + sizeBytes: number 218 + createdAt: string 219 + } 220 + let backups = $state<BackupInfo[]>([]) 221 + let backupEnabled = $state(true) 222 + let backupsLoading = $state(false) 223 + let createBackupLoading = $state(false) 224 + let restoreFile = $state<File | null>(null) 225 + let restoreLoading = $state(false) 226 + 227 + async function loadBackups() { 228 + if (!auth.session) return 229 + backupsLoading = true 230 + try { 231 + const result = await api.listBackups(auth.session.accessJwt) 232 + backups = result.backups 233 + backupEnabled = result.backupEnabled 234 + } catch (e) { 235 + console.error('Failed to load backups:', e) 236 + } finally { 237 + backupsLoading = false 238 + } 239 + } 240 + 241 + onMount(() => { 242 + loadBackups() 243 + }) 244 + 245 + async function handleToggleBackup() { 246 + if (!auth.session) return 247 + const newEnabled = !backupEnabled 248 + backupsLoading = true 249 + try { 250 + await api.setBackupEnabled(auth.session.accessJwt, newEnabled) 251 + backupEnabled = newEnabled 252 + showMessage('success', newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled')) 253 + } catch (e) { 254 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed')) 255 + } finally { 256 + backupsLoading = false 257 + } 258 + } 259 + 260 + async function handleCreateBackup() { 261 + if (!auth.session) return 262 + createBackupLoading = true 263 + message = null 264 + try { 265 + await api.createBackup(auth.session.accessJwt) 266 + await loadBackups() 267 + showMessage('success', $_('settings.backups.created')) 268 + } catch (e) { 269 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.createFailed')) 270 + } finally { 271 + createBackupLoading = false 272 + } 273 + } 274 + 275 + async function handleDownloadBackup(id: string, rev: string) { 276 + if (!auth.session) return 277 + try { 278 + const blob = await api.getBackup(auth.session.accessJwt, id) 279 + const url = URL.createObjectURL(blob) 280 + const a = document.createElement('a') 281 + a.href = url 282 + a.download = `${auth.session.handle}-${rev}.car` 283 + document.body.appendChild(a) 284 + a.click() 285 + document.body.removeChild(a) 286 + URL.revokeObjectURL(url) 287 + } catch (e) { 288 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed')) 289 + } 290 + } 291 + 292 + async function handleDeleteBackup(id: string) { 293 + if (!auth.session) return 294 + try { 295 + await api.deleteBackup(auth.session.accessJwt, id) 296 + await loadBackups() 297 + showMessage('success', $_('settings.backups.deleted')) 298 + } catch (e) { 299 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed')) 300 + } 301 + } 302 + 303 + function handleFileSelect(e: Event) { 304 + const input = e.target as HTMLInputElement 305 + if (input.files && input.files.length > 0) { 306 + restoreFile = input.files[0] 307 + } 308 + } 309 + 310 + async function handleRestore() { 311 + if (!auth.session || !restoreFile) return 312 + restoreLoading = true 313 + message = null 314 + try { 315 + const buffer = await restoreFile.arrayBuffer() 316 + const car = new Uint8Array(buffer) 317 + await api.importRepo(auth.session.accessJwt, car) 318 + showMessage('success', $_('settings.backups.restored')) 319 + restoreFile = null 320 + } catch (e) { 321 + showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed')) 322 + } finally { 323 + restoreLoading = false 324 + } 325 + } 326 + 327 + function formatBytes(bytes: number): string { 328 + if (bytes < 1024) return `${bytes} B` 329 + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` 330 + return `${(bytes / (1024 * 1024)).toFixed(1)} MB` 331 + } 332 + 333 + function formatDate(iso: string): string { 334 + return new Date(iso).toLocaleDateString(undefined, { 335 + year: 'numeric', 336 + month: 'short', 337 + day: 'numeric', 338 + hour: '2-digit', 339 + minute: '2-digit' 340 + }) 341 + } 342 + 176 343 async function handleChangePassword(e: Event) { 177 344 e.preventDefault() 178 345 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return ··· 323 490 /> 324 491 </div> 325 492 <button type="submit" disabled={handleLoading || !newHandle}> 326 - {handleLoading ? $_('settings.verifying') : $_('settings.verifyAndUpdate')} 493 + {handleLoading ? $_('common.verifying') : $_('settings.verifyAndUpdate')} 327 494 </button> 328 495 </form> 329 496 </div> ··· 394 561 <section> 395 562 <h2>{$_('settings.exportData')}</h2> 396 563 <p class="description">{$_('settings.exportDataDescription')}</p> 397 - <button onclick={handleExportRepo} disabled={exportLoading}> 398 - {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 564 + <div class="export-buttons"> 565 + <button onclick={handleExportRepo} disabled={exportLoading}> 566 + {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 567 + </button> 568 + <button onclick={handleExportBlobs} disabled={exportBlobsLoading} class="secondary"> 569 + {exportBlobsLoading ? $_('settings.exporting') : $_('settings.downloadBlobs')} 570 + </button> 571 + </div> 572 + </section> 573 + <section class="backups-section"> 574 + <h2>{$_('settings.backups.title')}</h2> 575 + <p class="description">{$_('settings.backups.description')}</p> 576 + 577 + <label class="checkbox-label"> 578 + <input type="checkbox" checked={backupEnabled} onchange={handleToggleBackup} disabled={backupsLoading} /> 579 + <span>{$_('settings.backups.enableAutomatic')}</span> 580 + </label> 581 + 582 + {#if backupsLoading} 583 + <p class="loading">{$_('common.loading')}</p> 584 + {:else if backups.length > 0} 585 + <ul class="backup-list"> 586 + {#each backups as backup} 587 + <li class="backup-item"> 588 + <div class="backup-info"> 589 + <span class="backup-date">{formatDate(backup.createdAt)}</span> 590 + <span class="backup-size">{formatBytes(backup.sizeBytes)}</span> 591 + <span class="backup-blocks">{backup.blockCount} {$_('settings.backups.blocks')}</span> 592 + </div> 593 + <div class="backup-actions"> 594 + <button class="small" onclick={() => handleDownloadBackup(backup.id, backup.repoRev)}> 595 + {$_('settings.backups.download')} 596 + </button> 597 + <button class="small danger" onclick={() => handleDeleteBackup(backup.id)}> 598 + {$_('settings.backups.delete')} 599 + </button> 600 + </div> 601 + </li> 602 + {/each} 603 + </ul> 604 + {:else} 605 + <p class="empty">{$_('settings.backups.noBackups')}</p> 606 + {/if} 607 + 608 + <button onclick={handleCreateBackup} disabled={createBackupLoading || !backupEnabled}> 609 + {createBackupLoading ? $_('common.creating') : $_('settings.backups.createNow')} 399 610 </button> 611 + </section> 612 + <section class="restore-section"> 613 + <h2>{$_('settings.backups.restoreTitle')}</h2> 614 + <p class="description">{$_('settings.backups.restoreDescription')}</p> 615 + 616 + <div class="field"> 617 + <label for="restore-file">{$_('settings.backups.selectFile')}</label> 618 + <input 619 + id="restore-file" 620 + type="file" 621 + accept=".car" 622 + onchange={handleFileSelect} 623 + disabled={restoreLoading} 624 + /> 625 + </div> 626 + 627 + {#if restoreFile} 628 + <div class="restore-preview"> 629 + <p>{$_('settings.backups.selectedFile')}: {restoreFile.name} ({formatBytes(restoreFile.size)})</p> 630 + <button onclick={handleRestore} disabled={restoreLoading} class="danger"> 631 + {restoreLoading ? $_('settings.backups.restoring') : $_('settings.backups.restore')} 632 + </button> 633 + </div> 634 + {/if} 400 635 </section> 401 636 </div> 402 637 <section class="danger-zone"> ··· 658 893 white-space: nowrap; 659 894 border-left: 1px solid var(--border-color); 660 895 background: var(--bg-card); 896 + } 897 + 898 + .checkbox-label { 899 + display: flex; 900 + align-items: center; 901 + gap: var(--space-2); 902 + cursor: pointer; 903 + margin-bottom: var(--space-4); 904 + } 905 + 906 + .checkbox-label input[type="checkbox"] { 907 + width: 18px; 908 + height: 18px; 909 + cursor: pointer; 910 + } 911 + 912 + .backup-list { 913 + list-style: none; 914 + padding: 0; 915 + margin: 0 0 var(--space-4) 0; 916 + display: flex; 917 + flex-direction: column; 918 + gap: var(--space-2); 919 + } 920 + 921 + .backup-item { 922 + display: flex; 923 + justify-content: space-between; 924 + align-items: center; 925 + padding: var(--space-3); 926 + background: var(--bg-card); 927 + border: 1px solid var(--border-color); 928 + border-radius: var(--radius-md); 929 + gap: var(--space-4); 930 + } 931 + 932 + .backup-info { 933 + display: flex; 934 + gap: var(--space-4); 935 + font-size: var(--text-sm); 936 + flex-wrap: wrap; 937 + } 938 + 939 + .backup-date { 940 + font-weight: 500; 941 + } 942 + 943 + .backup-size, 944 + .backup-blocks { 945 + color: var(--text-secondary); 946 + } 947 + 948 + .backup-actions { 949 + display: flex; 950 + gap: var(--space-2); 951 + flex-shrink: 0; 952 + } 953 + 954 + button.small { 955 + padding: var(--space-1) var(--space-2); 956 + font-size: var(--text-xs); 957 + } 958 + 959 + .empty, 960 + .loading { 961 + color: var(--text-secondary); 962 + font-size: var(--text-sm); 963 + margin-bottom: var(--space-4); 964 + } 965 + 966 + .restore-preview { 967 + background: var(--bg-card); 968 + border: 1px solid var(--border-color); 969 + border-radius: var(--radius-md); 970 + padding: var(--space-4); 971 + margin-top: var(--space-3); 972 + } 973 + 974 + .restore-preview p { 975 + margin: 0 0 var(--space-3) 0; 976 + font-size: var(--text-sm); 977 + } 978 + 979 + .export-buttons { 980 + display: flex; 981 + gap: var(--space-2); 982 + flex-wrap: wrap; 983 + } 984 + 985 + @media (max-width: 640px) { 986 + .backup-item { 987 + flex-direction: column; 988 + align-items: flex-start; 989 + } 990 + 991 + .backup-actions { 992 + width: 100%; 993 + margin-top: var(--space-2); 994 + } 995 + 996 + .backup-actions button { 997 + flex: 1; 998 + } 661 999 } 662 1000 </style>
+8 -8
frontend/src/routes/Verify.svelte
··· 225 225 <div class="verify-page"> 226 226 {#if autoSubmitting} 227 227 <div class="loading-container"> 228 - <h1>{$_('verify.verifying')}</h1> 228 + <h1>{$_('common.verifying')}</h1> 229 229 <p class="subtitle">{$_('verify.pleaseWait')}</p> 230 230 </div> 231 231 {:else if success} ··· 235 235 <p class="subtitle">{$_('verify.emailUpdated')}</p> 236 236 <p class="info-text">{$_('verify.emailUpdatedInfo')}</p> 237 237 <div class="actions"> 238 - <a href="#/settings" class="btn">{$_('verify.backToSettings')}</a> 238 + <a href="#/settings" class="btn">{$_('common.backToSettings')}</a> 239 239 </div> 240 240 {:else if successPurpose === 'migration' || successPurpose === 'signup'} 241 241 <p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p> ··· 301 301 </form> 302 302 303 303 <p class="link-text"> 304 - <a href="#/settings">{$_('verify.backToSettings')}</a> 304 + <a href="#/settings">{$_('common.backToSettings')}</a> 305 305 </p> 306 306 {/if} 307 307 {:else if mode === 'token'} ··· 347 347 </div> 348 348 349 349 <button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}> 350 - {submitting ? $_('verify.verifying') : $_('verify.verify')} 350 + {submitting ? $_('common.verifying') : $_('common.verify')} 351 351 </button> 352 352 353 353 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}> 354 - {resendingCode ? $_('verify.sending') : $_('verify.resendCode')} 354 + {resendingCode ? $_('common.sending') : $_('common.resendCode')} 355 355 </button> 356 356 </form> 357 357 358 358 <p class="link-text"> 359 - <a href="#/login">{$_('verify.backToLogin')}</a> 359 + <a href="#/login">{$_('common.backToLogin')}</a> 360 360 </p> 361 361 {:else if pendingVerification} 362 362 <h1>{$_('verify.title')}</h1> ··· 390 390 </div> 391 391 392 392 <button type="submit" disabled={submitting || !verificationCode.trim()}> 393 - {submitting ? $_('verify.verifying') : $_('verify.verifyButton')} 393 + {submitting ? $_('common.verifying') : $_('common.verify')} 394 394 </button> 395 395 396 396 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 397 - {resendingCode ? $_('verify.resending') : $_('verify.resendCode')} 397 + {resendingCode ? $_('common.sending') : $_('common.resendCode')} 398 398 </button> 399 399 </form> 400 400
+5 -5
frontend/src/styles/base.css
··· 54 54 } 55 55 56 56 a { 57 - color: var(--secondary); 58 - text-decoration: none; 59 - transition: color 0.3s ease; 57 + color: var(--accent); 58 + text-decoration: underline; 59 + text-underline-offset: 2px; 60 60 } 61 61 62 62 a:hover { 63 - color: var(--secondary-hover); 64 - text-decoration: none; 63 + color: var(--accent-hover); 65 64 } 66 65 67 66 ::selection { ··· 372 371 color: var(--text-secondary); 373 372 font-size: var(--text-sm); 374 373 margin-bottom: var(--space-3); 374 + text-decoration: none; 375 375 } 376 376 377 377 .back-link:hover {
+90
frontend/src/styles/migration.css
··· 190 190 191 191 .current-info .value { 192 192 font-weight: var(--font-medium); 193 + word-break: break-all; 194 + } 195 + 196 + .current-info .value.mono { 197 + font-family: var(--font-mono); 198 + font-size: var(--text-sm); 193 199 } 194 200 195 201 .review-card { ··· 268 274 text-align: center; 269 275 color: var(--text-secondary); 270 276 font-size: var(--text-sm); 277 + } 278 + 279 + .blob-progress { 280 + margin: var(--space-4) 0; 281 + } 282 + 283 + .blob-progress-bar { 284 + height: 8px; 285 + background: var(--bg-primary); 286 + border-radius: var(--radius-md); 287 + overflow: hidden; 288 + margin-bottom: var(--space-2); 289 + } 290 + 291 + .blob-progress-fill { 292 + height: 100%; 293 + background: var(--accent); 294 + transition: width var(--transition-slow); 295 + } 296 + 297 + .blob-progress-text { 298 + text-align: center; 299 + color: var(--text-secondary); 300 + font-size: var(--text-sm); 301 + margin: 0; 271 302 } 272 303 273 304 .success-content { ··· 567 598 font-size: var(--text-sm); 568 599 font-style: italic; 569 600 } 601 + 602 + .file-input-container { 603 + display: flex; 604 + flex-direction: column; 605 + gap: var(--space-3); 606 + } 607 + 608 + .file-info { 609 + display: flex; 610 + gap: var(--space-2); 611 + align-items: center; 612 + padding: var(--space-3); 613 + background: var(--bg-primary); 614 + border-radius: var(--radius-md); 615 + } 616 + 617 + .file-name { 618 + font-weight: var(--font-medium); 619 + } 620 + 621 + .file-size { 622 + color: var(--text-secondary); 623 + font-size: var(--text-sm); 624 + } 625 + 626 + .step-content textarea { 627 + width: 100%; 628 + font-family: var(--font-mono); 629 + font-size: var(--text-sm); 630 + padding: var(--space-3); 631 + border: 1px solid var(--border-color); 632 + border-radius: var(--radius-md); 633 + background: var(--bg-input); 634 + color: var(--text-primary); 635 + resize: vertical; 636 + } 637 + 638 + .step-content textarea:focus { 639 + outline: none; 640 + border-color: var(--accent); 641 + } 642 + 643 + .message { 644 + padding: var(--space-4); 645 + border-radius: var(--radius-lg); 646 + margin-bottom: var(--space-4); 647 + } 648 + 649 + .message.success { 650 + background: var(--success-bg); 651 + color: var(--success-text); 652 + border: 1px solid var(--success-border); 653 + } 654 + 655 + .message.error { 656 + background: var(--error-bg); 657 + color: var(--error-text); 658 + border: 1px solid var(--error-border); 659 + }
+35 -35
frontend/src/tests/Comms.test.ts
··· 29 29 beforeEach(() => { 30 30 setupAuthenticatedUser(); 31 31 mockEndpoint( 32 - "com.tranquil.account.getNotificationPrefs", 32 + "_account.getNotificationPrefs", 33 33 () => jsonResponse(mockData.notificationPrefs()), 34 34 ); 35 35 mockEndpoint( ··· 37 37 () => jsonResponse(mockData.describeServer()), 38 38 ); 39 39 mockEndpoint( 40 - "com.tranquil.account.getNotificationHistory", 40 + "_account.getNotificationHistory", 41 41 () => jsonResponse({ notifications: [] }), 42 42 ); 43 43 }); ··· 67 67 () => jsonResponse(mockData.describeServer()), 68 68 ); 69 69 mockEndpoint( 70 - "com.tranquil.account.getNotificationHistory", 70 + "_account.getNotificationHistory", 71 71 () => jsonResponse({ notifications: [] }), 72 72 ); 73 73 }); 74 74 it("shows loading text while fetching preferences", async () => { 75 - mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => { 75 + mockEndpoint("_account.getNotificationPrefs", async () => { 76 76 await new Promise((resolve) => setTimeout(resolve, 100)); 77 77 return jsonResponse(mockData.notificationPrefs()); 78 78 }); ··· 88 88 () => jsonResponse(mockData.describeServer()), 89 89 ); 90 90 mockEndpoint( 91 - "com.tranquil.account.getNotificationHistory", 91 + "_account.getNotificationHistory", 92 92 () => jsonResponse({ notifications: [] }), 93 93 ); 94 94 }); 95 95 it("displays all four channel options", async () => { 96 96 mockEndpoint( 97 - "com.tranquil.account.getNotificationPrefs", 97 + "_account.getNotificationPrefs", 98 98 () => jsonResponse(mockData.notificationPrefs()), 99 99 ); 100 100 render(Comms); ··· 111 111 }); 112 112 it("email channel is always selectable", async () => { 113 113 mockEndpoint( 114 - "com.tranquil.account.getNotificationPrefs", 114 + "_account.getNotificationPrefs", 115 115 () => jsonResponse(mockData.notificationPrefs()), 116 116 ); 117 117 render(Comms); ··· 122 122 }); 123 123 it("discord channel is disabled when not configured", async () => { 124 124 mockEndpoint( 125 - "com.tranquil.account.getNotificationPrefs", 125 + "_account.getNotificationPrefs", 126 126 () => jsonResponse(mockData.notificationPrefs({ discordId: null })), 127 127 ); 128 128 render(Comms); ··· 133 133 }); 134 134 it("discord channel is enabled when configured", async () => { 135 135 mockEndpoint( 136 - "com.tranquil.account.getNotificationPrefs", 136 + "_account.getNotificationPrefs", 137 137 () => 138 138 jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })), 139 139 ); ··· 145 145 }); 146 146 it("shows hint for disabled channels", async () => { 147 147 mockEndpoint( 148 - "com.tranquil.account.getNotificationPrefs", 148 + "_account.getNotificationPrefs", 149 149 () => jsonResponse(mockData.notificationPrefs()), 150 150 ); 151 151 render(Comms); ··· 156 156 }); 157 157 it("selects current preferred channel", async () => { 158 158 mockEndpoint( 159 - "com.tranquil.account.getNotificationPrefs", 159 + "_account.getNotificationPrefs", 160 160 () => 161 161 jsonResponse( 162 162 mockData.notificationPrefs({ preferredChannel: "email" }), ··· 179 179 () => jsonResponse(mockData.describeServer()), 180 180 ); 181 181 mockEndpoint( 182 - "com.tranquil.account.getNotificationHistory", 182 + "_account.getNotificationHistory", 183 183 () => jsonResponse({ notifications: [] }), 184 184 ); 185 185 }); 186 186 it("displays email as readonly with current value", async () => { 187 187 mockEndpoint( 188 - "com.tranquil.account.getNotificationPrefs", 188 + "_account.getNotificationPrefs", 189 189 () => jsonResponse(mockData.notificationPrefs()), 190 190 ); 191 191 render(Comms); ··· 199 199 }); 200 200 it("displays all channel inputs with current values", async () => { 201 201 mockEndpoint( 202 - "com.tranquil.account.getNotificationPrefs", 202 + "_account.getNotificationPrefs", 203 203 () => 204 204 jsonResponse(mockData.notificationPrefs({ 205 205 discordId: "123456789", ··· 231 231 () => jsonResponse(mockData.describeServer()), 232 232 ); 233 233 mockEndpoint( 234 - "com.tranquil.account.getNotificationHistory", 234 + "_account.getNotificationHistory", 235 235 () => jsonResponse({ notifications: [] }), 236 236 ); 237 237 }); 238 238 it("shows Primary badge for email", async () => { 239 239 mockEndpoint( 240 - "com.tranquil.account.getNotificationPrefs", 240 + "_account.getNotificationPrefs", 241 241 () => jsonResponse(mockData.notificationPrefs()), 242 242 ); 243 243 render(Comms); ··· 247 247 }); 248 248 it("shows Verified badge for verified discord", async () => { 249 249 mockEndpoint( 250 - "com.tranquil.account.getNotificationPrefs", 250 + "_account.getNotificationPrefs", 251 251 () => 252 252 jsonResponse(mockData.notificationPrefs({ 253 253 discordId: "123456789", ··· 262 262 }); 263 263 it("shows Not verified badge for unverified discord", async () => { 264 264 mockEndpoint( 265 - "com.tranquil.account.getNotificationPrefs", 265 + "_account.getNotificationPrefs", 266 266 () => 267 267 jsonResponse(mockData.notificationPrefs({ 268 268 discordId: "123456789", ··· 276 276 }); 277 277 it("does not show badge when channel not configured", async () => { 278 278 mockEndpoint( 279 - "com.tranquil.account.getNotificationPrefs", 279 + "_account.getNotificationPrefs", 280 280 () => jsonResponse(mockData.notificationPrefs()), 281 281 ); 282 282 render(Comms); ··· 294 294 () => jsonResponse(mockData.describeServer()), 295 295 ); 296 296 mockEndpoint( 297 - "com.tranquil.account.getNotificationHistory", 297 + "_account.getNotificationHistory", 298 298 () => jsonResponse({ notifications: [] }), 299 299 ); 300 300 }); 301 301 it("calls updateNotificationPrefs with correct data", async () => { 302 302 let capturedBody: Record<string, unknown> | null = null; 303 303 mockEndpoint( 304 - "com.tranquil.account.getNotificationPrefs", 304 + "_account.getNotificationPrefs", 305 305 () => jsonResponse(mockData.notificationPrefs()), 306 306 ); 307 307 mockEndpoint( 308 - "com.tranquil.account.updateNotificationPrefs", 308 + "_account.updateNotificationPrefs", 309 309 (_url, options) => { 310 310 capturedBody = JSON.parse((options?.body as string) || "{}"); 311 311 return jsonResponse({ success: true }); ··· 329 329 }); 330 330 it("shows loading state while saving", async () => { 331 331 mockEndpoint( 332 - "com.tranquil.account.getNotificationPrefs", 332 + "_account.getNotificationPrefs", 333 333 () => jsonResponse(mockData.notificationPrefs()), 334 334 ); 335 - mockEndpoint("com.tranquil.account.updateNotificationPrefs", async () => { 335 + mockEndpoint("_account.updateNotificationPrefs", async () => { 336 336 await new Promise((resolve) => setTimeout(resolve, 100)); 337 337 return jsonResponse({ success: true }); 338 338 }); ··· 350 350 }); 351 351 it("shows success message after saving", async () => { 352 352 mockEndpoint( 353 - "com.tranquil.account.getNotificationPrefs", 353 + "_account.getNotificationPrefs", 354 354 () => jsonResponse(mockData.notificationPrefs()), 355 355 ); 356 356 mockEndpoint( 357 - "com.tranquil.account.updateNotificationPrefs", 357 + "_account.updateNotificationPrefs", 358 358 () => jsonResponse({ success: true }), 359 359 ); 360 360 render(Comms); ··· 372 372 }); 373 373 it("shows error when save fails", async () => { 374 374 mockEndpoint( 375 - "com.tranquil.account.getNotificationPrefs", 375 + "_account.getNotificationPrefs", 376 376 () => jsonResponse(mockData.notificationPrefs()), 377 377 ); 378 378 mockEndpoint( 379 - "com.tranquil.account.updateNotificationPrefs", 379 + "_account.updateNotificationPrefs", 380 380 () => 381 381 errorResponse("InvalidRequest", "Invalid channel configuration", 400), 382 382 ); ··· 400 400 }); 401 401 it("reloads preferences after successful save", async () => { 402 402 let loadCount = 0; 403 - mockEndpoint("com.tranquil.account.getNotificationPrefs", () => { 403 + mockEndpoint("_account.getNotificationPrefs", () => { 404 404 loadCount++; 405 405 return jsonResponse(mockData.notificationPrefs()); 406 406 }); 407 407 mockEndpoint( 408 - "com.tranquil.account.updateNotificationPrefs", 408 + "_account.updateNotificationPrefs", 409 409 () => jsonResponse({ success: true }), 410 410 ); 411 411 render(Comms); ··· 430 430 () => jsonResponse(mockData.describeServer()), 431 431 ); 432 432 mockEndpoint( 433 - "com.tranquil.account.getNotificationHistory", 433 + "_account.getNotificationHistory", 434 434 () => jsonResponse({ notifications: [] }), 435 435 ); 436 436 }); 437 437 it("enables discord channel after entering discord ID", async () => { 438 438 mockEndpoint( 439 - "com.tranquil.account.getNotificationPrefs", 439 + "_account.getNotificationPrefs", 440 440 () => jsonResponse(mockData.notificationPrefs()), 441 441 ); 442 442 render(Comms); ··· 453 453 }); 454 454 it("allows selecting a configured channel", async () => { 455 455 mockEndpoint( 456 - "com.tranquil.account.getNotificationPrefs", 456 + "_account.getNotificationPrefs", 457 457 () => 458 458 jsonResponse(mockData.notificationPrefs({ 459 459 discordId: "123456789", ··· 480 480 () => jsonResponse(mockData.describeServer()), 481 481 ); 482 482 mockEndpoint( 483 - "com.tranquil.account.getNotificationHistory", 483 + "_account.getNotificationHistory", 484 484 () => jsonResponse({ notifications: [] }), 485 485 ); 486 486 }); 487 487 it("shows error when loading preferences fails", async () => { 488 488 mockEndpoint( 489 - "com.tranquil.account.getNotificationPrefs", 489 + "_account.getNotificationPrefs", 490 490 () => errorResponse("InternalError", "Database connection failed", 500), 491 491 ); 492 492 render(Comms);
+2 -2
frontend/src/tests/Settings.test.ts
··· 8 8 mockData, 9 9 mockEndpoint, 10 10 setupAuthenticatedUser, 11 - setupFetchMock, 11 + setupDefaultMocks, 12 12 setupUnauthenticatedUser, 13 13 } from "./mocks"; 14 14 describe("Settings", () => { 15 15 beforeEach(() => { 16 16 clearMocks(); 17 - setupFetchMock(); 17 + setupDefaultMocks(); 18 18 globalThis.confirm = vi.fn(() => true); 19 19 }); 20 20 describe("authentication guard", () => {
+491
frontend/src/tests/migration/offline-flow.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { createOfflineInboundMigrationFlow } from "../../lib/migration/offline-flow.svelte"; 3 + 4 + const OFFLINE_STORAGE_KEY = "tranquil_offline_migration_state"; 5 + 6 + describe("migration/offline-flow", () => { 7 + beforeEach(() => { 8 + localStorage.removeItem(OFFLINE_STORAGE_KEY); 9 + vi.restoreAllMocks(); 10 + }); 11 + 12 + describe("createOfflineInboundMigrationFlow", () => { 13 + it("creates flow with initial state", () => { 14 + const flow = createOfflineInboundMigrationFlow(); 15 + 16 + expect(flow.state.direction).toBe("offline-inbound"); 17 + expect(flow.state.step).toBe("welcome"); 18 + expect(flow.state.userDid).toBe(""); 19 + expect(flow.state.carFile).toBeNull(); 20 + expect(flow.state.carFileName).toBe(""); 21 + expect(flow.state.carSizeBytes).toBe(0); 22 + expect(flow.state.rotationKey).toBe(""); 23 + expect(flow.state.rotationKeyDidKey).toBe(""); 24 + expect(flow.state.targetHandle).toBe(""); 25 + expect(flow.state.targetEmail).toBe(""); 26 + expect(flow.state.targetPassword).toBe(""); 27 + expect(flow.state.inviteCode).toBe(""); 28 + expect(flow.state.localAccessToken).toBeNull(); 29 + expect(flow.state.localRefreshToken).toBeNull(); 30 + expect(flow.state.error).toBeNull(); 31 + }); 32 + 33 + it("initializes progress correctly", () => { 34 + const flow = createOfflineInboundMigrationFlow(); 35 + 36 + expect(flow.state.progress.repoExported).toBe(false); 37 + expect(flow.state.progress.repoImported).toBe(false); 38 + expect(flow.state.progress.blobsTotal).toBe(0); 39 + expect(flow.state.progress.blobsMigrated).toBe(0); 40 + expect(flow.state.progress.blobsFailed).toEqual([]); 41 + expect(flow.state.progress.prefsMigrated).toBe(false); 42 + expect(flow.state.progress.plcSigned).toBe(false); 43 + expect(flow.state.progress.activated).toBe(false); 44 + expect(flow.state.progress.deactivated).toBe(false); 45 + expect(flow.state.progress.currentOperation).toBe(""); 46 + }); 47 + }); 48 + 49 + describe("setUserDid", () => { 50 + it("sets the user DID", () => { 51 + const flow = createOfflineInboundMigrationFlow(); 52 + 53 + flow.setUserDid("did:plc:abc123"); 54 + 55 + expect(flow.state.userDid).toBe("did:plc:abc123"); 56 + }); 57 + 58 + it("saves state to localStorage", () => { 59 + const flow = createOfflineInboundMigrationFlow(); 60 + 61 + flow.setUserDid("did:plc:xyz789"); 62 + 63 + const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 64 + expect(stored.userDid).toBe("did:plc:xyz789"); 65 + }); 66 + }); 67 + 68 + describe("setCarFile", () => { 69 + it("sets CAR file data", () => { 70 + const flow = createOfflineInboundMigrationFlow(); 71 + const carData = new Uint8Array([1, 2, 3, 4, 5]); 72 + 73 + flow.setCarFile(carData, "repo.car"); 74 + 75 + expect(flow.state.carFile).toEqual(carData); 76 + expect(flow.state.carFileName).toBe("repo.car"); 77 + expect(flow.state.carSizeBytes).toBe(5); 78 + }); 79 + 80 + it("saves file metadata to localStorage (not file content)", () => { 81 + const flow = createOfflineInboundMigrationFlow(); 82 + const carData = new Uint8Array([1, 2, 3, 4, 5]); 83 + 84 + flow.setCarFile(carData, "backup.car"); 85 + 86 + const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 87 + expect(stored.carFileName).toBe("backup.car"); 88 + expect(stored.carSizeBytes).toBe(5); 89 + }); 90 + }); 91 + 92 + describe("setRotationKey", () => { 93 + it("sets the rotation key", () => { 94 + const flow = createOfflineInboundMigrationFlow(); 95 + 96 + flow.setRotationKey("abc123privatekey"); 97 + 98 + expect(flow.state.rotationKey).toBe("abc123privatekey"); 99 + }); 100 + 101 + it("does not save rotation key to localStorage (security)", () => { 102 + const flow = createOfflineInboundMigrationFlow(); 103 + 104 + flow.setRotationKey("supersecretkey"); 105 + 106 + const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); 107 + if (stored) { 108 + const parsed = JSON.parse(stored); 109 + expect(parsed.rotationKey).toBeUndefined(); 110 + } 111 + }); 112 + }); 113 + 114 + describe("setTargetHandle", () => { 115 + it("sets the target handle", () => { 116 + const flow = createOfflineInboundMigrationFlow(); 117 + 118 + flow.setTargetHandle("alice.example.com"); 119 + 120 + expect(flow.state.targetHandle).toBe("alice.example.com"); 121 + }); 122 + 123 + it("saves to localStorage", () => { 124 + const flow = createOfflineInboundMigrationFlow(); 125 + 126 + flow.setTargetHandle("bob.example.com"); 127 + 128 + const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 129 + expect(stored.targetHandle).toBe("bob.example.com"); 130 + }); 131 + }); 132 + 133 + describe("setTargetEmail", () => { 134 + it("sets the target email", () => { 135 + const flow = createOfflineInboundMigrationFlow(); 136 + 137 + flow.setTargetEmail("alice@example.com"); 138 + 139 + expect(flow.state.targetEmail).toBe("alice@example.com"); 140 + }); 141 + 142 + it("saves to localStorage", () => { 143 + const flow = createOfflineInboundMigrationFlow(); 144 + 145 + flow.setTargetEmail("bob@example.com"); 146 + 147 + const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 148 + expect(stored.targetEmail).toBe("bob@example.com"); 149 + }); 150 + }); 151 + 152 + describe("setTargetPassword", () => { 153 + it("sets the target password", () => { 154 + const flow = createOfflineInboundMigrationFlow(); 155 + 156 + flow.setTargetPassword("securepassword123"); 157 + 158 + expect(flow.state.targetPassword).toBe("securepassword123"); 159 + }); 160 + 161 + it("does not save password to localStorage (security)", () => { 162 + const flow = createOfflineInboundMigrationFlow(); 163 + flow.setUserDid("did:plc:test"); 164 + 165 + flow.setTargetPassword("mypassword"); 166 + 167 + const stored = localStorage.getItem(OFFLINE_STORAGE_KEY); 168 + if (stored) { 169 + const parsed = JSON.parse(stored); 170 + expect(parsed.targetPassword).toBeUndefined(); 171 + } 172 + }); 173 + }); 174 + 175 + describe("setInviteCode", () => { 176 + it("sets the invite code", () => { 177 + const flow = createOfflineInboundMigrationFlow(); 178 + 179 + flow.setInviteCode("invite-abc123"); 180 + 181 + expect(flow.state.inviteCode).toBe("invite-abc123"); 182 + }); 183 + }); 184 + 185 + describe("setStep", () => { 186 + it("changes the current step", () => { 187 + const flow = createOfflineInboundMigrationFlow(); 188 + 189 + flow.setStep("provide-did"); 190 + 191 + expect(flow.state.step).toBe("provide-did"); 192 + }); 193 + 194 + it("clears error when changing step", () => { 195 + const flow = createOfflineInboundMigrationFlow(); 196 + flow.setError("Previous error"); 197 + 198 + flow.setStep("upload-car"); 199 + 200 + expect(flow.state.error).toBeNull(); 201 + }); 202 + 203 + it("saves step to localStorage", () => { 204 + const flow = createOfflineInboundMigrationFlow(); 205 + 206 + flow.setStep("provide-rotation-key"); 207 + 208 + const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 209 + expect(stored.step).toBe("provide-rotation-key"); 210 + }); 211 + }); 212 + 213 + describe("setError", () => { 214 + it("sets the error message", () => { 215 + const flow = createOfflineInboundMigrationFlow(); 216 + 217 + flow.setError("Something went wrong"); 218 + 219 + expect(flow.state.error).toBe("Something went wrong"); 220 + }); 221 + 222 + it("saves error to localStorage", () => { 223 + const flow = createOfflineInboundMigrationFlow(); 224 + 225 + flow.setError("Connection failed"); 226 + 227 + const stored = JSON.parse(localStorage.getItem(OFFLINE_STORAGE_KEY)!); 228 + expect(stored.lastError).toBe("Connection failed"); 229 + }); 230 + }); 231 + 232 + describe("setProgress", () => { 233 + it("updates progress fields", () => { 234 + const flow = createOfflineInboundMigrationFlow(); 235 + 236 + flow.setProgress({ 237 + repoImported: true, 238 + currentOperation: "Importing...", 239 + }); 240 + 241 + expect(flow.state.progress.repoImported).toBe(true); 242 + expect(flow.state.progress.currentOperation).toBe("Importing..."); 243 + }); 244 + 245 + it("preserves other progress fields", () => { 246 + const flow = createOfflineInboundMigrationFlow(); 247 + flow.setProgress({ repoExported: true }); 248 + 249 + flow.setProgress({ repoImported: true }); 250 + 251 + expect(flow.state.progress.repoExported).toBe(true); 252 + expect(flow.state.progress.repoImported).toBe(true); 253 + }); 254 + }); 255 + 256 + describe("reset", () => { 257 + it("resets state to initial values", () => { 258 + const flow = createOfflineInboundMigrationFlow(); 259 + flow.setUserDid("did:plc:abc123"); 260 + flow.setTargetHandle("alice.example.com"); 261 + flow.setStep("review"); 262 + 263 + flow.reset(); 264 + 265 + expect(flow.state.step).toBe("welcome"); 266 + expect(flow.state.userDid).toBe(""); 267 + expect(flow.state.targetHandle).toBe(""); 268 + }); 269 + 270 + it("clears localStorage", () => { 271 + const flow = createOfflineInboundMigrationFlow(); 272 + flow.setUserDid("did:plc:abc123"); 273 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull(); 274 + 275 + flow.reset(); 276 + 277 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 278 + }); 279 + }); 280 + 281 + describe("clearOfflineState", () => { 282 + it("removes state from localStorage", () => { 283 + const flow = createOfflineInboundMigrationFlow(); 284 + flow.setUserDid("did:plc:abc123"); 285 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).not.toBeNull(); 286 + 287 + flow.clearOfflineState(); 288 + 289 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 290 + }); 291 + }); 292 + 293 + describe("tryResume", () => { 294 + it("returns false when no stored state", () => { 295 + const flow = createOfflineInboundMigrationFlow(); 296 + 297 + const result = flow.tryResume(); 298 + 299 + expect(result).toBe(false); 300 + }); 301 + 302 + it("restores state from localStorage", () => { 303 + const storedState = { 304 + version: 1, 305 + step: "choose-handle", 306 + startedAt: new Date().toISOString(), 307 + userDid: "did:plc:restored123", 308 + carFileName: "backup.car", 309 + carSizeBytes: 12345, 310 + rotationKeyDidKey: "did:key:z123abc", 311 + targetHandle: "restored.example.com", 312 + targetEmail: "restored@example.com", 313 + progress: { 314 + accountCreated: true, 315 + repoImported: false, 316 + plcSigned: false, 317 + activated: false, 318 + }, 319 + }; 320 + localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState)); 321 + 322 + const flow = createOfflineInboundMigrationFlow(); 323 + const result = flow.tryResume(); 324 + 325 + expect(result).toBe(true); 326 + expect(flow.state.step).toBe("choose-handle"); 327 + expect(flow.state.userDid).toBe("did:plc:restored123"); 328 + expect(flow.state.carFileName).toBe("backup.car"); 329 + expect(flow.state.carSizeBytes).toBe(12345); 330 + expect(flow.state.rotationKeyDidKey).toBe("did:key:z123abc"); 331 + expect(flow.state.targetHandle).toBe("restored.example.com"); 332 + expect(flow.state.targetEmail).toBe("restored@example.com"); 333 + expect(flow.state.progress.repoExported).toBe(true); 334 + }); 335 + 336 + it("restores error from stored state", () => { 337 + const storedState = { 338 + version: 1, 339 + step: "error", 340 + startedAt: new Date().toISOString(), 341 + userDid: "did:plc:abc", 342 + carFileName: "", 343 + carSizeBytes: 0, 344 + rotationKeyDidKey: "", 345 + targetHandle: "", 346 + targetEmail: "", 347 + progress: { 348 + accountCreated: false, 349 + repoImported: false, 350 + plcSigned: false, 351 + activated: false, 352 + }, 353 + lastError: "Previous migration failed", 354 + }; 355 + localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState)); 356 + 357 + const flow = createOfflineInboundMigrationFlow(); 358 + flow.tryResume(); 359 + 360 + expect(flow.state.error).toBe("Previous migration failed"); 361 + }); 362 + 363 + it("returns false and clears for incompatible version", () => { 364 + const storedState = { 365 + version: 999, 366 + step: "review", 367 + userDid: "did:plc:abc", 368 + }; 369 + localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(storedState)); 370 + 371 + const flow = createOfflineInboundMigrationFlow(); 372 + const result = flow.tryResume(); 373 + 374 + expect(result).toBe(false); 375 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 376 + }); 377 + 378 + it("returns false and clears for expired state (> 24 hours)", () => { 379 + const expiredState = { 380 + version: 1, 381 + step: "review", 382 + startedAt: new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(), 383 + userDid: "did:plc:expired", 384 + carFileName: "old.car", 385 + carSizeBytes: 100, 386 + rotationKeyDidKey: "", 387 + targetHandle: "old.example.com", 388 + targetEmail: "old@example.com", 389 + progress: { 390 + accountCreated: false, 391 + repoImported: false, 392 + plcSigned: false, 393 + activated: false, 394 + }, 395 + }; 396 + localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(expiredState)); 397 + 398 + const flow = createOfflineInboundMigrationFlow(); 399 + const result = flow.tryResume(); 400 + 401 + expect(result).toBe(false); 402 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 403 + }); 404 + 405 + it("returns false and clears for invalid JSON", () => { 406 + localStorage.setItem(OFFLINE_STORAGE_KEY, "not-valid-json"); 407 + 408 + const flow = createOfflineInboundMigrationFlow(); 409 + const result = flow.tryResume(); 410 + 411 + expect(result).toBe(false); 412 + expect(localStorage.getItem(OFFLINE_STORAGE_KEY)).toBeNull(); 413 + }); 414 + 415 + it("accepts state within 24 hours", () => { 416 + const recentState = { 417 + version: 1, 418 + step: "review", 419 + startedAt: new Date(Date.now() - 23 * 60 * 60 * 1000).toISOString(), 420 + userDid: "did:plc:recent", 421 + carFileName: "recent.car", 422 + carSizeBytes: 500, 423 + rotationKeyDidKey: "did:key:zRecent", 424 + targetHandle: "recent.example.com", 425 + targetEmail: "recent@example.com", 426 + progress: { 427 + accountCreated: true, 428 + repoImported: true, 429 + plcSigned: false, 430 + activated: false, 431 + }, 432 + }; 433 + localStorage.setItem(OFFLINE_STORAGE_KEY, JSON.stringify(recentState)); 434 + 435 + const flow = createOfflineInboundMigrationFlow(); 436 + const result = flow.tryResume(); 437 + 438 + expect(result).toBe(true); 439 + expect(flow.state.userDid).toBe("did:plc:recent"); 440 + }); 441 + }); 442 + 443 + describe("loadLocalServerInfo", () => { 444 + function createMockResponse(data: unknown) { 445 + const jsonStr = JSON.stringify(data); 446 + return new Response(jsonStr, { 447 + status: 200, 448 + headers: { "Content-Type": "application/json" }, 449 + }); 450 + } 451 + 452 + it("fetches server description", async () => { 453 + const mockServerInfo = { 454 + did: "did:web:example.com", 455 + availableUserDomains: ["example.com"], 456 + inviteCodeRequired: false, 457 + }; 458 + 459 + globalThis.fetch = vi.fn().mockResolvedValue( 460 + createMockResponse(mockServerInfo), 461 + ); 462 + 463 + const flow = createOfflineInboundMigrationFlow(); 464 + const result = await flow.loadLocalServerInfo(); 465 + 466 + expect(result).toEqual(mockServerInfo); 467 + expect(fetch).toHaveBeenCalledWith( 468 + expect.stringContaining("com.atproto.server.describeServer"), 469 + expect.any(Object), 470 + ); 471 + }); 472 + 473 + it("caches server info", async () => { 474 + const mockServerInfo = { 475 + did: "did:web:example.com", 476 + availableUserDomains: ["example.com"], 477 + inviteCodeRequired: false, 478 + }; 479 + 480 + globalThis.fetch = vi.fn().mockResolvedValue( 481 + createMockResponse(mockServerInfo), 482 + ); 483 + 484 + const flow = createOfflineInboundMigrationFlow(); 485 + await flow.loadLocalServerInfo(); 486 + await flow.loadLocalServerInfo(); 487 + 488 + expect(fetch).toHaveBeenCalledTimes(1); 489 + }); 490 + }); 491 + });
+333
frontend/src/tests/migration/plc-ops.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { PlcOps, plcOps } from "../../lib/migration/plc-ops"; 3 + 4 + describe("migration/plc-ops", () => { 5 + beforeEach(() => { 6 + vi.restoreAllMocks(); 7 + }); 8 + 9 + describe("PlcOps class", () => { 10 + it("uses default PLC directory URL", () => { 11 + const ops = new PlcOps(); 12 + expect(ops).toBeDefined(); 13 + }); 14 + 15 + it("accepts custom PLC directory URL", () => { 16 + const ops = new PlcOps("https://custom-plc.example.com"); 17 + expect(ops).toBeDefined(); 18 + }); 19 + }); 20 + 21 + describe("plcOps singleton", () => { 22 + it("exports a singleton instance", () => { 23 + expect(plcOps).toBeInstanceOf(PlcOps); 24 + }); 25 + }); 26 + 27 + describe("getPlcAuditLogs", () => { 28 + it("throws on HTTP error", async () => { 29 + globalThis.fetch = vi.fn().mockResolvedValue({ 30 + ok: false, 31 + status: 404, 32 + }); 33 + 34 + await expect(plcOps.getPlcAuditLogs("did:plc:notfound")).rejects.toThrow( 35 + "Failed to fetch PLC audit logs: 404", 36 + ); 37 + }); 38 + }); 39 + 40 + describe("getLastPlcOpFromPlc", () => { 41 + it("throws when empty array returned", async () => { 42 + globalThis.fetch = vi.fn().mockResolvedValue({ 43 + ok: true, 44 + json: () => Promise.resolve([]), 45 + }); 46 + 47 + await expect( 48 + plcOps.getLastPlcOpFromPlc("did:plc:empty"), 49 + ).rejects.toThrow(); 50 + }); 51 + }); 52 + 53 + describe("createNewSecp256k1Keypair", () => { 54 + it("generates a keypair with private and public keys", async () => { 55 + const result = await plcOps.createNewSecp256k1Keypair(); 56 + 57 + expect(result.privateKey).toBeDefined(); 58 + expect(result.publicKey).toBeDefined(); 59 + expect(result.publicKey.startsWith("did:key:")).toBe(true); 60 + }); 61 + 62 + it("generates different keypairs each time", async () => { 63 + const result1 = await plcOps.createNewSecp256k1Keypair(); 64 + const result2 = await plcOps.createNewSecp256k1Keypair(); 65 + 66 + expect(result1.privateKey).not.toBe(result2.privateKey); 67 + expect(result1.publicKey).not.toBe(result2.publicKey); 68 + }); 69 + }); 70 + 71 + describe("getKeyPair", () => { 72 + it("parses 64-character hex private key", async () => { 73 + const hexKey = "a".repeat(64); 74 + 75 + const result = await plcOps.getKeyPair(hexKey); 76 + 77 + expect(result.type).toBe("private_key"); 78 + expect(result.didPublicKey.startsWith("did:key:")).toBe(true); 79 + expect(result.keypair).toBeDefined(); 80 + }); 81 + 82 + it("handles whitespace in key input", async () => { 83 + const hexKey = " " + "b".repeat(64) + " "; 84 + 85 + const result = await plcOps.getKeyPair(hexKey); 86 + 87 + expect(result.type).toBe("private_key"); 88 + }); 89 + 90 + it("throws for invalid key format", async () => { 91 + await expect(plcOps.getKeyPair("not-a-valid-key")).rejects.toThrow( 92 + "Invalid key format", 93 + ); 94 + }); 95 + 96 + it("throws for hex key with wrong length", async () => { 97 + await expect(plcOps.getKeyPair("abc123")).rejects.toThrow( 98 + "Invalid key format", 99 + ); 100 + }); 101 + }); 102 + 103 + describe("pushPlcOperation", () => { 104 + it("posts operation to PLC directory", async () => { 105 + globalThis.fetch = vi.fn().mockResolvedValue({ 106 + ok: true, 107 + }); 108 + 109 + const operation = { 110 + type: "plc_operation" as const, 111 + prev: "bafyreiabc", 112 + alsoKnownAs: ["at://alice.example.com"], 113 + rotationKeys: ["did:key:z123"], 114 + services: { 115 + atproto_pds: { 116 + type: "AtprotoPersonalDataServer", 117 + endpoint: "https://pds.example.com", 118 + }, 119 + }, 120 + verificationMethods: { 121 + atproto: "did:key:z456", 122 + }, 123 + sig: "test-signature", 124 + }; 125 + 126 + await plcOps.pushPlcOperation("did:plc:abc123", operation); 127 + 128 + expect(fetch).toHaveBeenCalledWith( 129 + "https://plc.directory/did:plc:abc123", 130 + expect.objectContaining({ 131 + method: "POST", 132 + headers: { "Content-Type": "application/json" }, 133 + body: JSON.stringify(operation), 134 + }), 135 + ); 136 + }); 137 + 138 + it("throws with error message from PLC directory", async () => { 139 + globalThis.fetch = vi.fn().mockResolvedValue({ 140 + ok: false, 141 + status: 400, 142 + headers: new Map([["content-type", "application/json"]]), 143 + json: () => Promise.resolve({ message: "Invalid signature" }), 144 + }); 145 + 146 + const operation = { 147 + type: "plc_operation" as const, 148 + prev: "bafyreiabc", 149 + alsoKnownAs: [], 150 + rotationKeys: ["did:key:z123"], 151 + services: {}, 152 + verificationMethods: {}, 153 + sig: "bad-sig", 154 + }; 155 + 156 + await expect( 157 + plcOps.pushPlcOperation("did:plc:abc123", operation), 158 + ).rejects.toThrow("Invalid signature"); 159 + }); 160 + 161 + it("throws generic error when no message in response", async () => { 162 + globalThis.fetch = vi.fn().mockResolvedValue({ 163 + ok: false, 164 + status: 500, 165 + headers: new Map([["content-type", "text/plain"]]), 166 + }); 167 + 168 + const operation = { 169 + type: "plc_operation" as const, 170 + prev: null, 171 + alsoKnownAs: [], 172 + rotationKeys: [], 173 + services: {}, 174 + verificationMethods: {}, 175 + }; 176 + 177 + await expect( 178 + plcOps.pushPlcOperation("did:plc:abc123", operation), 179 + ).rejects.toThrow("PLC directory returned HTTP 500"); 180 + }); 181 + }); 182 + 183 + describe("createServiceAuthToken", () => { 184 + it("creates a valid JWT", async () => { 185 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 186 + const keypair = await plcOps.getKeyPair(privateKey); 187 + 188 + const token = await plcOps.createServiceAuthToken( 189 + "did:plc:issuer", 190 + "did:web:audience.example.com", 191 + keypair.keypair, 192 + "com.atproto.server.createAccount", 193 + ); 194 + 195 + expect(token).toBeDefined(); 196 + const parts = token.split("."); 197 + expect(parts).toHaveLength(3); 198 + }); 199 + 200 + it("includes correct header", async () => { 201 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 202 + const keypair = await plcOps.getKeyPair(privateKey); 203 + 204 + const token = await plcOps.createServiceAuthToken( 205 + "did:plc:issuer", 206 + "did:web:audience", 207 + keypair.keypair, 208 + "com.atproto.server.createAccount", 209 + ); 210 + 211 + const headerB64 = token.split(".")[0]; 212 + const header = JSON.parse( 213 + atob(headerB64.replace(/-/g, "+").replace(/_/g, "/")), 214 + ); 215 + expect(header.typ).toBe("JWT"); 216 + expect(header.alg).toBe("ES256K"); 217 + }); 218 + 219 + it("includes correct payload claims", async () => { 220 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 221 + const keypair = await plcOps.getKeyPair(privateKey); 222 + 223 + const before = Math.floor(Date.now() / 1000); 224 + const token = await plcOps.createServiceAuthToken( 225 + "did:plc:myissuer", 226 + "did:web:myaudience.com", 227 + keypair.keypair, 228 + "com.atproto.sync.getRepo", 229 + ); 230 + const after = Math.floor(Date.now() / 1000); 231 + 232 + const payloadB64 = token.split(".")[1]; 233 + const payload = JSON.parse( 234 + atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/")), 235 + ); 236 + 237 + expect(payload.iss).toBe("did:plc:myissuer"); 238 + expect(payload.aud).toBe("did:web:myaudience.com"); 239 + expect(payload.lxm).toBe("com.atproto.sync.getRepo"); 240 + expect(payload.iat).toBeGreaterThanOrEqual(before); 241 + expect(payload.iat).toBeLessThanOrEqual(after); 242 + expect(payload.exp).toBe(payload.iat + 60); 243 + expect(payload.jti).toBeDefined(); 244 + }); 245 + }); 246 + 247 + describe("signAndPublishNewOp", () => { 248 + it("throws when no rotation keys provided", async () => { 249 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 250 + const keypair = await plcOps.getKeyPair(privateKey); 251 + 252 + await expect( 253 + plcOps.signAndPublishNewOp( 254 + "did:plc:test", 255 + keypair.keypair, 256 + ["at://alice.example.com"], 257 + [], 258 + "https://pds.example.com", 259 + "did:key:zVerify", 260 + "bafyreiprev", 261 + ), 262 + ).rejects.toThrow("No rotation keys provided"); 263 + }); 264 + 265 + it("throws when more than 5 unique rotation keys provided", async () => { 266 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 267 + const keypair = await plcOps.getKeyPair(privateKey); 268 + 269 + const tooManyKeys = [ 270 + "did:key:z1", 271 + "did:key:z2", 272 + "did:key:z3", 273 + "did:key:z4", 274 + "did:key:z5", 275 + "did:key:z6", 276 + ]; 277 + 278 + await expect( 279 + plcOps.signAndPublishNewOp( 280 + "did:plc:test", 281 + keypair.keypair, 282 + [], 283 + tooManyKeys, 284 + "https://pds.example.com", 285 + "did:key:zVerify", 286 + "bafyreiprev", 287 + ), 288 + ).rejects.toThrow("Maximum 5 rotation keys allowed"); 289 + }); 290 + }); 291 + 292 + describe("signPlcOperationWithCredentials", () => { 293 + it("throws when no rotation keys provided", async () => { 294 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 295 + const keypair = await plcOps.getKeyPair(privateKey); 296 + 297 + await expect( 298 + plcOps.signPlcOperationWithCredentials( 299 + "did:plc:test", 300 + keypair.keypair, 301 + { 302 + rotationKeys: [], 303 + alsoKnownAs: [], 304 + verificationMethods: {}, 305 + services: {}, 306 + }, 307 + [], 308 + "bafyreiprev", 309 + ), 310 + ).rejects.toThrow("No rotation keys provided"); 311 + }); 312 + 313 + it("throws when more than 5 rotation keys provided", async () => { 314 + const { privateKey } = await plcOps.createNewSecp256k1Keypair(); 315 + const keypair = await plcOps.getKeyPair(privateKey); 316 + 317 + await expect( 318 + plcOps.signPlcOperationWithCredentials( 319 + "did:plc:test", 320 + keypair.keypair, 321 + { 322 + rotationKeys: ["did:key:z1", "did:key:z2", "did:key:z3"], 323 + alsoKnownAs: [], 324 + verificationMethods: {}, 325 + services: {}, 326 + }, 327 + ["did:key:z4", "did:key:z5", "did:key:z6"], 328 + "bafyreiprev", 329 + ), 330 + ).rejects.toThrow("Maximum 5 rotation keys allowed"); 331 + }); 332 + }); 333 + });
+7 -3
frontend/src/tests/mocks.ts
··· 206 206 () => jsonResponse({ code: "new-invite-" + Date.now() }), 207 207 ); 208 208 mockEndpoint( 209 - "com.tranquil.account.getNotificationPrefs", 209 + "_account.getNotificationPrefs", 210 210 () => jsonResponse(mockData.notificationPrefs()), 211 211 ); 212 212 mockEndpoint( 213 - "com.tranquil.account.updateNotificationPrefs", 213 + "_account.updateNotificationPrefs", 214 214 () => jsonResponse({ success: true }), 215 215 ); 216 216 mockEndpoint( 217 - "com.tranquil.account.getNotificationHistory", 217 + "_account.getNotificationHistory", 218 218 () => jsonResponse({ notifications: [] }), 219 219 ); 220 220 mockEndpoint( ··· 240 240 mockEndpoint( 241 241 "com.atproto.repo.listRecords", 242 242 () => jsonResponse({ records: [] }), 243 + ); 244 + mockEndpoint( 245 + "_backup.listBackups", 246 + () => jsonResponse({ backups: [] }), 243 247 ); 244 248 } 245 249 export function setupAuthenticatedUser(
+15
migrations/20260101_account_backups.sql
··· 1 + ALTER TABLE users ADD COLUMN backup_enabled BOOLEAN NOT NULL DEFAULT TRUE; 2 + 3 + CREATE TABLE account_backups ( 4 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), 5 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, 6 + storage_key TEXT NOT NULL, 7 + repo_root_cid TEXT NOT NULL, 8 + repo_rev TEXT NOT NULL, 9 + block_count INT NOT NULL, 10 + size_bytes BIGINT NOT NULL, 11 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() 12 + ); 13 + 14 + CREATE INDEX idx_account_backups_user_id ON account_backups(user_id); 15 + CREATE INDEX idx_account_backups_created_at ON account_backups(created_at);
+6 -3
scripts/install-debian.sh
··· 44 44 sudo -u postgres psql -c "DROP DATABASE IF EXISTS pds;" 2>/dev/null || true 45 45 sudo -u postgres psql -c "DROP USER IF EXISTS tranquil_pds;" 2>/dev/null || true 46 46 47 - log_info "Removing minio bucket..." 47 + log_info "Removing minio buckets..." 48 48 if command -v mc &>/dev/null; then 49 49 mc rb local/pds-blobs --force 2>/dev/null || true 50 + mc rb local/pds-backups --force 2>/dev/null || true 50 51 mc alias remove local 2>/dev/null || true 51 52 fi 52 53 systemctl stop minio 2>/dev/null || true ··· 78 79 echo " - PostgreSQL database 'pds' and all data" 79 80 echo " - All Tranquil PDS configuration and credentials" 80 81 echo " - All source code in /opt/tranquil-pds" 81 - echo " - MinIO bucket 'pds-blobs' and all blobs" 82 + echo " - MinIO buckets 'pds-blobs' and 'pds-backups' and all data" 82 83 echo "" 83 84 read -p "Type 'NUKE' to confirm: " CONFIRM_NUKE 84 85 if [[ "$CONFIRM_NUKE" == "NUKE" ]]; then ··· 274 275 mc alias remove local 2>/dev/null || true 275 276 mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4 276 277 mc mb local/pds-blobs --ignore-existing 277 - log_success "minio bucket created" 278 + mc mb local/pds-backups --ignore-existing 279 + log_success "minio buckets created" 278 280 279 281 log_info "Installing rust..." 280 282 if [[ -f "$HOME/.cargo/env" ]]; then ··· 382 384 S3_ENDPOINT=http://localhost:9000 383 385 AWS_REGION=us-east-1 384 386 S3_BUCKET=pds-blobs 387 + BACKUP_S3_BUCKET=pds-backups 385 388 AWS_ACCESS_KEY_ID=minioadmin 386 389 AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} 387 390 VALKEY_URL=redis://localhost:6379
+5 -1
scripts/test-infra.sh
··· 83 83 echo "Waiting for Valkey... ($i/30)" 84 84 sleep 1 85 85 done 86 - echo "Creating MinIO bucket..." 86 + echo "Creating MinIO buckets..." 87 87 $CONTAINER_CMD run --rm --network host \ 88 88 -e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \ 89 89 minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true 90 + $CONTAINER_CMD run --rm --network host \ 91 + -e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \ 92 + minio/mc:latest mb minio/test-backups --ignore-existing >/dev/null 2>&1 || true 90 93 cat > "$INFRA_FILE" << EOF 91 94 export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres" 92 95 export TEST_DB_PORT="${PG_PORT}" 93 96 export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}" 94 97 export S3_BUCKET="test-bucket" 98 + export BACKUP_S3_BUCKET="test-backups" 95 99 export AWS_ACCESS_KEY_ID="minioadmin" 96 100 export AWS_SECRET_ACCESS_KEY="minioadmin" 97 101 export AWS_REGION="us-east-1"
+930
src/api/backup.rs
··· 1 + use crate::auth::BearerAuth; 2 + use crate::scheduled::generate_full_backup; 3 + use crate::state::AppState; 4 + use crate::storage::BackupStorage; 5 + use axum::{ 6 + Json, 7 + extract::{Query, State}, 8 + http::StatusCode, 9 + response::{IntoResponse, Response}, 10 + }; 11 + use cid::Cid; 12 + use serde::{Deserialize, Serialize}; 13 + use serde_json::json; 14 + use std::str::FromStr; 15 + use tracing::{error, info, warn}; 16 + 17 + #[derive(Serialize)] 18 + #[serde(rename_all = "camelCase")] 19 + pub struct BackupInfo { 20 + pub id: String, 21 + pub repo_rev: String, 22 + pub repo_root_cid: String, 23 + pub block_count: i32, 24 + pub size_bytes: i64, 25 + pub created_at: String, 26 + } 27 + 28 + #[derive(Serialize)] 29 + #[serde(rename_all = "camelCase")] 30 + pub struct ListBackupsOutput { 31 + pub backups: Vec<BackupInfo>, 32 + pub backup_enabled: bool, 33 + } 34 + 35 + pub async fn list_backups(State(state): State<AppState>, auth: BearerAuth) -> Response { 36 + let user = match sqlx::query!( 37 + "SELECT id, backup_enabled FROM users WHERE did = $1", 38 + auth.0.did 39 + ) 40 + .fetch_optional(&state.db) 41 + .await 42 + { 43 + Ok(Some(u)) => u, 44 + Ok(None) => { 45 + return ( 46 + StatusCode::NOT_FOUND, 47 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 48 + ) 49 + .into_response(); 50 + } 51 + Err(e) => { 52 + error!("DB error fetching user: {:?}", e); 53 + return ( 54 + StatusCode::INTERNAL_SERVER_ERROR, 55 + Json(json!({"error": "InternalError", "message": "Database error"})), 56 + ) 57 + .into_response(); 58 + } 59 + }; 60 + 61 + let backups = match sqlx::query!( 62 + r#" 63 + SELECT id, repo_rev, repo_root_cid, block_count, size_bytes, created_at 64 + FROM account_backups 65 + WHERE user_id = $1 66 + ORDER BY created_at DESC 67 + "#, 68 + user.id 69 + ) 70 + .fetch_all(&state.db) 71 + .await 72 + { 73 + Ok(rows) => rows, 74 + Err(e) => { 75 + error!("DB error fetching backups: {:?}", e); 76 + return ( 77 + StatusCode::INTERNAL_SERVER_ERROR, 78 + Json(json!({"error": "InternalError", "message": "Database error"})), 79 + ) 80 + .into_response(); 81 + } 82 + }; 83 + 84 + let backup_list: Vec<BackupInfo> = backups 85 + .into_iter() 86 + .map(|b| BackupInfo { 87 + id: b.id.to_string(), 88 + repo_rev: b.repo_rev, 89 + repo_root_cid: b.repo_root_cid, 90 + block_count: b.block_count, 91 + size_bytes: b.size_bytes, 92 + created_at: b.created_at.to_rfc3339(), 93 + }) 94 + .collect(); 95 + 96 + ( 97 + StatusCode::OK, 98 + Json(ListBackupsOutput { 99 + backups: backup_list, 100 + backup_enabled: user.backup_enabled, 101 + }), 102 + ) 103 + .into_response() 104 + } 105 + 106 + #[derive(Deserialize)] 107 + pub struct GetBackupQuery { 108 + pub id: String, 109 + } 110 + 111 + pub async fn get_backup( 112 + State(state): State<AppState>, 113 + auth: BearerAuth, 114 + Query(query): Query<GetBackupQuery>, 115 + ) -> Response { 116 + let backup_id = match uuid::Uuid::parse_str(&query.id) { 117 + Ok(id) => id, 118 + Err(_) => { 119 + return ( 120 + StatusCode::BAD_REQUEST, 121 + Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})), 122 + ) 123 + .into_response(); 124 + } 125 + }; 126 + 127 + let backup = match sqlx::query!( 128 + r#" 129 + SELECT ab.storage_key, ab.repo_rev 130 + FROM account_backups ab 131 + JOIN users u ON u.id = ab.user_id 132 + WHERE ab.id = $1 AND u.did = $2 133 + "#, 134 + backup_id, 135 + auth.0.did 136 + ) 137 + .fetch_optional(&state.db) 138 + .await 139 + { 140 + Ok(Some(b)) => b, 141 + Ok(None) => { 142 + return ( 143 + StatusCode::NOT_FOUND, 144 + Json(json!({"error": "BackupNotFound", "message": "Backup not found"})), 145 + ) 146 + .into_response(); 147 + } 148 + Err(e) => { 149 + error!("DB error fetching backup: {:?}", e); 150 + return ( 151 + StatusCode::INTERNAL_SERVER_ERROR, 152 + Json(json!({"error": "InternalError", "message": "Database error"})), 153 + ) 154 + .into_response(); 155 + } 156 + }; 157 + 158 + let backup_storage = match state.backup_storage.as_ref() { 159 + Some(storage) => storage, 160 + None => { 161 + return ( 162 + StatusCode::SERVICE_UNAVAILABLE, 163 + Json( 164 + json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}), 165 + ), 166 + ) 167 + .into_response(); 168 + } 169 + }; 170 + 171 + let car_bytes = match backup_storage.get_backup(&backup.storage_key).await { 172 + Ok(bytes) => bytes, 173 + Err(e) => { 174 + error!("Failed to fetch backup from storage: {:?}", e); 175 + return ( 176 + StatusCode::INTERNAL_SERVER_ERROR, 177 + Json(json!({"error": "InternalError", "message": "Failed to retrieve backup"})), 178 + ) 179 + .into_response(); 180 + } 181 + }; 182 + 183 + ( 184 + StatusCode::OK, 185 + [ 186 + (axum::http::header::CONTENT_TYPE, "application/vnd.ipld.car"), 187 + ( 188 + axum::http::header::CONTENT_DISPOSITION, 189 + &format!("attachment; filename=\"{}.car\"", backup.repo_rev), 190 + ), 191 + ], 192 + car_bytes, 193 + ) 194 + .into_response() 195 + } 196 + 197 + #[derive(Serialize)] 198 + #[serde(rename_all = "camelCase")] 199 + pub struct CreateBackupOutput { 200 + pub id: String, 201 + pub repo_rev: String, 202 + pub size_bytes: i64, 203 + pub block_count: i32, 204 + } 205 + 206 + pub async fn create_backup(State(state): State<AppState>, auth: BearerAuth) -> Response { 207 + let backup_storage = match state.backup_storage.as_ref() { 208 + Some(storage) => storage, 209 + None => { 210 + return ( 211 + StatusCode::SERVICE_UNAVAILABLE, 212 + Json( 213 + json!({"error": "BackupsDisabled", "message": "Backup storage not configured"}), 214 + ), 215 + ) 216 + .into_response(); 217 + } 218 + }; 219 + 220 + let user = match sqlx::query!( 221 + r#" 222 + SELECT u.id, u.did, u.backup_enabled, u.deactivated_at, r.repo_root_cid, r.repo_rev 223 + FROM users u 224 + JOIN repos r ON r.user_id = u.id 225 + WHERE u.did = $1 226 + "#, 227 + auth.0.did 228 + ) 229 + .fetch_optional(&state.db) 230 + .await 231 + { 232 + Ok(Some(u)) => u, 233 + Ok(None) => { 234 + return ( 235 + StatusCode::NOT_FOUND, 236 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 237 + ) 238 + .into_response(); 239 + } 240 + Err(e) => { 241 + error!("DB error fetching user: {:?}", e); 242 + return ( 243 + StatusCode::INTERNAL_SERVER_ERROR, 244 + Json(json!({"error": "InternalError", "message": "Database error"})), 245 + ) 246 + .into_response(); 247 + } 248 + }; 249 + 250 + if user.deactivated_at.is_some() { 251 + return ( 252 + StatusCode::BAD_REQUEST, 253 + Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), 254 + ) 255 + .into_response(); 256 + } 257 + 258 + let repo_rev = match &user.repo_rev { 259 + Some(rev) => rev.clone(), 260 + None => { 261 + return ( 262 + StatusCode::BAD_REQUEST, 263 + Json( 264 + json!({"error": "RepoNotReady", "message": "Repository not ready for backup"}), 265 + ), 266 + ) 267 + .into_response(); 268 + } 269 + }; 270 + 271 + let head_cid = match Cid::from_str(&user.repo_root_cid) { 272 + Ok(c) => c, 273 + Err(_) => { 274 + return ( 275 + StatusCode::INTERNAL_SERVER_ERROR, 276 + Json(json!({"error": "InternalError", "message": "Invalid repo root CID"})), 277 + ) 278 + .into_response(); 279 + } 280 + }; 281 + 282 + let car_bytes = match generate_full_backup(&state.block_store, &head_cid).await { 283 + Ok(bytes) => bytes, 284 + Err(e) => { 285 + error!("Failed to generate CAR: {:?}", e); 286 + return ( 287 + StatusCode::INTERNAL_SERVER_ERROR, 288 + Json(json!({"error": "InternalError", "message": "Failed to generate backup"})), 289 + ) 290 + .into_response(); 291 + } 292 + }; 293 + 294 + let block_count = crate::scheduled::count_car_blocks(&car_bytes); 295 + let size_bytes = car_bytes.len() as i64; 296 + 297 + let storage_key = match backup_storage 298 + .put_backup(&user.did, &repo_rev, &car_bytes) 299 + .await 300 + { 301 + Ok(key) => key, 302 + Err(e) => { 303 + error!("Failed to upload backup: {:?}", e); 304 + return ( 305 + StatusCode::INTERNAL_SERVER_ERROR, 306 + Json(json!({"error": "InternalError", "message": "Failed to store backup"})), 307 + ) 308 + .into_response(); 309 + } 310 + }; 311 + 312 + let backup_id = match sqlx::query_scalar!( 313 + r#" 314 + INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes) 315 + VALUES ($1, $2, $3, $4, $5, $6) 316 + RETURNING id 317 + "#, 318 + user.id, 319 + storage_key, 320 + user.repo_root_cid, 321 + repo_rev, 322 + block_count, 323 + size_bytes 324 + ) 325 + .fetch_one(&state.db) 326 + .await 327 + { 328 + Ok(id) => id, 329 + Err(e) => { 330 + error!("DB error inserting backup: {:?}", e); 331 + if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await { 332 + error!( 333 + storage_key = %storage_key, 334 + error = %rollback_err, 335 + "Failed to rollback orphaned backup from S3" 336 + ); 337 + } 338 + return ( 339 + StatusCode::INTERNAL_SERVER_ERROR, 340 + Json(json!({"error": "InternalError", "message": "Failed to record backup"})), 341 + ) 342 + .into_response(); 343 + } 344 + }; 345 + 346 + info!( 347 + did = %user.did, 348 + rev = %repo_rev, 349 + size_bytes, 350 + "Created manual backup" 351 + ); 352 + 353 + let retention = BackupStorage::retention_count(); 354 + if let Err(e) = cleanup_old_backups(&state.db, backup_storage, user.id, retention).await { 355 + warn!(did = %user.did, error = %e, "Failed to cleanup old backups after manual backup"); 356 + } 357 + 358 + ( 359 + StatusCode::OK, 360 + Json(CreateBackupOutput { 361 + id: backup_id.to_string(), 362 + repo_rev, 363 + size_bytes, 364 + block_count, 365 + }), 366 + ) 367 + .into_response() 368 + } 369 + 370 + async fn cleanup_old_backups( 371 + db: &sqlx::PgPool, 372 + backup_storage: &BackupStorage, 373 + user_id: uuid::Uuid, 374 + retention_count: u32, 375 + ) -> Result<(), String> { 376 + let old_backups = sqlx::query!( 377 + r#" 378 + SELECT id, storage_key 379 + FROM account_backups 380 + WHERE user_id = $1 381 + ORDER BY created_at DESC 382 + OFFSET $2 383 + "#, 384 + user_id, 385 + retention_count as i64 386 + ) 387 + .fetch_all(db) 388 + .await 389 + .map_err(|e| format!("DB error fetching old backups: {}", e))?; 390 + 391 + for backup in old_backups { 392 + if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await { 393 + warn!( 394 + storage_key = %backup.storage_key, 395 + error = %e, 396 + "Failed to delete old backup from storage, skipping DB cleanup to avoid orphan" 397 + ); 398 + continue; 399 + } 400 + 401 + sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) 402 + .execute(db) 403 + .await 404 + .map_err(|e| format!("Failed to delete old backup record: {}", e))?; 405 + } 406 + 407 + Ok(()) 408 + } 409 + 410 + #[derive(Deserialize)] 411 + pub struct DeleteBackupQuery { 412 + pub id: String, 413 + } 414 + 415 + pub async fn delete_backup( 416 + State(state): State<AppState>, 417 + auth: BearerAuth, 418 + Query(query): Query<DeleteBackupQuery>, 419 + ) -> Response { 420 + let backup_id = match uuid::Uuid::parse_str(&query.id) { 421 + Ok(id) => id, 422 + Err(_) => { 423 + return ( 424 + StatusCode::BAD_REQUEST, 425 + Json(json!({"error": "InvalidRequest", "message": "Invalid backup ID"})), 426 + ) 427 + .into_response(); 428 + } 429 + }; 430 + 431 + let backup = match sqlx::query!( 432 + r#" 433 + SELECT ab.id, ab.storage_key, u.deactivated_at 434 + FROM account_backups ab 435 + JOIN users u ON u.id = ab.user_id 436 + WHERE ab.id = $1 AND u.did = $2 437 + "#, 438 + backup_id, 439 + auth.0.did 440 + ) 441 + .fetch_optional(&state.db) 442 + .await 443 + { 444 + Ok(Some(b)) => b, 445 + Ok(None) => { 446 + return ( 447 + StatusCode::NOT_FOUND, 448 + Json(json!({"error": "BackupNotFound", "message": "Backup not found"})), 449 + ) 450 + .into_response(); 451 + } 452 + Err(e) => { 453 + error!("DB error fetching backup: {:?}", e); 454 + return ( 455 + StatusCode::INTERNAL_SERVER_ERROR, 456 + Json(json!({"error": "InternalError", "message": "Database error"})), 457 + ) 458 + .into_response(); 459 + } 460 + }; 461 + 462 + if backup.deactivated_at.is_some() { 463 + return ( 464 + StatusCode::BAD_REQUEST, 465 + Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), 466 + ) 467 + .into_response(); 468 + } 469 + 470 + if let Some(backup_storage) = state.backup_storage.as_ref() 471 + && let Err(e) = backup_storage.delete_backup(&backup.storage_key).await 472 + { 473 + warn!( 474 + storage_key = %backup.storage_key, 475 + error = %e, 476 + "Failed to delete backup from storage (continuing anyway)" 477 + ); 478 + } 479 + 480 + if let Err(e) = sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) 481 + .execute(&state.db) 482 + .await 483 + { 484 + error!("DB error deleting backup: {:?}", e); 485 + return ( 486 + StatusCode::INTERNAL_SERVER_ERROR, 487 + Json(json!({"error": "InternalError", "message": "Failed to delete backup"})), 488 + ) 489 + .into_response(); 490 + } 491 + 492 + info!(did = %auth.0.did, backup_id = %backup_id, "Deleted backup"); 493 + 494 + (StatusCode::OK, Json(json!({}))).into_response() 495 + } 496 + 497 + #[derive(Deserialize)] 498 + #[serde(rename_all = "camelCase")] 499 + pub struct SetBackupEnabledInput { 500 + pub enabled: bool, 501 + } 502 + 503 + pub async fn set_backup_enabled( 504 + State(state): State<AppState>, 505 + auth: BearerAuth, 506 + Json(input): Json<SetBackupEnabledInput>, 507 + ) -> Response { 508 + let user = match sqlx::query!( 509 + "SELECT deactivated_at FROM users WHERE did = $1", 510 + auth.0.did 511 + ) 512 + .fetch_optional(&state.db) 513 + .await 514 + { 515 + Ok(Some(u)) => u, 516 + Ok(None) => { 517 + return ( 518 + StatusCode::NOT_FOUND, 519 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 520 + ) 521 + .into_response(); 522 + } 523 + Err(e) => { 524 + error!("DB error fetching user: {:?}", e); 525 + return ( 526 + StatusCode::INTERNAL_SERVER_ERROR, 527 + Json(json!({"error": "InternalError", "message": "Database error"})), 528 + ) 529 + .into_response(); 530 + } 531 + }; 532 + 533 + if user.deactivated_at.is_some() { 534 + return ( 535 + StatusCode::BAD_REQUEST, 536 + Json(json!({"error": "AccountDeactivated", "message": "Account is deactivated"})), 537 + ) 538 + .into_response(); 539 + } 540 + 541 + if let Err(e) = sqlx::query!( 542 + "UPDATE users SET backup_enabled = $1 WHERE did = $2", 543 + input.enabled, 544 + auth.0.did 545 + ) 546 + .execute(&state.db) 547 + .await 548 + { 549 + error!("DB error updating backup_enabled: {:?}", e); 550 + return ( 551 + StatusCode::INTERNAL_SERVER_ERROR, 552 + Json(json!({"error": "InternalError", "message": "Failed to update setting"})), 553 + ) 554 + .into_response(); 555 + } 556 + 557 + info!(did = %auth.0.did, enabled = input.enabled, "Updated backup_enabled setting"); 558 + 559 + (StatusCode::OK, Json(json!({"enabled": input.enabled}))).into_response() 560 + } 561 + 562 + pub async fn export_blobs(State(state): State<AppState>, auth: BearerAuth) -> Response { 563 + let user = match sqlx::query!("SELECT id FROM users WHERE did = $1", auth.0.did) 564 + .fetch_optional(&state.db) 565 + .await 566 + { 567 + Ok(Some(u)) => u, 568 + Ok(None) => { 569 + return ( 570 + StatusCode::NOT_FOUND, 571 + Json(json!({"error": "AccountNotFound", "message": "Account not found"})), 572 + ) 573 + .into_response(); 574 + } 575 + Err(e) => { 576 + error!("DB error fetching user: {:?}", e); 577 + return ( 578 + StatusCode::INTERNAL_SERVER_ERROR, 579 + Json(json!({"error": "InternalError", "message": "Database error"})), 580 + ) 581 + .into_response(); 582 + } 583 + }; 584 + 585 + let blobs = match sqlx::query!( 586 + r#" 587 + SELECT DISTINCT b.cid, b.storage_key, b.mime_type 588 + FROM blobs b 589 + JOIN record_blobs rb ON rb.blob_cid = b.cid 590 + WHERE rb.repo_id = $1 591 + "#, 592 + user.id 593 + ) 594 + .fetch_all(&state.db) 595 + .await 596 + { 597 + Ok(rows) => rows, 598 + Err(e) => { 599 + error!("DB error fetching blobs: {:?}", e); 600 + return ( 601 + StatusCode::INTERNAL_SERVER_ERROR, 602 + Json(json!({"error": "InternalError", "message": "Database error"})), 603 + ) 604 + .into_response(); 605 + } 606 + }; 607 + 608 + if blobs.is_empty() { 609 + return ( 610 + StatusCode::OK, 611 + [ 612 + (axum::http::header::CONTENT_TYPE, "application/zip"), 613 + ( 614 + axum::http::header::CONTENT_DISPOSITION, 615 + "attachment; filename=\"blobs.zip\"", 616 + ), 617 + ], 618 + Vec::<u8>::new(), 619 + ) 620 + .into_response(); 621 + } 622 + 623 + let mut zip_buffer = std::io::Cursor::new(Vec::new()); 624 + { 625 + let mut zip = zip::ZipWriter::new(&mut zip_buffer); 626 + 627 + let options = zip::write::SimpleFileOptions::default() 628 + .compression_method(zip::CompressionMethod::Deflated); 629 + 630 + let mut exported: Vec<serde_json::Value> = Vec::new(); 631 + let mut skipped: Vec<serde_json::Value> = Vec::new(); 632 + 633 + for blob in &blobs { 634 + let blob_data = match state.blob_store.get(&blob.storage_key).await { 635 + Ok(data) => data, 636 + Err(e) => { 637 + warn!(cid = %blob.cid, error = %e, "Failed to fetch blob, skipping"); 638 + skipped.push(json!({ 639 + "cid": blob.cid, 640 + "mimeType": blob.mime_type, 641 + "reason": "fetch_failed" 642 + })); 643 + continue; 644 + } 645 + }; 646 + 647 + let extension = mime_to_extension(&blob.mime_type); 648 + let filename = format!("{}{}", blob.cid, extension); 649 + 650 + if let Err(e) = zip.start_file(&filename, options) { 651 + warn!(filename = %filename, error = %e, "Failed to start zip file entry"); 652 + skipped.push(json!({ 653 + "cid": blob.cid, 654 + "mimeType": blob.mime_type, 655 + "reason": "zip_entry_failed" 656 + })); 657 + continue; 658 + } 659 + 660 + if let Err(e) = std::io::Write::write_all(&mut zip, &blob_data) { 661 + warn!(filename = %filename, error = %e, "Failed to write blob to zip"); 662 + skipped.push(json!({ 663 + "cid": blob.cid, 664 + "mimeType": blob.mime_type, 665 + "reason": "write_failed" 666 + })); 667 + continue; 668 + } 669 + 670 + exported.push(json!({ 671 + "cid": blob.cid, 672 + "filename": filename, 673 + "mimeType": blob.mime_type, 674 + "sizeBytes": blob_data.len() 675 + })); 676 + } 677 + 678 + let manifest = json!({ 679 + "exportedAt": chrono::Utc::now().to_rfc3339(), 680 + "totalBlobs": blobs.len(), 681 + "exportedCount": exported.len(), 682 + "skippedCount": skipped.len(), 683 + "exported": exported, 684 + "skipped": skipped 685 + }); 686 + 687 + if zip.start_file("manifest.json", options).is_ok() { 688 + let _ = std::io::Write::write_all( 689 + &mut zip, 690 + serde_json::to_string_pretty(&manifest) 691 + .unwrap_or_else(|_| "{}".to_string()) 692 + .as_bytes(), 693 + ); 694 + } 695 + 696 + if let Err(e) = zip.finish() { 697 + error!("Failed to finish zip: {:?}", e); 698 + return ( 699 + StatusCode::INTERNAL_SERVER_ERROR, 700 + Json(json!({"error": "InternalError", "message": "Failed to create zip file"})), 701 + ) 702 + .into_response(); 703 + } 704 + } 705 + 706 + let zip_bytes = zip_buffer.into_inner(); 707 + 708 + info!(did = %auth.0.did, blob_count = blobs.len(), size_bytes = zip_bytes.len(), "Exported blobs"); 709 + 710 + ( 711 + StatusCode::OK, 712 + [ 713 + (axum::http::header::CONTENT_TYPE, "application/zip"), 714 + ( 715 + axum::http::header::CONTENT_DISPOSITION, 716 + "attachment; filename=\"blobs.zip\"", 717 + ), 718 + ], 719 + zip_bytes, 720 + ) 721 + .into_response() 722 + } 723 + 724 + fn mime_to_extension(mime_type: &str) -> &'static str { 725 + match mime_type { 726 + "application/font-sfnt" => ".otf", 727 + "application/font-tdpfr" => ".pfr", 728 + "application/font-woff" => ".woff", 729 + "application/gzip" => ".gz", 730 + "application/json" => ".json", 731 + "application/json5" => ".json5", 732 + "application/jsonml+json" => ".jsonml", 733 + "application/octet-stream" => ".bin", 734 + "application/pdf" => ".pdf", 735 + "application/zip" => ".zip", 736 + "audio/aac" => ".aac", 737 + "audio/ac3" => ".ac3", 738 + "audio/aiff" => ".aiff", 739 + "audio/annodex" => ".axa", 740 + "audio/audible" => ".aa", 741 + "audio/basic" => ".au", 742 + "audio/flac" => ".flac", 743 + "audio/m4a" => ".m4a", 744 + "audio/m4b" => ".m4b", 745 + "audio/m4p" => ".m4p", 746 + "audio/mid" => ".mid", 747 + "audio/midi" => ".midi", 748 + "audio/mp4" => ".mp4a", 749 + "audio/mpeg" => ".mp3", 750 + "audio/ogg" => ".ogg", 751 + "audio/s3m" => ".s3m", 752 + "audio/scpls" => ".pls", 753 + "audio/silk" => ".sil", 754 + "audio/vnd.audible.aax" => ".aax", 755 + "audio/vnd.dece.audio" => ".uva", 756 + "audio/vnd.digital-winds" => ".eol", 757 + "audio/vnd.dlna.adts" => ".adt", 758 + "audio/vnd.dra" => ".dra", 759 + "audio/vnd.dts" => ".dts", 760 + "audio/vnd.dts.hd" => ".dtshd", 761 + "audio/vnd.lucent.voice" => ".lvp", 762 + "audio/vnd.ms-playready.media.pya" => ".pya", 763 + "audio/vnd.nuera.ecelp4800" => ".ecelp4800", 764 + "audio/vnd.nuera.ecelp7470" => ".ecelp7470", 765 + "audio/vnd.nuera.ecelp9600" => ".ecelp9600", 766 + "audio/vnd.rip" => ".rip", 767 + "audio/wav" => ".wav", 768 + "audio/webm" => ".weba", 769 + "audio/x-caf" => ".caf", 770 + "audio/x-gsm" => ".gsm", 771 + "audio/x-m4r" => ".m4r", 772 + "audio/x-matroska" => ".mka", 773 + "audio/x-mpegurl" => ".m3u", 774 + "audio/x-ms-wax" => ".wax", 775 + "audio/x-ms-wma" => ".wma", 776 + "audio/x-pn-realaudio" => ".ra", 777 + "audio/x-pn-realaudio-plugin" => ".rpm", 778 + "audio/x-sd2" => ".sd2", 779 + "audio/x-smd" => ".smd", 780 + "audio/xm" => ".xm", 781 + "font/collection" => ".ttc", 782 + "font/ttf" => ".ttf", 783 + "font/woff" => ".woff", 784 + "font/woff2" => ".woff2", 785 + "image/apng" => ".apng", 786 + "image/avif" => ".avif", 787 + "image/avif-sequence" => ".avifs", 788 + "image/bmp" => ".bmp", 789 + "image/cgm" => ".cgm", 790 + "image/cis-cod" => ".cod", 791 + "image/g3fax" => ".g3", 792 + "image/gif" => ".gif", 793 + "image/heic" => ".heic", 794 + "image/heic-sequence" => ".heics", 795 + "image/heif" => ".heif", 796 + "image/heif-sequence" => ".heifs", 797 + "image/ief" => ".ief", 798 + "image/jp2" => ".jp2", 799 + "image/jpeg" => ".jpg", 800 + "image/jpm" => ".jpm", 801 + "image/jpx" => ".jpf", 802 + "image/jxl" => ".jxl", 803 + "image/ktx" => ".ktx", 804 + "image/pict" => ".pct", 805 + "image/png" => ".png", 806 + "image/prs.btif" => ".btif", 807 + "image/qoi" => ".qoi", 808 + "image/sgi" => ".sgi", 809 + "image/svg+xml" => ".svg", 810 + "image/tiff" => ".tiff", 811 + "image/vnd.dece.graphic" => ".uvg", 812 + "image/vnd.djvu" => ".djv", 813 + "image/vnd.fastbidsheet" => ".fbs", 814 + "image/vnd.fpx" => ".fpx", 815 + "image/vnd.fst" => ".fst", 816 + "image/vnd.fujixerox.edmics-mmr" => ".mmr", 817 + "image/vnd.fujixerox.edmics-rlc" => ".rlc", 818 + "image/vnd.ms-modi" => ".mdi", 819 + "image/vnd.ms-photo" => ".wdp", 820 + "image/vnd.net-fpx" => ".npx", 821 + "image/vnd.radiance" => ".hdr", 822 + "image/vnd.rn-realflash" => ".rf", 823 + "image/vnd.wap.wbmp" => ".wbmp", 824 + "image/vnd.xiff" => ".xif", 825 + "image/webp" => ".webp", 826 + "image/x-3ds" => ".3ds", 827 + "image/x-adobe-dng" => ".dng", 828 + "image/x-canon-cr2" => ".cr2", 829 + "image/x-canon-cr3" => ".cr3", 830 + "image/x-canon-crw" => ".crw", 831 + "image/x-cmu-raster" => ".ras", 832 + "image/x-cmx" => ".cmx", 833 + "image/x-epson-erf" => ".erf", 834 + "image/x-freehand" => ".fh", 835 + "image/x-fuji-raf" => ".raf", 836 + "image/x-icon" => ".ico", 837 + "image/x-jg" => ".art", 838 + "image/x-jng" => ".jng", 839 + "image/x-kodak-dcr" => ".dcr", 840 + "image/x-kodak-k25" => ".k25", 841 + "image/x-kodak-kdc" => ".kdc", 842 + "image/x-macpaint" => ".mac", 843 + "image/x-minolta-mrw" => ".mrw", 844 + "image/x-mrsid-image" => ".sid", 845 + "image/x-nikon-nef" => ".nef", 846 + "image/x-nikon-nrw" => ".nrw", 847 + "image/x-olympus-orf" => ".orf", 848 + "image/x-panasonic-rw" => ".raw", 849 + "image/x-panasonic-rw2" => ".rw2", 850 + "image/x-pentax-pef" => ".pef", 851 + "image/x-portable-anymap" => ".pnm", 852 + "image/x-portable-bitmap" => ".pbm", 853 + "image/x-portable-graymap" => ".pgm", 854 + "image/x-portable-pixmap" => ".ppm", 855 + "image/x-qoi" => ".qoi", 856 + "image/x-quicktime" => ".qti", 857 + "image/x-rgb" => ".rgb", 858 + "image/x-sigma-x3f" => ".x3f", 859 + "image/x-sony-arw" => ".arw", 860 + "image/x-sony-sr2" => ".sr2", 861 + "image/x-sony-srf" => ".srf", 862 + "image/x-tga" => ".tga", 863 + "image/x-xbitmap" => ".xbm", 864 + "image/x-xcf" => ".xcf", 865 + "image/x-xpixmap" => ".xpm", 866 + "image/x-xwindowdump" => ".xwd", 867 + "model/gltf+json" => ".gltf", 868 + "model/gltf-binary" => ".glb", 869 + "model/iges" => ".igs", 870 + "model/mesh" => ".msh", 871 + "model/vnd.collada+xml" => ".dae", 872 + "model/vnd.gdl" => ".gdl", 873 + "model/vnd.gtw" => ".gtw", 874 + "model/vnd.vtu" => ".vtu", 875 + "model/vrml" => ".vrml", 876 + "model/x3d+binary" => ".x3db", 877 + "model/x3d+vrml" => ".x3dv", 878 + "model/x3d+xml" => ".x3d", 879 + "text/css" => ".css", 880 + "text/html" => ".html", 881 + "text/plain" => ".txt", 882 + "video/3gpp" => ".3gp", 883 + "video/3gpp2" => ".3g2", 884 + "video/annodex" => ".axv", 885 + "video/divx" => ".divx", 886 + "video/h261" => ".h261", 887 + "video/h263" => ".h263", 888 + "video/h264" => ".h264", 889 + "video/jpeg" => ".jpgv", 890 + "video/jpm" => ".jpgm", 891 + "video/mj2" => ".mj2", 892 + "video/mp4" => ".mp4", 893 + "video/mpeg" => ".mpg", 894 + "video/ogg" => ".ogv", 895 + "video/quicktime" => ".mov", 896 + "video/vnd.dece.hd" => ".uvh", 897 + "video/vnd.dece.mobile" => ".uvm", 898 + "video/vnd.dece.pd" => ".uvp", 899 + "video/vnd.dece.sd" => ".uvs", 900 + "video/vnd.dece.video" => ".uvv", 901 + "video/vnd.dlna.mpeg-tts" => ".ts", 902 + "video/vnd.dvb.file" => ".dvb", 903 + "video/vnd.fvt" => ".fvt", 904 + "video/vnd.mpegurl" => ".m4u", 905 + "video/vnd.ms-playready.media.pyv" => ".pyv", 906 + "video/vnd.uvvu.mp4" => ".uvu", 907 + "video/vnd.vivo" => ".viv", 908 + "video/webm" => ".webm", 909 + "video/x-dv" => ".dv", 910 + "video/x-f4v" => ".f4v", 911 + "video/x-fli" => ".fli", 912 + "video/x-flv" => ".flv", 913 + "video/x-ivf" => ".ivf", 914 + "video/x-la-asf" => ".lsf", 915 + "video/x-m4v" => ".m4v", 916 + "video/x-matroska" => ".mkv", 917 + "video/x-mng" => ".mng", 918 + "video/x-ms-asf" => ".asf", 919 + "video/x-ms-vob" => ".vob", 920 + "video/x-ms-wm" => ".wm", 921 + "video/x-ms-wmp" => ".wmp", 922 + "video/x-ms-wmv" => ".wmv", 923 + "video/x-ms-wmx" => ".wmx", 924 + "video/x-ms-wvx" => ".wvx", 925 + "video/x-msvideo" => ".avi", 926 + "video/x-sgi-movie" => ".movie", 927 + "video/x-smv" => ".smv", 928 + _ => ".bin", 929 + } 930 + }
+1
src/api/mod.rs
··· 1 1 pub mod actor; 2 2 pub mod admin; 3 3 pub mod age_assurance; 4 + pub mod backup; 4 5 pub mod delegation; 5 6 pub mod error; 6 7 pub mod identity;
+26 -7
src/api/notification_prefs.rs
··· 182 182 .into_response(), 183 183 }; 184 184 185 + let sensitive_types = [ 186 + "email_verification", 187 + "password_reset", 188 + "email_update", 189 + "two_factor_code", 190 + "passkey_recovery", 191 + "migration_verification", 192 + "plc_operation", 193 + "channel_verification", 194 + "signup_verification", 195 + ]; 196 + 185 197 let notifications = rows 186 198 .iter() 187 - .map(|row| NotificationHistoryEntry { 188 - created_at: row.created_at.to_rfc3339(), 189 - channel: row.channel.clone(), 190 - comms_type: row.comms_type.clone(), 191 - status: row.status.clone(), 192 - subject: row.subject.clone(), 193 - body: row.body.clone(), 199 + .map(|row| { 200 + let body = if sensitive_types.contains(&row.comms_type.as_str()) { 201 + "[Code redacted for security]".to_string() 202 + } else { 203 + row.body.clone() 204 + }; 205 + NotificationHistoryEntry { 206 + created_at: row.created_at.to_rfc3339(), 207 + channel: row.channel.clone(), 208 + comms_type: row.comms_type.clone(), 209 + status: row.status.clone(), 210 + subject: row.subject.clone(), 211 + body, 212 + } 194 213 }) 195 214 .collect(); 196 215
+1 -1
src/api/repo/blob.rs
··· 312 312 r#" 313 313 SELECT rb.blob_cid, rb.record_uri 314 314 FROM record_blobs rb 315 - LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = rb.repo_id 315 + LEFT JOIN blobs b ON rb.blob_cid = b.cid 316 316 WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2 317 317 ORDER BY rb.blob_cid 318 318 LIMIT $3
+4 -2
src/api/repo/record/batch.rs
··· 345 345 let rkey = rkey 346 346 .clone() 347 347 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); 348 + let record_ipld = crate::util::json_to_ipld(value); 348 349 let mut record_bytes = Vec::new(); 349 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { 350 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 350 351 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); 351 352 } 352 353 let record_cid = match tracking_store.put(&record_bytes).await { ··· 409 410 } 410 411 }; 411 412 all_blob_cids.extend(extract_blob_cids(value)); 413 + let record_ipld = crate::util::json_to_ipld(value); 412 414 let mut record_bytes = Vec::new(); 413 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { 415 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 414 416 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); 415 417 } 416 418 let record_cid = match tracking_store.put(&record_bytes).await {
+2 -1
src/api/repo/record/utils.rs
··· 382 382 let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes) 383 383 .map_err(|e| format!("Failed to parse commit: {:?}", e))?; 384 384 let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None); 385 + let record_ipld = crate::util::json_to_ipld(record); 385 386 let mut record_bytes = Vec::new(); 386 - serde_ipld_dagcbor::to_writer(&mut record_bytes, record) 387 + serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld) 387 388 .map_err(|e| format!("Failed to serialize record: {:?}", e))?; 388 389 let record_cid = tracking_store 389 390 .put(&record_bytes)
+4 -2
src/api/repo/record/write.rs
··· 297 297 let rkey = input 298 298 .rkey 299 299 .unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string()); 300 + let record_ipld = crate::util::json_to_ipld(&input.record); 300 301 let mut record_bytes = Vec::new(); 301 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() { 302 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 302 303 return ( 303 304 StatusCode::BAD_REQUEST, 304 305 Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})), ··· 550 551 } 551 552 } 552 553 let existing_cid = mst.get(&key).await.ok().flatten(); 554 + let record_ipld = crate::util::json_to_ipld(&input.record); 553 555 let mut record_bytes = Vec::new(); 554 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() { 556 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 555 557 return ( 556 558 StatusCode::BAD_REQUEST, 557 559 Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
+15 -44
src/api/server/account_status.rs
··· 567 567 #[serde(rename_all = "camelCase")] 568 568 pub struct DeactivateAccountInput { 569 569 pub delete_after: Option<String>, 570 - pub migrating_to: Option<String>, 571 570 } 572 571 573 572 pub async fn deactivate_account( ··· 618 617 619 618 let did = auth_user.did; 620 619 621 - let migrating_to = if let Some(ref url) = input.migrating_to { 622 - let url = url.trim().trim_end_matches('/'); 623 - if url.is_empty() || !did.starts_with("did:web:") { 624 - None 625 - } else { 626 - if !url.starts_with("https://") { 627 - return ApiError::InvalidRequest("migratingTo must start with https://".into()) 628 - .into_response(); 629 - } 630 - Some(url.to_string()) 631 - } 632 - } else { 633 - None 634 - }; 635 - 636 620 let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) 637 621 .fetch_optional(&state.db) 638 622 .await 639 623 .ok() 640 624 .flatten(); 641 625 642 - let result = if let Some(ref pds_url) = migrating_to { 643 - sqlx::query!( 644 - "UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1", 645 - did, 646 - delete_after, 647 - pds_url 648 - ) 649 - .execute(&state.db) 650 - .await 651 - } else { 652 - sqlx::query!( 653 - "UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1", 654 - did, 655 - delete_after 656 - ) 657 - .execute(&state.db) 658 - .await 659 - }; 660 - 661 - let status = if migrating_to.is_some() { 662 - "migrated" 663 - } else { 664 - "deactivated" 665 - }; 626 + let result = sqlx::query!( 627 + "UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1", 628 + did, 629 + delete_after 630 + ) 631 + .execute(&state.db) 632 + .await; 666 633 667 634 match result { 668 635 Ok(_) => { 669 636 if let Some(ref h) = handle { 670 637 let _ = state.cache.delete(&format!("handle:{}", h)).await; 671 638 } 672 - if let Err(e) = 673 - crate::api::repo::record::sequence_account_event(&state, &did, false, Some(status)) 674 - .await 639 + if let Err(e) = crate::api::repo::record::sequence_account_event( 640 + &state, 641 + &did, 642 + false, 643 + Some("deactivated"), 644 + ) 645 + .await 675 646 { 676 - warn!("Failed to sequence account {} event: {}", status, e); 647 + warn!("Failed to sequence account deactivated event: {}", e); 677 648 } 678 649 (StatusCode::OK, Json(json!({}))).into_response() 679 650 }
+54
src/api/server/email.rs
··· 476 476 info!("Email updated for user {}", user_id); 477 477 (StatusCode::OK, Json(json!({}))).into_response() 478 478 } 479 + 480 + #[derive(Deserialize)] 481 + pub struct CheckEmailVerifiedInput { 482 + pub identifier: String, 483 + } 484 + 485 + pub async fn check_email_verified( 486 + State(state): State<AppState>, 487 + headers: axum::http::HeaderMap, 488 + Json(input): Json<CheckEmailVerifiedInput>, 489 + ) -> Response { 490 + let client_ip = crate::rate_limit::extract_client_ip(&headers, None); 491 + if !state 492 + .check_rate_limit(RateLimitKind::VerificationCheck, &client_ip) 493 + .await 494 + { 495 + return ( 496 + StatusCode::TOO_MANY_REQUESTS, 497 + Json(json!({ 498 + "error": "RateLimitExceeded", 499 + "message": "Too many requests. Please try again later." 500 + })), 501 + ) 502 + .into_response(); 503 + } 504 + 505 + let user = sqlx::query!( 506 + "SELECT email_verified FROM users WHERE email = $1 OR handle = $1", 507 + input.identifier 508 + ) 509 + .fetch_optional(&state.db) 510 + .await; 511 + 512 + match user { 513 + Ok(Some(row)) => ( 514 + StatusCode::OK, 515 + Json(json!({ "verified": row.email_verified })), 516 + ) 517 + .into_response(), 518 + Ok(None) => ( 519 + StatusCode::NOT_FOUND, 520 + Json(json!({ "error": "AccountNotFound", "message": "Account not found" })), 521 + ) 522 + .into_response(), 523 + Err(e) => { 524 + error!("DB error checking email verified: {:?}", e); 525 + ( 526 + StatusCode::INTERNAL_SERVER_ERROR, 527 + Json(json!({ "error": "InternalError" })), 528 + ) 529 + .into_response() 530 + } 531 + } 532 + }
+6 -241
src/api/server/migration.rs
··· 6 6 http::StatusCode, 7 7 response::{IntoResponse, Response}, 8 8 }; 9 - use chrono::{DateTime, Utc}; 9 + use chrono::Utc; 10 10 use serde::{Deserialize, Serialize}; 11 11 use serde_json::json; 12 12 13 - #[derive(Serialize)] 14 - #[serde(rename_all = "camelCase")] 15 - pub struct GetMigrationStatusOutput { 16 - pub did: String, 17 - pub did_type: String, 18 - pub migrated: bool, 19 - #[serde(skip_serializing_if = "Option::is_none")] 20 - pub migrated_to_pds: Option<String>, 21 - #[serde(skip_serializing_if = "Option::is_none")] 22 - pub migrated_at: Option<DateTime<Utc>>, 23 - } 24 - 25 - pub async fn get_migration_status( 26 - State(state): State<AppState>, 27 - headers: axum::http::HeaderMap, 28 - ) -> Response { 29 - let extracted = match crate::auth::extract_auth_token_from_header( 30 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 31 - ) { 32 - Some(t) => t, 33 - None => return ApiError::AuthenticationRequired.into_response(), 34 - }; 35 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 36 - let http_uri = format!( 37 - "https://{}/xrpc/com.tranquil.account.getMigrationStatus", 38 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 39 - ); 40 - let auth_user = match crate::auth::validate_token_with_dpop( 41 - &state.db, 42 - &extracted.token, 43 - extracted.is_dpop, 44 - dpop_proof, 45 - "GET", 46 - &http_uri, 47 - true, 48 - ) 49 - .await 50 - { 51 - Ok(user) => user, 52 - Err(e) => return ApiError::from(e).into_response(), 53 - }; 54 - let user = match sqlx::query!( 55 - "SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1", 56 - auth_user.did 57 - ) 58 - .fetch_optional(&state.db) 59 - .await 60 - { 61 - Ok(Some(row)) => row, 62 - Ok(None) => return ApiError::AccountNotFound.into_response(), 63 - Err(e) => { 64 - tracing::error!("DB error getting migration status: {:?}", e); 65 - return ApiError::InternalError.into_response(); 66 - } 67 - }; 68 - let did_type = if user.did.starts_with("did:plc:") { 69 - "plc" 70 - } else if user.did.starts_with("did:web:") { 71 - "web" 72 - } else { 73 - "unknown" 74 - }; 75 - let migrated = user.migrated_to_pds.is_some(); 76 - ( 77 - StatusCode::OK, 78 - Json(GetMigrationStatusOutput { 79 - did: user.did, 80 - did_type: did_type.to_string(), 81 - migrated, 82 - migrated_to_pds: user.migrated_to_pds, 83 - migrated_at: user.migrated_at, 84 - }), 85 - ) 86 - .into_response() 87 - } 88 - 89 - #[derive(Deserialize)] 90 - #[serde(rename_all = "camelCase")] 91 - pub struct UpdateMigrationForwardingInput { 92 - pub pds_url: String, 93 - } 94 - 95 - #[derive(Serialize)] 96 - #[serde(rename_all = "camelCase")] 97 - pub struct UpdateMigrationForwardingOutput { 98 - pub success: bool, 99 - pub migrated_to_pds: String, 100 - pub migrated_at: DateTime<Utc>, 101 - } 102 - 103 - pub async fn update_migration_forwarding( 104 - State(state): State<AppState>, 105 - headers: axum::http::HeaderMap, 106 - Json(input): Json<UpdateMigrationForwardingInput>, 107 - ) -> Response { 108 - let extracted = match crate::auth::extract_auth_token_from_header( 109 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 110 - ) { 111 - Some(t) => t, 112 - None => return ApiError::AuthenticationRequired.into_response(), 113 - }; 114 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 115 - let http_uri = format!( 116 - "https://{}/xrpc/com.tranquil.account.updateMigrationForwarding", 117 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 118 - ); 119 - let auth_user = match crate::auth::validate_token_with_dpop( 120 - &state.db, 121 - &extracted.token, 122 - extracted.is_dpop, 123 - dpop_proof, 124 - "POST", 125 - &http_uri, 126 - true, 127 - ) 128 - .await 129 - { 130 - Ok(user) => user, 131 - Err(e) => return ApiError::from(e).into_response(), 132 - }; 133 - if !auth_user.did.starts_with("did:web:") { 134 - return ( 135 - StatusCode::BAD_REQUEST, 136 - Json(json!({ 137 - "error": "InvalidRequest", 138 - "message": "Migration forwarding is only available for did:web accounts. did:plc accounts use PLC directory for identity updates." 139 - })), 140 - ) 141 - .into_response(); 142 - } 143 - let pds_url = input.pds_url.trim(); 144 - if pds_url.is_empty() { 145 - return ApiError::InvalidRequest("pds_url is required".into()).into_response(); 146 - } 147 - if !pds_url.starts_with("https://") { 148 - return ApiError::InvalidRequest("pds_url must start with https://".into()).into_response(); 149 - } 150 - let pds_url_clean = pds_url.trim_end_matches('/'); 151 - let now = Utc::now(); 152 - let result = sqlx::query!( 153 - "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3", 154 - pds_url_clean, 155 - now, 156 - auth_user.did 157 - ) 158 - .execute(&state.db) 159 - .await; 160 - match result { 161 - Ok(_) => { 162 - tracing::info!( 163 - "Updated migration forwarding for {} to {}", 164 - auth_user.did, 165 - pds_url_clean 166 - ); 167 - ( 168 - StatusCode::OK, 169 - Json(UpdateMigrationForwardingOutput { 170 - success: true, 171 - migrated_to_pds: pds_url_clean.to_string(), 172 - migrated_at: now, 173 - }), 174 - ) 175 - .into_response() 176 - } 177 - Err(e) => { 178 - tracing::error!("DB error updating migration forwarding: {:?}", e); 179 - ApiError::InternalError.into_response() 180 - } 181 - } 182 - } 183 - 184 - pub async fn clear_migration_forwarding( 185 - State(state): State<AppState>, 186 - headers: axum::http::HeaderMap, 187 - ) -> Response { 188 - let extracted = match crate::auth::extract_auth_token_from_header( 189 - headers.get("Authorization").and_then(|h| h.to_str().ok()), 190 - ) { 191 - Some(t) => t, 192 - None => return ApiError::AuthenticationRequired.into_response(), 193 - }; 194 - let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 195 - let http_uri = format!( 196 - "https://{}/xrpc/com.tranquil.account.clearMigrationForwarding", 197 - std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 198 - ); 199 - let auth_user = match crate::auth::validate_token_with_dpop( 200 - &state.db, 201 - &extracted.token, 202 - extracted.is_dpop, 203 - dpop_proof, 204 - "POST", 205 - &http_uri, 206 - true, 207 - ) 208 - .await 209 - { 210 - Ok(user) => user, 211 - Err(e) => return ApiError::from(e).into_response(), 212 - }; 213 - if !auth_user.did.starts_with("did:web:") { 214 - return ( 215 - StatusCode::BAD_REQUEST, 216 - Json(json!({ 217 - "error": "InvalidRequest", 218 - "message": "Migration forwarding is only available for did:web accounts" 219 - })), 220 - ) 221 - .into_response(); 222 - } 223 - let result = sqlx::query!( 224 - "UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1", 225 - auth_user.did 226 - ) 227 - .execute(&state.db) 228 - .await; 229 - match result { 230 - Ok(_) => { 231 - tracing::info!("Cleared migration forwarding for {}", auth_user.did); 232 - (StatusCode::OK, Json(json!({ "success": true }))).into_response() 233 - } 234 - Err(e) => { 235 - tracing::error!("DB error clearing migration forwarding: {:?}", e); 236 - ApiError::InternalError.into_response() 237 - } 238 - } 239 - } 240 - 241 13 #[derive(Debug, Clone, Serialize, Deserialize)] 242 14 #[serde(rename_all = "camelCase")] 243 15 pub struct VerificationMethod { ··· 275 47 }; 276 48 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 277 49 let http_uri = format!( 278 - "https://{}/xrpc/com.tranquil.account.updateDidDocument", 50 + "https://{}/xrpc/_account.updateDidDocument", 279 51 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 280 52 ); 281 53 let auth_user = match crate::auth::validate_token_with_dpop( ··· 305 77 } 306 78 307 79 let user = match sqlx::query!( 308 - "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1", 80 + "SELECT id, handle, deactivated_at FROM users WHERE did = $1", 309 81 auth_user.did 310 82 ) 311 83 .fetch_optional(&state.db) ··· 319 91 } 320 92 }; 321 93 322 - if user.migrated_to_pds.is_none() { 323 - return ( 324 - StatusCode::BAD_REQUEST, 325 - Json(json!({ 326 - "error": "InvalidRequest", 327 - "message": "DID document updates are only available for migrated accounts. Use the migration flow to migrate first." 328 - })), 329 - ) 330 - .into_response(); 94 + if user.deactivated_at.is_some() { 95 + return ApiError::AccountDeactivated.into_response(); 331 96 } 332 97 333 98 if let Some(ref methods) = input.verification_methods { ··· 452 217 }; 453 218 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 454 219 let http_uri = format!( 455 - "https://{}/xrpc/com.tranquil.account.getDidDocument", 220 + "https://{}/xrpc/_account.getDidDocument", 456 221 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 457 222 ); 458 223 let auth_user = match crate::auth::validate_token_with_dpop(
+2 -5
src/api/server/mod.rs
··· 22 22 request_account_delete, 23 23 }; 24 24 pub use app_password::{create_app_password, list_app_passwords, revoke_app_password}; 25 - pub use email::{confirm_email, request_email_update, update_email}; 25 + pub use email::{check_email_verified, confirm_email, request_email_update, update_email}; 26 26 pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes}; 27 27 pub use logo::get_logo; 28 28 pub use meta::{describe_server, health, robots_txt}; 29 - pub use migration::{ 30 - clear_migration_forwarding, get_did_document, get_migration_status, update_did_document, 31 - update_migration_forwarding, 32 - }; 29 + pub use migration::{get_did_document, update_did_document}; 33 30 pub use passkey_account::{ 34 31 complete_passkey_setup, create_passkey_account, recover_passkey_account, 35 32 request_passkey_recovery, start_passkey_registration_for_setup,
+59 -55
src/lib.rs
··· 57 57 get(api::server::get_session), 58 58 ) 59 59 .route( 60 - "/xrpc/com.tranquil.account.listSessions", 60 + "/xrpc/_account.listSessions", 61 61 get(api::server::list_sessions), 62 62 ) 63 63 .route( 64 - "/xrpc/com.tranquil.account.revokeSession", 64 + "/xrpc/_account.revokeSession", 65 65 post(api::server::revoke_session), 66 66 ) 67 67 .route( 68 - "/xrpc/com.tranquil.account.revokeAllSessions", 68 + "/xrpc/_account.revokeAllSessions", 69 69 post(api::server::revoke_all_sessions), 70 70 ) 71 71 .route( ··· 208 208 post(api::server::reset_password), 209 209 ) 210 210 .route( 211 - "/xrpc/com.tranquil.account.changePassword", 211 + "/xrpc/_account.changePassword", 212 212 post(api::server::change_password), 213 213 ) 214 214 .route( 215 - "/xrpc/com.tranquil.account.removePassword", 215 + "/xrpc/_account.removePassword", 216 216 post(api::server::remove_password), 217 217 ) 218 218 .route( 219 - "/xrpc/com.tranquil.account.getPasswordStatus", 219 + "/xrpc/_account.getPasswordStatus", 220 220 get(api::server::get_password_status), 221 221 ) 222 222 .route( 223 - "/xrpc/com.tranquil.account.getReauthStatus", 223 + "/xrpc/_account.getReauthStatus", 224 224 get(api::server::get_reauth_status), 225 225 ) 226 226 .route( 227 - "/xrpc/com.tranquil.account.reauthPassword", 227 + "/xrpc/_account.reauthPassword", 228 228 post(api::server::reauth_password), 229 229 ) 230 - .route( 231 - "/xrpc/com.tranquil.account.reauthTotp", 232 - post(api::server::reauth_totp), 233 - ) 230 + .route("/xrpc/_account.reauthTotp", post(api::server::reauth_totp)) 234 231 .route( 235 - "/xrpc/com.tranquil.account.reauthPasskeyStart", 232 + "/xrpc/_account.reauthPasskeyStart", 236 233 post(api::server::reauth_passkey_start), 237 234 ) 238 235 .route( 239 - "/xrpc/com.tranquil.account.reauthPasskeyFinish", 236 + "/xrpc/_account.reauthPasskeyFinish", 240 237 post(api::server::reauth_passkey_finish), 241 238 ) 242 239 .route( 243 - "/xrpc/com.tranquil.account.getLegacyLoginPreference", 240 + "/xrpc/_account.getLegacyLoginPreference", 244 241 get(api::server::get_legacy_login_preference), 245 242 ) 246 243 .route( 247 - "/xrpc/com.tranquil.account.updateLegacyLoginPreference", 244 + "/xrpc/_account.updateLegacyLoginPreference", 248 245 post(api::server::update_legacy_login_preference), 249 246 ) 250 247 .route( 251 - "/xrpc/com.tranquil.account.updateLocale", 248 + "/xrpc/_account.updateLocale", 252 249 post(api::server::update_locale), 253 250 ) 254 251 .route( 255 - "/xrpc/com.tranquil.account.listTrustedDevices", 252 + "/xrpc/_account.listTrustedDevices", 256 253 get(api::server::list_trusted_devices), 257 254 ) 258 255 .route( 259 - "/xrpc/com.tranquil.account.revokeTrustedDevice", 256 + "/xrpc/_account.revokeTrustedDevice", 260 257 post(api::server::revoke_trusted_device), 261 258 ) 262 259 .route( 263 - "/xrpc/com.tranquil.account.updateTrustedDevice", 260 + "/xrpc/_account.updateTrustedDevice", 264 261 post(api::server::update_trusted_device), 265 262 ) 266 263 .route( 267 - "/xrpc/com.tranquil.account.createPasskeyAccount", 264 + "/xrpc/_account.createPasskeyAccount", 268 265 post(api::server::create_passkey_account), 269 266 ) 270 267 .route( 271 - "/xrpc/com.tranquil.account.startPasskeyRegistrationForSetup", 268 + "/xrpc/_account.startPasskeyRegistrationForSetup", 272 269 post(api::server::start_passkey_registration_for_setup), 273 270 ) 274 271 .route( 275 - "/xrpc/com.tranquil.account.completePasskeySetup", 272 + "/xrpc/_account.completePasskeySetup", 276 273 post(api::server::complete_passkey_setup), 277 274 ) 278 275 .route( 279 - "/xrpc/com.tranquil.account.requestPasskeyRecovery", 276 + "/xrpc/_account.requestPasskeyRecovery", 280 277 post(api::server::request_passkey_recovery), 281 278 ) 282 279 .route( 283 - "/xrpc/com.tranquil.account.recoverPasskeyAccount", 280 + "/xrpc/_account.recoverPasskeyAccount", 284 281 post(api::server::recover_passkey_account), 285 282 ) 286 283 .route( 287 - "/xrpc/com.tranquil.account.getMigrationStatus", 288 - get(api::server::get_migration_status), 289 - ) 290 - .route( 291 - "/xrpc/com.tranquil.account.updateMigrationForwarding", 292 - post(api::server::update_migration_forwarding), 293 - ) 294 - .route( 295 - "/xrpc/com.tranquil.account.clearMigrationForwarding", 296 - post(api::server::clear_migration_forwarding), 297 - ) 298 - .route( 299 - "/xrpc/com.tranquil.account.updateDidDocument", 284 + "/xrpc/_account.updateDidDocument", 300 285 post(api::server::update_did_document), 301 286 ) 302 287 .route( 303 - "/xrpc/com.tranquil.account.getDidDocument", 288 + "/xrpc/_account.getDidDocument", 304 289 get(api::server::get_did_document), 305 290 ) 306 291 .route( 307 292 "/xrpc/com.atproto.server.requestEmailUpdate", 308 293 post(api::server::request_email_update), 294 + ) 295 + .route( 296 + "/xrpc/_checkEmailVerified", 297 + post(api::server::check_email_verified), 309 298 ) 310 299 .route( 311 300 "/xrpc/com.atproto.server.confirmEmail", ··· 432 421 get(api::admin::get_invite_codes), 433 422 ) 434 423 .route( 435 - "/xrpc/com.tranquil.admin.getServerStats", 424 + "/xrpc/_admin.getServerStats", 436 425 get(api::admin::get_server_stats), 437 426 ) 438 427 .route( 439 - "/xrpc/com.tranquil.server.getConfig", 428 + "/xrpc/_server.getConfig", 440 429 get(api::admin::get_server_config), 441 430 ) 442 431 .route( 443 - "/xrpc/com.tranquil.admin.updateServerConfig", 432 + "/xrpc/_admin.updateServerConfig", 444 433 post(api::admin::update_server_config), 445 434 ) 446 435 .route( ··· 575 564 post(api::temp::dereference_scope), 576 565 ) 577 566 .route( 578 - "/xrpc/com.tranquil.account.getNotificationPrefs", 567 + "/xrpc/_account.getNotificationPrefs", 579 568 get(api::notification_prefs::get_notification_prefs), 580 569 ) 581 570 .route( 582 - "/xrpc/com.tranquil.account.updateNotificationPrefs", 571 + "/xrpc/_account.updateNotificationPrefs", 583 572 post(api::notification_prefs::update_notification_prefs), 584 573 ) 585 574 .route( 586 - "/xrpc/com.tranquil.account.getNotificationHistory", 575 + "/xrpc/_account.getNotificationHistory", 587 576 get(api::notification_prefs::get_notification_history), 588 577 ) 589 578 .route( 590 - "/xrpc/com.tranquil.account.confirmChannelVerification", 579 + "/xrpc/_account.confirmChannelVerification", 591 580 post(api::verification::confirm_channel_verification), 592 581 ) 593 582 .route( 594 - "/xrpc/com.tranquil.account.verifyToken", 583 + "/xrpc/_account.verifyToken", 595 584 post(api::server::verify_token), 596 585 ) 597 586 .route( 598 - "/xrpc/com.tranquil.delegation.listControllers", 587 + "/xrpc/_delegation.listControllers", 599 588 get(api::delegation::list_controllers), 600 589 ) 601 590 .route( 602 - "/xrpc/com.tranquil.delegation.addController", 591 + "/xrpc/_delegation.addController", 603 592 post(api::delegation::add_controller), 604 593 ) 605 594 .route( 606 - "/xrpc/com.tranquil.delegation.removeController", 595 + "/xrpc/_delegation.removeController", 607 596 post(api::delegation::remove_controller), 608 597 ) 609 598 .route( 610 - "/xrpc/com.tranquil.delegation.updateControllerScopes", 599 + "/xrpc/_delegation.updateControllerScopes", 611 600 post(api::delegation::update_controller_scopes), 612 601 ) 613 602 .route( 614 - "/xrpc/com.tranquil.delegation.listControlledAccounts", 603 + "/xrpc/_delegation.listControlledAccounts", 615 604 get(api::delegation::list_controlled_accounts), 616 605 ) 617 606 .route( 618 - "/xrpc/com.tranquil.delegation.getAuditLog", 607 + "/xrpc/_delegation.getAuditLog", 619 608 get(api::delegation::get_audit_log), 620 609 ) 621 610 .route( 622 - "/xrpc/com.tranquil.delegation.getScopePresets", 611 + "/xrpc/_delegation.getScopePresets", 623 612 get(api::delegation::get_scope_presets), 624 613 ) 625 614 .route( 626 - "/xrpc/com.tranquil.delegation.createDelegatedAccount", 615 + "/xrpc/_delegation.createDelegatedAccount", 627 616 post(api::delegation::create_delegated_account), 628 617 ) 618 + .route("/xrpc/_backup.listBackups", get(api::backup::list_backups)) 619 + .route("/xrpc/_backup.getBackup", get(api::backup::get_backup)) 620 + .route( 621 + "/xrpc/_backup.createBackup", 622 + post(api::backup::create_backup), 623 + ) 624 + .route( 625 + "/xrpc/_backup.deleteBackup", 626 + post(api::backup::delete_backup), 627 + ) 628 + .route( 629 + "/xrpc/_backup.setEnabled", 630 + post(api::backup::set_backup_enabled), 631 + ) 632 + .route("/xrpc/_backup.exportBlobs", get(api::backup::export_blobs)) 629 633 .route( 630 634 "/xrpc/app.bsky.ageassurance.getState", 631 635 get(api::age_assurance::get_state),
+18 -1
src/main.rs
··· 7 7 use tranquil_pds::crawlers::{Crawlers, start_crawlers_service}; 8 8 use tranquil_pds::scheduled::{ 9 9 backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks, 10 - start_scheduled_tasks, 10 + start_backup_tasks, start_scheduled_tasks, 11 11 }; 12 12 use tranquil_pds::state::AppState; 13 13 ··· 83 83 None 84 84 }; 85 85 86 + let backup_handle = if let Some(backup_storage) = state.backup_storage.clone() { 87 + info!("Backup service enabled"); 88 + Some(tokio::spawn(start_backup_tasks( 89 + state.db.clone(), 90 + state.block_store.clone(), 91 + backup_storage, 92 + shutdown_rx.clone(), 93 + ))) 94 + } else { 95 + warn!("Backup service disabled (BACKUP_S3_BUCKET not set or BACKUP_ENABLED=false)"); 96 + None 97 + }; 98 + 86 99 let scheduled_handle = tokio::spawn(start_scheduled_tasks( 87 100 state.db.clone(), 88 101 state.blob_store.clone(), ··· 114 127 comms_handle.await.ok(); 115 128 116 129 if let Some(handle) = crawlers_handle { 130 + handle.await.ok(); 131 + } 132 + 133 + if let Some(handle) = backup_handle { 117 134 handle.await.ok(); 118 135 } 119 136
+4
src/rate_limit.rs
··· 32 32 pub totp_verify: Arc<KeyedRateLimiter>, 33 33 pub handle_update: Arc<KeyedRateLimiter>, 34 34 pub handle_update_daily: Arc<KeyedRateLimiter>, 35 + pub verification_check: Arc<KeyedRateLimiter>, 35 36 } 36 37 37 38 impl Default for RateLimiters { ··· 91 92 .unwrap() 92 93 .allow_burst(NonZeroU32::new(50).unwrap()), 93 94 )), 95 + verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute( 96 + NonZeroU32::new(60).unwrap(), 97 + ))), 94 98 } 95 99 } 96 100
+311 -1
src/scheduled.rs
··· 11 11 use tracing::{debug, error, info, warn}; 12 12 13 13 use crate::repo::PostgresBlockStore; 14 - use crate::storage::BlobStorage; 14 + use crate::storage::{BackupStorage, BlobStorage}; 15 + use crate::sync::car::encode_car_header; 15 16 16 17 pub async fn backfill_genesis_commit_blocks(db: &PgPool, block_store: PostgresBlockStore) { 17 18 let broken_genesis_commits = match sqlx::query!( ··· 563 564 564 565 Ok(()) 565 566 } 567 + 568 + pub async fn start_backup_tasks( 569 + db: PgPool, 570 + block_store: PostgresBlockStore, 571 + backup_storage: Arc<BackupStorage>, 572 + mut shutdown_rx: watch::Receiver<bool>, 573 + ) { 574 + let backup_interval = Duration::from_secs(BackupStorage::interval_secs()); 575 + 576 + info!( 577 + interval_secs = backup_interval.as_secs(), 578 + retention_count = BackupStorage::retention_count(), 579 + "Starting backup service" 580 + ); 581 + 582 + let mut ticker = interval(backup_interval); 583 + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); 584 + 585 + loop { 586 + tokio::select! { 587 + _ = shutdown_rx.changed() => { 588 + if *shutdown_rx.borrow() { 589 + info!("Backup service shutting down"); 590 + break; 591 + } 592 + } 593 + _ = ticker.tick() => { 594 + if let Err(e) = process_scheduled_backups(&db, &block_store, &backup_storage).await { 595 + error!("Error processing scheduled backups: {}", e); 596 + } 597 + } 598 + } 599 + } 600 + } 601 + 602 + async fn process_scheduled_backups( 603 + db: &PgPool, 604 + block_store: &PostgresBlockStore, 605 + backup_storage: &BackupStorage, 606 + ) -> Result<(), String> { 607 + let backup_interval_secs = BackupStorage::interval_secs() as i64; 608 + let retention_count = BackupStorage::retention_count(); 609 + 610 + let users_needing_backup = sqlx::query!( 611 + r#" 612 + SELECT u.id as user_id, u.did, r.repo_root_cid, r.repo_rev 613 + FROM users u 614 + JOIN repos r ON r.user_id = u.id 615 + WHERE u.backup_enabled = true 616 + AND u.deactivated_at IS NULL 617 + AND ( 618 + NOT EXISTS ( 619 + SELECT 1 FROM account_backups ab WHERE ab.user_id = u.id 620 + ) 621 + OR ( 622 + SELECT MAX(ab.created_at) FROM account_backups ab WHERE ab.user_id = u.id 623 + ) < NOW() - make_interval(secs => $1) 624 + ) 625 + LIMIT 50 626 + "#, 627 + backup_interval_secs as f64 628 + ) 629 + .fetch_all(db) 630 + .await 631 + .map_err(|e| format!("DB error fetching users for backup: {}", e))?; 632 + 633 + if users_needing_backup.is_empty() { 634 + debug!("No accounts need backup"); 635 + return Ok(()); 636 + } 637 + 638 + info!( 639 + count = users_needing_backup.len(), 640 + "Processing scheduled backups" 641 + ); 642 + 643 + for user in users_needing_backup { 644 + let repo_root_cid = user.repo_root_cid.clone(); 645 + 646 + let repo_rev = match &user.repo_rev { 647 + Some(rev) => rev.clone(), 648 + None => { 649 + warn!(did = %user.did, "User has no repo_rev, skipping backup"); 650 + continue; 651 + } 652 + }; 653 + 654 + let head_cid = match Cid::from_str(&repo_root_cid) { 655 + Ok(c) => c, 656 + Err(e) => { 657 + warn!(did = %user.did, error = %e, "Invalid repo_root_cid, skipping backup"); 658 + continue; 659 + } 660 + }; 661 + 662 + let car_result = generate_full_backup(block_store, &head_cid).await; 663 + let car_bytes = match car_result { 664 + Ok(bytes) => bytes, 665 + Err(e) => { 666 + warn!(did = %user.did, error = %e, "Failed to generate CAR for backup"); 667 + continue; 668 + } 669 + }; 670 + 671 + let block_count = count_car_blocks(&car_bytes); 672 + let size_bytes = car_bytes.len() as i64; 673 + 674 + let storage_key = match backup_storage 675 + .put_backup(&user.did, &repo_rev, &car_bytes) 676 + .await 677 + { 678 + Ok(key) => key, 679 + Err(e) => { 680 + warn!(did = %user.did, error = %e, "Failed to upload backup to storage"); 681 + continue; 682 + } 683 + }; 684 + 685 + if let Err(e) = sqlx::query!( 686 + r#" 687 + INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes) 688 + VALUES ($1, $2, $3, $4, $5, $6) 689 + "#, 690 + user.user_id, 691 + storage_key, 692 + repo_root_cid, 693 + repo_rev, 694 + block_count, 695 + size_bytes 696 + ) 697 + .execute(db) 698 + .await 699 + { 700 + warn!(did = %user.did, error = %e, "Failed to insert backup record, rolling back S3 upload"); 701 + if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await { 702 + error!( 703 + did = %user.did, 704 + storage_key = %storage_key, 705 + error = %rollback_err, 706 + "Failed to rollback orphaned backup from S3" 707 + ); 708 + } 709 + continue; 710 + } 711 + 712 + info!( 713 + did = %user.did, 714 + rev = %repo_rev, 715 + size_bytes, 716 + block_count, 717 + "Created backup" 718 + ); 719 + 720 + if let Err(e) = cleanup_old_backups(db, backup_storage, user.user_id, retention_count).await 721 + { 722 + warn!(did = %user.did, error = %e, "Failed to cleanup old backups"); 723 + } 724 + } 725 + 726 + Ok(()) 727 + } 728 + 729 + pub async fn generate_repo_car( 730 + block_store: &PostgresBlockStore, 731 + head_cid: &Cid, 732 + ) -> Result<Vec<u8>, String> { 733 + use jacquard_repo::storage::BlockStore; 734 + use std::io::Write; 735 + 736 + let mut car_bytes = 737 + encode_car_header(head_cid).map_err(|e| format!("Failed to encode CAR header: {}", e))?; 738 + 739 + let mut stack = vec![*head_cid]; 740 + let mut visited = std::collections::HashSet::new(); 741 + 742 + while let Some(cid) = stack.pop() { 743 + if visited.contains(&cid) { 744 + continue; 745 + } 746 + visited.insert(cid); 747 + 748 + if let Ok(Some(block)) = block_store.get(&cid).await { 749 + let cid_bytes = cid.to_bytes(); 750 + let total_len = cid_bytes.len() + block.len(); 751 + let mut writer = Vec::new(); 752 + crate::sync::car::write_varint(&mut writer, total_len as u64) 753 + .expect("Writing to Vec<u8> should never fail"); 754 + writer 755 + .write_all(&cid_bytes) 756 + .expect("Writing to Vec<u8> should never fail"); 757 + writer 758 + .write_all(&block) 759 + .expect("Writing to Vec<u8> should never fail"); 760 + car_bytes.extend_from_slice(&writer); 761 + 762 + if let Ok(value) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) { 763 + extract_links(&value, &mut stack); 764 + } 765 + } 766 + } 767 + 768 + Ok(car_bytes) 769 + } 770 + 771 + pub async fn generate_full_backup( 772 + block_store: &PostgresBlockStore, 773 + head_cid: &Cid, 774 + ) -> Result<Vec<u8>, String> { 775 + generate_repo_car(block_store, head_cid).await 776 + } 777 + 778 + fn extract_links(value: &Ipld, stack: &mut Vec<Cid>) { 779 + match value { 780 + Ipld::Link(cid) => { 781 + stack.push(*cid); 782 + } 783 + Ipld::Map(map) => { 784 + for v in map.values() { 785 + extract_links(v, stack); 786 + } 787 + } 788 + Ipld::List(arr) => { 789 + for v in arr { 790 + extract_links(v, stack); 791 + } 792 + } 793 + _ => {} 794 + } 795 + } 796 + 797 + pub fn count_car_blocks(car_bytes: &[u8]) -> i32 { 798 + let mut count = 0; 799 + let mut pos = 0; 800 + 801 + if let Some((header_len, header_varint_len)) = read_varint(&car_bytes[pos..]) { 802 + pos += header_varint_len + header_len as usize; 803 + } else { 804 + return 0; 805 + } 806 + 807 + while pos < car_bytes.len() { 808 + if let Some((block_len, varint_len)) = read_varint(&car_bytes[pos..]) { 809 + pos += varint_len + block_len as usize; 810 + count += 1; 811 + } else { 812 + break; 813 + } 814 + } 815 + 816 + count 817 + } 818 + 819 + fn read_varint(data: &[u8]) -> Option<(u64, usize)> { 820 + let mut value: u64 = 0; 821 + let mut shift = 0; 822 + let mut pos = 0; 823 + 824 + while pos < data.len() && pos < 10 { 825 + let byte = data[pos]; 826 + value |= ((byte & 0x7f) as u64) << shift; 827 + pos += 1; 828 + if byte & 0x80 == 0 { 829 + return Some((value, pos)); 830 + } 831 + shift += 7; 832 + } 833 + 834 + None 835 + } 836 + 837 + async fn cleanup_old_backups( 838 + db: &PgPool, 839 + backup_storage: &BackupStorage, 840 + user_id: uuid::Uuid, 841 + retention_count: u32, 842 + ) -> Result<(), String> { 843 + let old_backups = sqlx::query!( 844 + r#" 845 + SELECT id, storage_key 846 + FROM account_backups 847 + WHERE user_id = $1 848 + ORDER BY created_at DESC 849 + OFFSET $2 850 + "#, 851 + user_id, 852 + retention_count as i64 853 + ) 854 + .fetch_all(db) 855 + .await 856 + .map_err(|e| format!("DB error fetching old backups: {}", e))?; 857 + 858 + for backup in old_backups { 859 + if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await { 860 + warn!( 861 + storage_key = %backup.storage_key, 862 + error = %e, 863 + "Failed to delete old backup from storage, skipping DB cleanup to avoid orphan" 864 + ); 865 + continue; 866 + } 867 + 868 + sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id) 869 + .execute(db) 870 + .await 871 + .map_err(|e| format!("Failed to delete old backup record: {}", e))?; 872 + } 873 + 874 + Ok(()) 875 + }
+8 -1
src/state.rs
··· 4 4 use crate::config::AuthConfig; 5 5 use crate::rate_limit::RateLimiters; 6 6 use crate::repo::PostgresBlockStore; 7 - use crate::storage::{BlobStorage, S3BlobStorage}; 7 + use crate::storage::{BackupStorage, BlobStorage, S3BlobStorage}; 8 8 use crate::sync::firehose::SequencedEvent; 9 9 use sqlx::PgPool; 10 10 use std::error::Error; ··· 16 16 pub db: PgPool, 17 17 pub block_store: PostgresBlockStore, 18 18 pub blob_store: Arc<dyn BlobStorage>, 19 + pub backup_storage: Option<Arc<BackupStorage>>, 19 20 pub firehose_tx: broadcast::Sender<SequencedEvent>, 20 21 pub rate_limiters: Arc<RateLimiters>, 21 22 pub circuit_breakers: Arc<CircuitBreakers>, ··· 39 40 TotpVerify, 40 41 HandleUpdate, 41 42 HandleUpdateDaily, 43 + VerificationCheck, 42 44 } 43 45 44 46 impl RateLimitKind { ··· 58 60 Self::TotpVerify => "totp_verify", 59 61 Self::HandleUpdate => "handle_update", 60 62 Self::HandleUpdateDaily => "handle_update_daily", 63 + Self::VerificationCheck => "verification_check", 61 64 } 62 65 } 63 66 ··· 77 80 Self::TotpVerify => (5, 300_000), 78 81 Self::HandleUpdate => (10, 300_000), 79 82 Self::HandleUpdateDaily => (50, 86_400_000), 83 + Self::VerificationCheck => (60, 60_000), 80 84 } 81 85 } 82 86 } ··· 131 135 132 136 let block_store = PostgresBlockStore::new(db.clone()); 133 137 let blob_store = S3BlobStorage::new().await; 138 + let backup_storage = BackupStorage::new().await.map(Arc::new); 134 139 135 140 let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE") 136 141 .ok() ··· 147 152 db, 148 153 block_store, 149 154 blob_store: Arc::new(blob_store), 155 + backup_storage, 150 156 firehose_tx, 151 157 rate_limiters, 152 158 circuit_breakers, ··· 199 205 RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify, 200 206 RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update, 201 207 RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily, 208 + RateLimitKind::VerificationCheck => &self.rate_limiters.verification_check, 202 209 }; 203 210 204 211 let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+119 -16
src/storage/mod.rs
··· 32 32 33 33 impl S3BlobStorage { 34 34 pub async fn new() -> Self { 35 - let region_provider = RegionProviderChain::default_provider().or_else("us-east-1"); 35 + let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set"); 36 + let client = create_s3_client().await; 37 + Self { client, bucket } 38 + } 39 + } 40 + 41 + async fn create_s3_client() -> Client { 42 + let region_provider = RegionProviderChain::default_provider().or_else("us-east-1"); 43 + 44 + let config = aws_config::defaults(BehaviorVersion::latest()) 45 + .region(region_provider) 46 + .load() 47 + .await; 48 + 49 + if let Ok(endpoint) = std::env::var("S3_ENDPOINT") { 50 + let s3_config = aws_sdk_s3::config::Builder::from(&config) 51 + .endpoint_url(endpoint) 52 + .force_path_style(true) 53 + .build(); 54 + Client::from_conf(s3_config) 55 + } else { 56 + Client::new(&config) 57 + } 58 + } 59 + 60 + pub struct BackupStorage { 61 + client: Client, 62 + bucket: String, 63 + } 64 + 65 + impl BackupStorage { 66 + pub async fn new() -> Option<Self> { 67 + let backup_enabled = std::env::var("BACKUP_ENABLED") 68 + .map(|v| v != "false" && v != "0") 69 + .unwrap_or(true); 70 + 71 + if !backup_enabled { 72 + return None; 73 + } 74 + 75 + let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?; 76 + let client = create_s3_client().await; 77 + Some(Self { client, bucket }) 78 + } 79 + 80 + pub fn retention_count() -> u32 { 81 + std::env::var("BACKUP_RETENTION_COUNT") 82 + .ok() 83 + .and_then(|v| v.parse().ok()) 84 + .unwrap_or(7) 85 + } 86 + 87 + pub fn interval_secs() -> u64 { 88 + std::env::var("BACKUP_INTERVAL_SECS") 89 + .ok() 90 + .and_then(|v| v.parse().ok()) 91 + .unwrap_or(86400) 92 + } 93 + 94 + pub async fn put_backup( 95 + &self, 96 + did: &str, 97 + rev: &str, 98 + data: &[u8], 99 + ) -> Result<String, StorageError> { 100 + let key = format!("{}/{}.car", did, rev); 101 + self.client 102 + .put_object() 103 + .bucket(&self.bucket) 104 + .key(&key) 105 + .body(ByteStream::from(Bytes::copy_from_slice(data))) 106 + .send() 107 + .await 108 + .map_err(|e| { 109 + crate::metrics::record_s3_operation("backup_put", "error"); 110 + StorageError::S3(e.to_string()) 111 + })?; 36 112 37 - let config = aws_config::defaults(BehaviorVersion::latest()) 38 - .region(region_provider) 39 - .load() 40 - .await; 113 + crate::metrics::record_s3_operation("backup_put", "success"); 114 + Ok(key) 115 + } 116 + 117 + pub async fn get_backup(&self, storage_key: &str) -> Result<Bytes, StorageError> { 118 + let resp = self 119 + .client 120 + .get_object() 121 + .bucket(&self.bucket) 122 + .key(storage_key) 123 + .send() 124 + .await 125 + .map_err(|e| { 126 + crate::metrics::record_s3_operation("backup_get", "error"); 127 + StorageError::S3(e.to_string()) 128 + })?; 129 + 130 + let data = resp 131 + .body 132 + .collect() 133 + .await 134 + .map_err(|e| { 135 + crate::metrics::record_s3_operation("backup_get", "error"); 136 + StorageError::S3(e.to_string()) 137 + })? 138 + .into_bytes(); 41 139 42 - let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set"); 140 + crate::metrics::record_s3_operation("backup_get", "success"); 141 + Ok(data) 142 + } 43 143 44 - let client = if let Ok(endpoint) = std::env::var("S3_ENDPOINT") { 45 - let s3_config = aws_sdk_s3::config::Builder::from(&config) 46 - .endpoint_url(endpoint) 47 - .force_path_style(true) 48 - .build(); 49 - Client::from_conf(s3_config) 50 - } else { 51 - Client::new(&config) 52 - }; 144 + pub async fn delete_backup(&self, storage_key: &str) -> Result<(), StorageError> { 145 + self.client 146 + .delete_object() 147 + .bucket(&self.bucket) 148 + .key(storage_key) 149 + .send() 150 + .await 151 + .map_err(|e| { 152 + crate::metrics::record_s3_operation("backup_delete", "error"); 153 + StorageError::S3(e.to_string()) 154 + })?; 53 155 54 - Self { client, bucket } 156 + crate::metrics::record_s3_operation("backup_delete", "success"); 157 + Ok(()) 55 158 } 56 159 } 57 160
+23 -12
src/sync/import.rs
··· 77 77 Ipld::Map(obj) => { 78 78 if let Some(Ipld::String(type_str)) = obj.get("$type") 79 79 && type_str == "blob" 80 - && let Some(Ipld::Link(link_cid)) = obj.get("ref") 81 80 { 82 - let mime = obj.get("mimeType").and_then(|v| { 83 - if let Ipld::String(s) = v { 84 - Some(s.clone()) 85 - } else { 86 - None 87 - } 88 - }); 89 - return vec![BlobRef { 90 - cid: link_cid.to_string(), 91 - mime_type: mime, 92 - }]; 81 + let cid_str = if let Some(Ipld::Link(link_cid)) = obj.get("ref") { 82 + Some(link_cid.to_string()) 83 + } else if let Some(Ipld::Map(ref_obj)) = obj.get("ref") 84 + && let Some(Ipld::String(link)) = ref_obj.get("$link") 85 + { 86 + Some(link.clone()) 87 + } else { 88 + None 89 + }; 90 + 91 + if let Some(cid) = cid_str { 92 + let mime = obj.get("mimeType").and_then(|v| { 93 + if let Ipld::String(s) = v { 94 + Some(s.clone()) 95 + } else { 96 + None 97 + } 98 + }); 99 + return vec![BlobRef { 100 + cid, 101 + mime_type: mime, 102 + }]; 103 + } 93 104 } 94 105 obj.values() 95 106 .flat_map(|v| find_blob_refs_ipld(v, depth + 1))
+129
src/util.rs
··· 1 1 use axum::http::HeaderMap; 2 + use cid::Cid; 3 + use ipld_core::ipld::Ipld; 2 4 use rand::Rng; 5 + use serde_json::Value as JsonValue; 3 6 use sqlx::PgPool; 7 + use std::collections::BTreeMap; 8 + use std::str::FromStr; 4 9 use std::sync::OnceLock; 5 10 use uuid::Uuid; 6 11 ··· 150 155 format!("{}{}", pds_public_url(), path) 151 156 } 152 157 158 + pub fn json_to_ipld(value: &JsonValue) -> Ipld { 159 + match value { 160 + JsonValue::Null => Ipld::Null, 161 + JsonValue::Bool(b) => Ipld::Bool(*b), 162 + JsonValue::Number(n) => { 163 + if let Some(i) = n.as_i64() { 164 + Ipld::Integer(i as i128) 165 + } else if let Some(f) = n.as_f64() { 166 + Ipld::Float(f) 167 + } else { 168 + Ipld::Null 169 + } 170 + } 171 + JsonValue::String(s) => Ipld::String(s.clone()), 172 + JsonValue::Array(arr) => Ipld::List(arr.iter().map(json_to_ipld).collect()), 173 + JsonValue::Object(obj) => { 174 + if let Some(JsonValue::String(link)) = obj.get("$link") 175 + && obj.len() == 1 176 + && let Ok(cid) = Cid::from_str(link) 177 + { 178 + return Ipld::Link(cid); 179 + } 180 + let map: BTreeMap<String, Ipld> = obj 181 + .iter() 182 + .map(|(k, v)| (k.clone(), json_to_ipld(v))) 183 + .collect(); 184 + Ipld::Map(map) 185 + } 186 + } 187 + } 188 + 153 189 #[cfg(test)] 154 190 mod tests { 155 191 use super::*; ··· 223 259 for part in parts { 224 260 assert_eq!(part.len(), 4); 225 261 } 262 + } 263 + 264 + #[test] 265 + fn test_json_to_ipld_cid_link() { 266 + let json = serde_json::json!({ 267 + "$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 268 + }); 269 + let ipld = json_to_ipld(&json); 270 + match ipld { 271 + Ipld::Link(cid) => { 272 + assert_eq!( 273 + cid.to_string(), 274 + "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 275 + ); 276 + } 277 + _ => panic!("Expected Ipld::Link, got {:?}", ipld), 278 + } 279 + } 280 + 281 + #[test] 282 + fn test_json_to_ipld_blob_ref() { 283 + let json = serde_json::json!({ 284 + "$type": "blob", 285 + "ref": { 286 + "$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 287 + }, 288 + "mimeType": "image/jpeg", 289 + "size": 12345 290 + }); 291 + let ipld = json_to_ipld(&json); 292 + match ipld { 293 + Ipld::Map(map) => { 294 + assert_eq!(map.get("$type"), Some(&Ipld::String("blob".to_string()))); 295 + assert_eq!( 296 + map.get("mimeType"), 297 + Some(&Ipld::String("image/jpeg".to_string())) 298 + ); 299 + assert_eq!(map.get("size"), Some(&Ipld::Integer(12345))); 300 + match map.get("ref") { 301 + Some(Ipld::Link(cid)) => { 302 + assert_eq!( 303 + cid.to_string(), 304 + "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 305 + ); 306 + } 307 + _ => panic!("Expected Ipld::Link in ref field, got {:?}", map.get("ref")), 308 + } 309 + } 310 + _ => panic!("Expected Ipld::Map, got {:?}", ipld), 311 + } 312 + } 313 + 314 + #[test] 315 + fn test_json_to_ipld_nested_blob_refs_serializes_correctly() { 316 + let record = serde_json::json!({ 317 + "$type": "app.bsky.feed.post", 318 + "text": "Hello world", 319 + "embed": { 320 + "$type": "app.bsky.embed.images", 321 + "images": [ 322 + { 323 + "alt": "Test image", 324 + "image": { 325 + "$type": "blob", 326 + "ref": { 327 + "$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 328 + }, 329 + "mimeType": "image/jpeg", 330 + "size": 12345 331 + } 332 + } 333 + ] 334 + } 335 + }); 336 + let ipld = json_to_ipld(&record); 337 + let cbor_bytes = serde_ipld_dagcbor::to_vec(&ipld).expect("CBOR serialization failed"); 338 + assert!(!cbor_bytes.is_empty()); 339 + let parsed: Ipld = 340 + serde_ipld_dagcbor::from_slice(&cbor_bytes).expect("CBOR deserialization failed"); 341 + if let Ipld::Map(map) = &parsed 342 + && let Some(Ipld::Map(embed)) = map.get("embed") 343 + && let Some(Ipld::List(images)) = embed.get("images") 344 + && let Some(Ipld::Map(img)) = images.first() 345 + && let Some(Ipld::Map(blob)) = img.get("image") 346 + && let Some(Ipld::Link(cid)) = blob.get("ref") 347 + { 348 + assert_eq!( 349 + cid.to_string(), 350 + "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku" 351 + ); 352 + return; 353 + } 354 + panic!("Failed to find CID link in parsed CBOR"); 226 355 } 227 356 }
+10 -40
tests/account_notifications.rs
··· 27 27 } 28 28 29 29 let resp = client 30 - .get(format!( 31 - "{}/xrpc/com.tranquil.account.getNotificationHistory", 32 - base 33 - )) 30 + .get(format!("{}/xrpc/_account.getNotificationHistory", base)) 34 31 .header("Authorization", format!("Bearer {}", token)) 35 32 .send() 36 33 .await ··· 56 53 "discordId": "123456789" 57 54 }); 58 55 let resp = client 59 - .post(format!( 60 - "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 61 - base 62 - )) 56 + .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 63 57 .header("Authorization", format!("Bearer {}", token)) 64 58 .json(&prefs) 65 59 .send() ··· 101 95 "code": code 102 96 }); 103 97 let resp = client 104 - .post(format!( 105 - "{}/xrpc/com.tranquil.account.confirmChannelVerification", 106 - base 107 - )) 98 + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 108 99 .header("Authorization", format!("Bearer {}", token)) 109 100 .json(&input) 110 101 .send() ··· 113 104 assert_eq!(resp.status(), 200); 114 105 115 106 let resp = client 116 - .get(format!( 117 - "{}/xrpc/com.tranquil.account.getNotificationPrefs", 118 - base 119 - )) 107 + .get(format!("{}/xrpc/_account.getNotificationPrefs", base)) 120 108 .header("Authorization", format!("Bearer {}", token)) 121 109 .send() 122 110 .await ··· 136 124 "telegramUsername": "testuser" 137 125 }); 138 126 let resp = client 139 - .post(format!( 140 - "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 141 - base 142 - )) 127 + .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 143 128 .header("Authorization", format!("Bearer {}", token)) 144 129 .json(&prefs) 145 130 .send() ··· 153 138 "code": "XXXX-XXXX-XXXX-XXXX" 154 139 }); 155 140 let resp = client 156 - .post(format!( 157 - "{}/xrpc/com.tranquil.account.confirmChannelVerification", 158 - base 159 - )) 141 + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 160 142 .header("Authorization", format!("Bearer {}", token)) 161 143 .json(&input) 162 144 .send() ··· 181 163 "code": "XXXX-XXXX-XXXX-XXXX" 182 164 }); 183 165 let resp = client 184 - .post(format!( 185 - "{}/xrpc/com.tranquil.account.confirmChannelVerification", 186 - base 187 - )) 166 + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 188 167 .header("Authorization", format!("Bearer {}", token)) 189 168 .json(&input) 190 169 .send() ··· 209 188 "email": unique_email 210 189 }); 211 190 let resp = client 212 - .post(format!( 213 - "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 214 - base 215 - )) 191 + .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 216 192 .header("Authorization", format!("Bearer {}", token)) 217 193 .json(&prefs) 218 194 .send() ··· 263 239 "code": code 264 240 }); 265 241 let resp = client 266 - .post(format!( 267 - "{}/xrpc/com.tranquil.account.confirmChannelVerification", 268 - base 269 - )) 242 + .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 270 243 .header("Authorization", format!("Bearer {}", token)) 271 244 .json(&input) 272 245 .send() ··· 275 248 assert_eq!(resp.status(), 200); 276 249 277 250 let resp = client 278 - .get(format!( 279 - "{}/xrpc/com.tranquil.account.getNotificationPrefs", 280 - base 281 - )) 251 + .get(format!("{}/xrpc/_account.getNotificationPrefs", base)) 282 252 .header("Authorization", format!("Bearer {}", token)) 283 253 .send() 284 254 .await
+2 -2
tests/admin_stats.rs
··· 11 11 let (_, _) = create_admin_account_and_login(&client).await; 12 12 13 13 let resp = client 14 - .get(format!("{}/xrpc/com.tranquil.admin.getServerStats", base)) 14 + .get(format!("{}/xrpc/_admin.getServerStats", base)) 15 15 .header("Authorization", format!("Bearer {}", token1)) 16 16 .send() 17 17 .await ··· 33 33 let client = client(); 34 34 let base = base_url().await; 35 35 let resp = client 36 - .get(format!("{}/xrpc/com.tranquil.admin.getServerStats", base)) 36 + .get(format!("{}/xrpc/_admin.getServerStats", base)) 37 37 .send() 38 38 .await 39 39 .unwrap();
+325
tests/backup.rs
··· 1 + mod common; 2 + mod helpers; 3 + 4 + use common::*; 5 + use reqwest::{StatusCode, header}; 6 + use serde_json::{Value, json}; 7 + 8 + #[tokio::test] 9 + async fn test_list_backups_empty() { 10 + let client = client(); 11 + let (token, _did) = create_account_and_login(&client).await; 12 + 13 + let res = client 14 + .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 15 + .bearer_auth(&token) 16 + .send() 17 + .await 18 + .expect("listBackups request failed"); 19 + 20 + assert_eq!(res.status(), StatusCode::OK); 21 + let body: Value = res.json().await.expect("Invalid JSON"); 22 + assert!(body["backups"].is_array()); 23 + assert_eq!(body["backups"].as_array().unwrap().len(), 0); 24 + assert!(body["backupEnabled"].as_bool().unwrap_or(false)); 25 + } 26 + 27 + #[tokio::test] 28 + async fn test_create_and_list_backup() { 29 + let client = client(); 30 + let (token, _did) = create_account_and_login(&client).await; 31 + 32 + let create_res = client 33 + .post(format!("{}/xrpc/_backup.createBackup", base_url().await)) 34 + .bearer_auth(&token) 35 + .send() 36 + .await 37 + .expect("createBackup request failed"); 38 + 39 + assert_eq!(create_res.status(), StatusCode::OK, "createBackup failed"); 40 + let create_body: Value = create_res.json().await.expect("Invalid JSON"); 41 + assert!(create_body["id"].is_string()); 42 + assert!(create_body["repoRev"].is_string()); 43 + assert!(create_body["sizeBytes"].is_i64()); 44 + assert!(create_body["blockCount"].is_i64()); 45 + 46 + let list_res = client 47 + .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 48 + .bearer_auth(&token) 49 + .send() 50 + .await 51 + .expect("listBackups request failed"); 52 + 53 + assert_eq!(list_res.status(), StatusCode::OK); 54 + let list_body: Value = list_res.json().await.expect("Invalid JSON"); 55 + let backups = list_body["backups"].as_array().unwrap(); 56 + assert!(backups.len() >= 1); 57 + } 58 + 59 + #[tokio::test] 60 + async fn test_download_backup() { 61 + let client = client(); 62 + let (token, _did) = create_account_and_login(&client).await; 63 + 64 + let create_res = client 65 + .post(format!("{}/xrpc/_backup.createBackup", base_url().await)) 66 + .bearer_auth(&token) 67 + .send() 68 + .await 69 + .expect("createBackup request failed"); 70 + 71 + assert_eq!(create_res.status(), StatusCode::OK); 72 + let create_body: Value = create_res.json().await.expect("Invalid JSON"); 73 + let backup_id = create_body["id"].as_str().unwrap(); 74 + 75 + let get_res = client 76 + .get(format!( 77 + "{}/xrpc/_backup.getBackup?id={}", 78 + base_url().await, 79 + backup_id 80 + )) 81 + .bearer_auth(&token) 82 + .send() 83 + .await 84 + .expect("getBackup request failed"); 85 + 86 + assert_eq!(get_res.status(), StatusCode::OK); 87 + let content_type = get_res.headers().get(header::CONTENT_TYPE).unwrap(); 88 + assert_eq!(content_type, "application/vnd.ipld.car"); 89 + 90 + let bytes = get_res.bytes().await.expect("Failed to read body"); 91 + assert!(bytes.len() > 100, "CAR file should have content"); 92 + assert_eq!( 93 + bytes[1], 0xa2, 94 + "CAR file should have valid header structure" 95 + ); 96 + } 97 + 98 + #[tokio::test] 99 + async fn test_delete_backup() { 100 + let client = client(); 101 + let (token, _did) = create_account_and_login(&client).await; 102 + 103 + let create_res = client 104 + .post(format!("{}/xrpc/_backup.createBackup", base_url().await)) 105 + .bearer_auth(&token) 106 + .send() 107 + .await 108 + .expect("createBackup request failed"); 109 + 110 + assert_eq!(create_res.status(), StatusCode::OK); 111 + let create_body: Value = create_res.json().await.expect("Invalid JSON"); 112 + let backup_id = create_body["id"].as_str().unwrap(); 113 + 114 + let delete_res = client 115 + .post(format!( 116 + "{}/xrpc/_backup.deleteBackup?id={}", 117 + base_url().await, 118 + backup_id 119 + )) 120 + .bearer_auth(&token) 121 + .send() 122 + .await 123 + .expect("deleteBackup request failed"); 124 + 125 + assert_eq!(delete_res.status(), StatusCode::OK); 126 + 127 + let get_res = client 128 + .get(format!( 129 + "{}/xrpc/_backup.getBackup?id={}", 130 + base_url().await, 131 + backup_id 132 + )) 133 + .bearer_auth(&token) 134 + .send() 135 + .await 136 + .expect("getBackup request failed"); 137 + 138 + assert_eq!(get_res.status(), StatusCode::NOT_FOUND); 139 + } 140 + 141 + #[tokio::test] 142 + async fn test_toggle_backup_enabled() { 143 + let client = client(); 144 + let (token, _did) = create_account_and_login(&client).await; 145 + 146 + let list_res = client 147 + .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 148 + .bearer_auth(&token) 149 + .send() 150 + .await 151 + .expect("listBackups request failed"); 152 + 153 + assert_eq!(list_res.status(), StatusCode::OK); 154 + let list_body: Value = list_res.json().await.expect("Invalid JSON"); 155 + assert!(list_body["backupEnabled"].as_bool().unwrap()); 156 + 157 + let disable_res = client 158 + .post(format!("{}/xrpc/_backup.setEnabled", base_url().await)) 159 + .bearer_auth(&token) 160 + .json(&json!({"enabled": false})) 161 + .send() 162 + .await 163 + .expect("setEnabled request failed"); 164 + 165 + assert_eq!(disable_res.status(), StatusCode::OK); 166 + let disable_body: Value = disable_res.json().await.expect("Invalid JSON"); 167 + assert!(!disable_body["enabled"].as_bool().unwrap()); 168 + 169 + let list_res2 = client 170 + .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 171 + .bearer_auth(&token) 172 + .send() 173 + .await 174 + .expect("listBackups request failed"); 175 + 176 + let list_body2: Value = list_res2.json().await.expect("Invalid JSON"); 177 + assert!(!list_body2["backupEnabled"].as_bool().unwrap()); 178 + 179 + let enable_res = client 180 + .post(format!("{}/xrpc/_backup.setEnabled", base_url().await)) 181 + .bearer_auth(&token) 182 + .json(&json!({"enabled": true})) 183 + .send() 184 + .await 185 + .expect("setEnabled request failed"); 186 + 187 + assert_eq!(enable_res.status(), StatusCode::OK); 188 + } 189 + 190 + #[tokio::test] 191 + async fn test_backup_includes_blobs() { 192 + let client = client(); 193 + let (token, did) = create_account_and_login(&client).await; 194 + 195 + let blob_data = b"Hello, this is test blob data for backup testing!"; 196 + let upload_res = client 197 + .post(format!( 198 + "{}/xrpc/com.atproto.repo.uploadBlob", 199 + base_url().await 200 + )) 201 + .header(header::CONTENT_TYPE, "text/plain") 202 + .bearer_auth(&token) 203 + .body(blob_data.to_vec()) 204 + .send() 205 + .await 206 + .expect("uploadBlob request failed"); 207 + 208 + assert_eq!(upload_res.status(), StatusCode::OK); 209 + let upload_body: Value = upload_res.json().await.expect("Invalid JSON"); 210 + let blob = &upload_body["blob"]; 211 + 212 + let record = json!({ 213 + "$type": "app.bsky.feed.post", 214 + "text": "Test post with blob", 215 + "createdAt": chrono::Utc::now().to_rfc3339(), 216 + "embed": { 217 + "$type": "app.bsky.embed.images", 218 + "images": [{ 219 + "alt": "test image", 220 + "image": blob 221 + }] 222 + } 223 + }); 224 + 225 + let create_record_res = client 226 + .post(format!( 227 + "{}/xrpc/com.atproto.repo.createRecord", 228 + base_url().await 229 + )) 230 + .bearer_auth(&token) 231 + .json(&json!({ 232 + "repo": did, 233 + "collection": "app.bsky.feed.post", 234 + "record": record 235 + })) 236 + .send() 237 + .await 238 + .expect("createRecord request failed"); 239 + 240 + assert_eq!(create_record_res.status(), StatusCode::OK); 241 + 242 + let create_backup_res = client 243 + .post(format!("{}/xrpc/_backup.createBackup", base_url().await)) 244 + .bearer_auth(&token) 245 + .send() 246 + .await 247 + .expect("createBackup request failed"); 248 + 249 + assert_eq!(create_backup_res.status(), StatusCode::OK); 250 + let backup_body: Value = create_backup_res.json().await.expect("Invalid JSON"); 251 + let backup_id = backup_body["id"].as_str().unwrap(); 252 + 253 + let get_backup_res = client 254 + .get(format!( 255 + "{}/xrpc/_backup.getBackup?id={}", 256 + base_url().await, 257 + backup_id 258 + )) 259 + .bearer_auth(&token) 260 + .send() 261 + .await 262 + .expect("getBackup request failed"); 263 + 264 + assert_eq!(get_backup_res.status(), StatusCode::OK); 265 + let car_bytes = get_backup_res.bytes().await.expect("Failed to read body"); 266 + 267 + let blob_cid = blob["ref"]["$link"].as_str().unwrap(); 268 + let blob_found = String::from_utf8_lossy(&car_bytes).contains("Hello, this is test blob data"); 269 + assert!( 270 + blob_found || car_bytes.len() > 500, 271 + "Backup should contain blob data (cid: {})", 272 + blob_cid 273 + ); 274 + } 275 + 276 + #[tokio::test] 277 + async fn test_backup_unauthorized() { 278 + let client = client(); 279 + 280 + let res = client 281 + .get(format!("{}/xrpc/_backup.listBackups", base_url().await)) 282 + .send() 283 + .await 284 + .expect("listBackups request failed"); 285 + 286 + assert_eq!(res.status(), StatusCode::UNAUTHORIZED); 287 + } 288 + 289 + #[tokio::test] 290 + async fn test_get_nonexistent_backup() { 291 + let client = client(); 292 + let (token, _did) = create_account_and_login(&client).await; 293 + 294 + let fake_id = uuid::Uuid::new_v4(); 295 + let res = client 296 + .get(format!( 297 + "{}/xrpc/_backup.getBackup?id={}", 298 + base_url().await, 299 + fake_id 300 + )) 301 + .bearer_auth(&token) 302 + .send() 303 + .await 304 + .expect("getBackup request failed"); 305 + 306 + assert_eq!(res.status(), StatusCode::NOT_FOUND); 307 + } 308 + 309 + #[tokio::test] 310 + async fn test_backup_invalid_id() { 311 + let client = client(); 312 + let (token, _did) = create_account_and_login(&client).await; 313 + 314 + let res = client 315 + .get(format!( 316 + "{}/xrpc/_backup.getBackup?id=not-a-uuid", 317 + base_url().await 318 + )) 319 + .bearer_auth(&token) 320 + .send() 321 + .await 322 + .expect("getBackup request failed"); 323 + 324 + assert_eq!(res.status(), StatusCode::BAD_REQUEST); 325 + }
+6 -24
tests/change_password.rs
··· 32 32 let did = create_body["did"].as_str().unwrap(); 33 33 let jwt = verify_new_account(&client, did).await; 34 34 let change_res = client 35 - .post(format!( 36 - "{}/xrpc/com.tranquil.account.changePassword", 37 - base_url().await 38 - )) 35 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 39 36 .bearer_auth(&jwt) 40 37 .json(&json!({ 41 38 "currentPassword": old_password, ··· 86 83 let client = client(); 87 84 let (_, jwt) = setup_new_user("change-pw-wrong").await; 88 85 let res = client 89 - .post(format!( 90 - "{}/xrpc/com.tranquil.account.changePassword", 91 - base_url().await 92 - )) 86 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 93 87 .bearer_auth(&jwt) 94 88 .json(&json!({ 95 89 "currentPassword": "Wrongpass999!", ··· 129 123 let did = create_body["did"].as_str().unwrap(); 130 124 let jwt = verify_new_account(&client, did).await; 131 125 let res = client 132 - .post(format!( 133 - "{}/xrpc/com.tranquil.account.changePassword", 134 - base_url().await 135 - )) 126 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 136 127 .bearer_auth(&jwt) 137 128 .json(&json!({ 138 129 "currentPassword": password, ··· 151 142 let client = client(); 152 143 let (_, jwt) = setup_new_user("change-pw-empty").await; 153 144 let res = client 154 - .post(format!( 155 - "{}/xrpc/com.tranquil.account.changePassword", 156 - base_url().await 157 - )) 145 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 158 146 .bearer_auth(&jwt) 159 147 .json(&json!({ 160 148 "currentPassword": "", ··· 171 159 let client = client(); 172 160 let (_, jwt) = setup_new_user("change-pw-emptynew").await; 173 161 let res = client 174 - .post(format!( 175 - "{}/xrpc/com.tranquil.account.changePassword", 176 - base_url().await 177 - )) 162 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 178 163 .bearer_auth(&jwt) 179 164 .json(&json!({ 180 165 "currentPassword": "E2epass123!", ··· 190 175 async fn test_change_password_requires_auth() { 191 176 let client = client(); 192 177 let res = client 193 - .post(format!( 194 - "{}/xrpc/com.tranquil.account.changePassword", 195 - base_url().await 196 - )) 178 + .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 197 179 .json(&json!({ 198 180 "currentPassword": "Oldpass123!", 199 181 "newPassword": "Newpass123!"
+28 -247
tests/did_web.rs
··· 547 547 } 548 548 549 549 #[tokio::test] 550 - async fn test_deactivate_with_migrating_to() { 550 + async fn test_did_web_can_edit_did_document() { 551 551 let client = client(); 552 552 let base = base_url().await; 553 - let handle = format!("mig{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 553 + let handle = format!("doc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 554 554 let payload = json!({ 555 555 "handle": handle, 556 556 "email": format!("{}@example.com", handle), ··· 567 567 let body: Value = res.json().await.expect("Response was not JSON"); 568 568 let did = body["did"].as_str().expect("No DID").to_string(); 569 569 let jwt = verify_new_account(&client, &did).await; 570 - let target_pds = "https://pds2.example.com"; 571 570 let res = client 572 - .post(format!( 573 - "{}/xrpc/com.atproto.server.deactivateAccount", 574 - base 575 - )) 571 + .get(format!("{}/xrpc/_account.getDidDocument", base)) 576 572 .bearer_auth(&jwt) 577 - .json(&json!({ "migratingTo": target_pds })) 578 - .send() 579 - .await 580 - .expect("Failed to send request"); 581 - assert_eq!(res.status(), StatusCode::OK); 582 - let pool = get_test_db_pool().await; 583 - let row = sqlx::query!( 584 - r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#, 585 - &did 586 - ) 587 - .fetch_one(pool) 588 - .await 589 - .expect("Failed to query user"); 590 - assert_eq!( 591 - row.migrated_to_pds.as_deref(), 592 - Some(target_pds), 593 - "migrated_to_pds should be set to target PDS" 594 - ); 595 - assert!( 596 - row.deactivated_at.is_some(), 597 - "deactivated_at should be set for migrated account" 598 - ); 599 - } 600 - 601 - #[tokio::test] 602 - async fn test_migrated_account_blocked_from_repo_ops() { 603 - let client = client(); 604 - let base = base_url().await; 605 - let handle = format!("blk{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 606 - let payload = json!({ 607 - "handle": handle, 608 - "email": format!("{}@example.com", handle), 609 - "password": "Testpass123!", 610 - "didType": "web" 611 - }); 612 - let res = client 613 - .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 614 - .json(&payload) 615 573 .send() 616 574 .await 617 575 .expect("Failed to send request"); 618 576 assert_eq!(res.status(), StatusCode::OK); 619 577 let body: Value = res.json().await.expect("Response was not JSON"); 620 - let did = body["did"].as_str().expect("No DID").to_string(); 621 - let jwt = verify_new_account(&client, &did).await; 622 - let res = client 623 - .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 624 - .bearer_auth(&jwt) 625 - .json(&json!({ 626 - "repo": did, 627 - "collection": "app.bsky.feed.post", 628 - "record": { 629 - "$type": "app.bsky.feed.post", 630 - "text": "Pre-migration post", 631 - "createdAt": chrono::Utc::now().to_rfc3339() 632 - } 633 - })) 634 - .send() 635 - .await 636 - .expect("Failed to send request"); 637 - assert_eq!(res.status(), StatusCode::OK); 638 - let res = client 639 - .post(format!( 640 - "{}/xrpc/com.atproto.server.deactivateAccount", 641 - base 642 - )) 643 - .bearer_auth(&jwt) 644 - .json(&json!({ "migratingTo": "https://pds2.example.com" })) 645 - .send() 646 - .await 647 - .expect("Failed to send request"); 648 - assert_eq!(res.status(), StatusCode::OK); 649 - let res = client 650 - .post(format!("{}/xrpc/com.atproto.repo.createRecord", base)) 651 - .bearer_auth(&jwt) 652 - .json(&json!({ 653 - "repo": did, 654 - "collection": "app.bsky.feed.post", 655 - "record": { 656 - "$type": "app.bsky.feed.post", 657 - "text": "Post-migration post - should fail", 658 - "createdAt": chrono::Utc::now().to_rfc3339() 659 - } 660 - })) 661 - .send() 662 - .await 663 - .expect("Failed to send request"); 664 578 assert!( 665 - res.status().is_client_error(), 666 - "createRecord should fail for migrated account: {}", 667 - res.status() 579 + body["didDocument"].is_object(), 580 + "Should return DID document" 668 581 ); 669 - let res = client 670 - .post(format!("{}/xrpc/com.atproto.repo.putRecord", base)) 671 - .bearer_auth(&jwt) 672 - .json(&json!({ 673 - "repo": did, 674 - "collection": "app.bsky.actor.profile", 675 - "rkey": "self", 676 - "record": { 677 - "$type": "app.bsky.actor.profile", 678 - "displayName": "Test" 679 - } 680 - })) 681 - .send() 682 - .await 683 - .expect("Failed to send request"); 684 - assert!( 685 - res.status().is_client_error(), 686 - "putRecord should fail for migrated account: {}", 687 - res.status() 582 + assert_eq!( 583 + body["didDocument"]["id"], did, 584 + "DID document should have correct id" 688 585 ); 689 586 let res = client 690 - .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) 587 + .post(format!("{}/xrpc/_account.updateDidDocument", base)) 691 588 .bearer_auth(&jwt) 692 589 .json(&json!({ 693 - "repo": did, 694 - "collection": "app.bsky.feed.post", 695 - "rkey": "test123" 590 + "alsoKnownAs": ["at://custom.handle.test"] 696 591 })) 697 592 .send() 698 593 .await 699 594 .expect("Failed to send request"); 700 - assert!( 701 - res.status().is_client_error(), 702 - "deleteRecord should fail for migrated account: {}", 703 - res.status() 595 + assert_eq!( 596 + res.status(), 597 + StatusCode::OK, 598 + "Non-migrated did:web user should be able to update DID document" 704 599 ); 705 - let res = client 706 - .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base)) 707 - .bearer_auth(&jwt) 708 - .json(&json!({ 709 - "repo": did, 710 - "writes": [{ 711 - "$type": "com.atproto.repo.applyWrites#create", 712 - "collection": "app.bsky.feed.post", 713 - "value": { 714 - "$type": "app.bsky.feed.post", 715 - "text": "Batch post", 716 - "createdAt": chrono::Utc::now().to_rfc3339() 717 - } 718 - }] 719 - })) 720 - .send() 721 - .await 722 - .expect("Failed to send request"); 600 + let body: Value = res.json().await.expect("Response was not JSON"); 601 + assert!(body["success"].as_bool().unwrap_or(false)); 602 + let also_known_as = body["didDocument"]["alsoKnownAs"] 603 + .as_array() 604 + .expect("alsoKnownAs should be array"); 723 605 assert!( 724 - res.status().is_client_error(), 725 - "applyWrites should fail for migrated account: {}", 726 - res.status() 727 - ); 728 - let res = client 729 - .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base)) 730 - .bearer_auth(&jwt) 731 - .header("Content-Type", "text/plain") 732 - .body("test blob content") 733 - .send() 734 - .await 735 - .expect("Failed to send request"); 736 - assert!( 737 - res.status().is_client_error(), 738 - "uploadBlob should fail for migrated account: {}", 739 - res.status() 606 + also_known_as 607 + .iter() 608 + .any(|v| v.as_str() == Some("at://custom.handle.test")), 609 + "alsoKnownAs should contain custom entry" 740 610 ); 741 611 } 742 612 743 613 #[tokio::test] 744 - async fn test_migrated_session_status() { 614 + async fn test_deactivate_account_basic() { 745 615 let client = client(); 746 616 let base = base_url().await; 747 - let handle = format!("ses{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 617 + let handle = format!("dea{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 748 618 let payload = json!({ 749 619 "handle": handle, 750 620 "email": format!("{}@example.com", handle), ··· 762 632 let did = body["did"].as_str().expect("No DID").to_string(); 763 633 let jwt = verify_new_account(&client, &did).await; 764 634 let res = client 765 - .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 766 - .bearer_auth(&jwt) 767 - .send() 768 - .await 769 - .expect("Failed to send request"); 770 - assert_eq!(res.status(), StatusCode::OK); 771 - let body: Value = res.json().await.expect("Response was not JSON"); 772 - assert_eq!(body["active"], true); 773 - assert!( 774 - body["status"].is_null() || body["status"] == "active", 775 - "Status should be null or 'active' for normal accounts" 776 - ); 777 - let target_pds = "https://pds3.example.com"; 778 - let res = client 779 635 .post(format!( 780 636 "{}/xrpc/com.atproto.server.deactivateAccount", 781 637 base 782 638 )) 783 639 .bearer_auth(&jwt) 784 - .json(&json!({ "migratingTo": target_pds })) 640 + .json(&json!({})) 785 641 .send() 786 642 .await 787 643 .expect("Failed to send request"); ··· 794 650 .expect("Failed to send request"); 795 651 assert_eq!(res.status(), StatusCode::OK); 796 652 let body: Value = res.json().await.expect("Response was not JSON"); 797 - assert_eq!( 798 - body["active"], false, 799 - "Migrated account should not be active" 800 - ); 801 - assert_eq!( 802 - body["status"], "migrated", 803 - "Status should be 'migrated' after migration" 804 - ); 805 - assert_eq!( 806 - body["migratedToPds"], target_pds, 807 - "migratedToPds should be set to target PDS" 808 - ); 809 - } 810 - 811 - #[tokio::test] 812 - async fn test_migrating_to_ignored_for_did_plc() { 813 - let client = client(); 814 - let base = base_url().await; 815 - let handle = format!("plc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 816 - let payload = json!({ 817 - "handle": handle, 818 - "email": format!("{}@example.com", handle), 819 - "password": "Testpass123!", 820 - "didType": "plc" 821 - }); 822 - let res = client 823 - .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 824 - .json(&payload) 825 - .send() 826 - .await 827 - .expect("Failed to send request"); 828 - assert_eq!(res.status(), StatusCode::OK); 829 - let body: Value = res.json().await.expect("Response was not JSON"); 830 - let did = body["did"].as_str().expect("No DID").to_string(); 831 - assert!(did.starts_with("did:plc:"), "Should be did:plc account"); 832 - let jwt = verify_new_account(&client, &did).await; 833 - let res = client 834 - .post(format!( 835 - "{}/xrpc/com.atproto.server.deactivateAccount", 836 - base 837 - )) 838 - .bearer_auth(&jwt) 839 - .json(&json!({ "migratingTo": "https://pds2.example.com" })) 840 - .send() 841 - .await 842 - .expect("Failed to send request"); 843 - assert_eq!(res.status(), StatusCode::OK); 844 - let pool = get_test_db_pool().await; 845 - let row = sqlx::query!( 846 - r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#, 847 - &did 848 - ) 849 - .fetch_one(pool) 850 - .await 851 - .expect("Failed to query user"); 852 - assert!( 853 - row.migrated_to_pds.is_none(), 854 - "migrated_to_pds should NOT be set for did:plc accounts" 855 - ); 856 - assert!( 857 - row.deactivated_at.is_some(), 858 - "deactivated_at should still be set" 859 - ); 860 - let res = client 861 - .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 862 - .bearer_auth(&jwt) 863 - .send() 864 - .await 865 - .expect("Failed to send request"); 866 - assert_eq!(res.status(), StatusCode::OK); 867 - let body: Value = res.json().await.expect("Response was not JSON"); 868 - assert_eq!(body["active"], false); 653 + assert_eq!(body["active"], false, "Account should be deactivated"); 869 654 assert_eq!( 870 655 body["status"], "deactivated", 871 - "Status should be 'deactivated' not 'migrated' for did:plc" 872 - ); 873 - assert!( 874 - body["migratedToPds"].is_null(), 875 - "migratedToPds should not be set for did:plc accounts" 656 + "Status should be 'deactivated'" 876 657 ); 877 658 }
-1
tests/oauth.rs
··· 1 1 mod common; 2 2 mod helpers; 3 3 use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; 4 - use chrono::Utc; 5 4 use common::{base_url, client, get_test_db_pool}; 6 5 use helpers::verify_new_account; 7 6 use reqwest::{StatusCode, redirect};
+1 -4
tests/oauth_security.rs
··· 1116 1116 1117 1117 let delegated_handle = format!("dg{}", suffix); 1118 1118 let delegated_res = http_client 1119 - .post(format!( 1120 - "{}/xrpc/com.tranquil.delegation.createDelegatedAccount", 1121 - url 1122 - )) 1119 + .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) 1123 1120 .bearer_auth(controller_jwt) 1124 1121 .json(&json!({ 1125 1122 "handle": delegated_handle,
+9 -36
tests/session_management.rs
··· 10 10 let client = client(); 11 11 let (did, jwt) = setup_new_user("list-sessions").await; 12 12 let res = client 13 - .get(format!( 14 - "{}/xrpc/com.tranquil.account.listSessions", 15 - base_url().await 16 - )) 13 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 17 14 .bearer_auth(&jwt) 18 15 .send() 19 16 .await ··· 83 80 let login_body: Value = login_res.json().await.unwrap(); 84 81 let jwt2 = login_body["accessJwt"].as_str().unwrap(); 85 82 let list_res = client 86 - .get(format!( 87 - "{}/xrpc/com.tranquil.account.listSessions", 88 - base_url().await 89 - )) 83 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 90 84 .bearer_auth(jwt2) 91 85 .send() 92 86 .await ··· 106 100 async fn test_list_sessions_requires_auth() { 107 101 let client = client(); 108 102 let res = client 109 - .get(format!( 110 - "{}/xrpc/com.tranquil.account.listSessions", 111 - base_url().await 112 - )) 103 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 113 104 .send() 114 105 .await 115 106 .expect("Failed to send request"); ··· 158 149 let login_body: Value = login_res.json().await.unwrap(); 159 150 let jwt2 = login_body["accessJwt"].as_str().unwrap(); 160 151 let list_res = client 161 - .get(format!( 162 - "{}/xrpc/com.tranquil.account.listSessions", 163 - base_url().await 164 - )) 152 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 165 153 .bearer_auth(jwt2) 166 154 .send() 167 155 .await ··· 177 165 ); 178 166 let session_id = other_session.unwrap()["id"].as_str().unwrap(); 179 167 let revoke_res = client 180 - .post(format!( 181 - "{}/xrpc/com.tranquil.account.revokeSession", 182 - base_url().await 183 - )) 168 + .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 184 169 .bearer_auth(jwt2) 185 170 .json(&json!({"sessionId": session_id})) 186 171 .send() ··· 188 173 .expect("Failed to revoke session"); 189 174 assert_eq!(revoke_res.status(), StatusCode::OK); 190 175 let list_after_res = client 191 - .get(format!( 192 - "{}/xrpc/com.tranquil.account.listSessions", 193 - base_url().await 194 - )) 176 + .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 195 177 .bearer_auth(jwt2) 196 178 .send() 197 179 .await ··· 213 195 let client = client(); 214 196 let (_, jwt) = setup_new_user("revoke-invalid").await; 215 197 let res = client 216 - .post(format!( 217 - "{}/xrpc/com.tranquil.account.revokeSession", 218 - base_url().await 219 - )) 198 + .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 220 199 .bearer_auth(&jwt) 221 200 .json(&json!({"sessionId": "not-a-number"})) 222 201 .send() ··· 230 209 let client = client(); 231 210 let (_, jwt) = setup_new_user("revoke-notfound").await; 232 211 let res = client 233 - .post(format!( 234 - "{}/xrpc/com.tranquil.account.revokeSession", 235 - base_url().await 236 - )) 212 + .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 237 213 .bearer_auth(&jwt) 238 214 .json(&json!({"sessionId": "jwt:999999999"})) 239 215 .send() ··· 246 222 async fn test_revoke_session_requires_auth() { 247 223 let client = client(); 248 224 let res = client 249 - .post(format!( 250 - "{}/xrpc/com.tranquil.account.revokeSession", 251 - base_url().await 252 - )) 225 + .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 253 226 .json(&json!({"sessionId": "1"})) 254 227 .send() 255 228 .await