Tranquil PDS! Moved to https://tangled.org/tranquil.farm/tranquil-pds

Fix replying to other users not in the same PDS, add PAR for oauth and fix minor bugs #10

closed opened by scanash.com targeting main from [deleted fork]: main
Labels

None yet.

assignee

None yet.

Participants 3
AT URI
at://did:plc:3i6uzuatdyk7rwfkrybynf5j/sh.tangled.repo.pull/3mbekljkj5d22
+3207 -7648
Diff #1
-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 - }
+34
.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json
··· 1 + { 2 + "db_name": "PostgreSQL", 3 + "query": "SELECT id, migrated_to_pds, handle 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": "migrated_to_pds", 14 + "type_info": "Text" 15 + }, 16 + { 17 + "ordinal": 2, 18 + "name": "handle", 19 + "type_info": "Text" 20 + } 21 + ], 22 + "parameters": { 23 + "Left": [ 24 + "Text" 25 + ] 26 + }, 27 + "nullable": [ 28 + false, 29 + true, 30 + false 31 + ] 32 + }, 33 + "hash": "63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073" 34 + }
+30
.sqlx/query-6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115.json
··· 1 + { 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 ", 4 + "describe": { 5 + "columns": [ 6 + { 7 + "ordinal": 0, 8 + "name": "blob_cid", 9 + "type_info": "Text" 10 + }, 11 + { 12 + "ordinal": 1, 13 + "name": "record_uri", 14 + "type_info": "Text" 15 + } 16 + ], 17 + "parameters": { 18 + "Left": [ 19 + "Uuid", 20 + "Text", 21 + "Int8" 22 + ] 23 + }, 24 + "nullable": [ 25 + false, 26 + false 27 + ] 28 + }, 29 + "hash": "6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115" 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-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json
··· 1 - { 2 - "db_name": "PostgreSQL", 3 - "query": "SELECT id, handle, deactivated_at 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": "handle", 14 - "type_info": "Text" 15 - }, 16 - { 17 - "ordinal": 2, 18 - "name": "deactivated_at", 19 - "type_info": "Timestamptz" 20 - } 21 - ], 22 - "parameters": { 23 - "Left": [ 24 - "Text" 25 - ] 26 - }, 27 - "nullable": [ 28 - false, 29 - false, 30 - true 31 - ] 32 - }, 33 - "hash": "e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7" 34 - }
-30
.sqlx/query-ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf.json
··· 1 - { 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\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 - "describe": { 5 - "columns": [ 6 - { 7 - "ordinal": 0, 8 - "name": "blob_cid", 9 - "type_info": "Text" 10 - }, 11 - { 12 - "ordinal": 1, 13 - "name": "record_uri", 14 - "type_info": "Text" 15 - } 16 - ], 17 - "parameters": { 18 - "Left": [ 19 - "Uuid", 20 - "Text", 21 - "Int8" 22 - ] 23 - }, 24 - "nullable": [ 25 - false, 26 - false 27 - ] 28 - }, 29 - "hash": "ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf" 30 - }
-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]] 123 114 name = "arc-swap" 124 115 version = "1.7.1" 125 116 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1630 1621 ] 1631 1622 1632 1623 [[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]] 1644 1624 name = "derive_more" 1645 1625 version = "1.0.0" 1646 1626 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1993 1973 checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" 1994 1974 dependencies = [ 1995 1975 "crc32fast", 1996 - "libz-rs-sys", 1997 1976 "miniz_oxide", 1998 1977 ] 1999 1978 ··· 3478 3457 dependencies = [ 3479 3458 "pkg-config", 3480 3459 "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", 3490 3460 ] 3491 3461 3492 3462 [[package]] ··· 6316 6286 "ed25519-dalek", 6317 6287 "futures", 6318 6288 "governor", 6319 - "hex", 6320 6289 "hickory-resolver", 6321 6290 "hkdf", 6322 6291 "hmac", ··· 6360 6329 "webauthn-rs", 6361 6330 "webauthn-rs-proto", 6362 6331 "wiremock", 6363 - "zip", 6364 6332 ] 6365 6333 6366 6334 [[package]] ··· 7321 7289 "proc-macro2", 7322 7290 "quote", 7323 7291 "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", 7356 7292 ] 7357 7293 7358 7294 [[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" 23 22 hkdf = "0.12" 24 23 hmac = "0.12" 25 24 aes-gcm = "0.10" ··· 63 62 totp-rs = { version = "5", features = ["qr"] } 64 63 webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] } 65 64 webauthn-rs-proto = "0.5.4" 66 - zip = { version = "7.0.0", default-features = false, features = ["deflate"] } 67 65 [features] 68 66 external-infra = [] 69 67 [dev-dependencies]
+1
Dockerfile
··· 17 17 cp target/release/tranquil-pds /tmp/tranquil-pds 18 18 19 19 FROM alpine:3.23 20 + RUN apk add --no-cache msmtp ca-certificates && ln -sf /usr/bin/msmtp /usr/sbin/sendmail 20 21 COPY --from=builder /tmp/tranquil-pds /usr/local/bin/tranquil-pds 21 22 COPY --from=builder /app/migrations /app/migrations 22 23 COPY --from=frontend-builder /frontend/dist /app/frontend/dist
+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), 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. 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. 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
+15 -2
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 + 5 20 ### Plugin system 6 21 Extensible architecture allowing third-party plugins to add functionality. Going with wasm-based rather than scripting language. 7 22 ··· 54 69 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. 55 70 56 71 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", 8 4 "npm:@noble/secp256k1@^2.1.0": "2.3.0", 9 5 "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", 10 6 "npm:@testing-library/jest-dom@^6.6.3": "6.9.1", ··· 34 30 "lru-cache" 35 31 ] 36 32 }, 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 - }, 111 33 "@babel/code-frame@7.27.1": { 112 34 "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", 113 35 "dependencies": [ ··· 121 43 }, 122 44 "@babel/runtime@7.28.4": { 123 45 "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==" 124 - }, 125 - "@badrap/valita@0.4.6": { 126 - "integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg==" 127 46 }, 128 47 "@csstools/color-helpers@5.1.0": { 129 48 "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==" ··· 579 498 "@noble/secp256k1@2.3.0": { 580 499 "integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw==" 581 500 }, 582 - "@noble/secp256k1@3.0.0": { 583 - "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==" 584 - }, 585 501 "@rollup/rollup-android-arm-eabi@4.53.3": { 586 502 "integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==", 587 503 "os": ["android"], ··· 691 607 "integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==", 692 608 "os": ["win32"], 693 609 "cpu": ["x64"] 694 - }, 695 - "@standard-schema/spec@1.1.0": { 696 - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" 697 610 }, 698 611 "@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": { 699 612 "integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==", ··· 1632 1545 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", 1633 1546 "bin": true 1634 1547 }, 1635 - "unicode-segmenter@0.14.4": { 1636 - "integrity": "sha512-pR5VCiCrLrKOL6FRW61jnk9+wyMtKKowq+jyFY9oc6uHbWKhDL4yVRiI4YZPksGMK72Pahh8m0cn/0JvbDDyJg==" 1637 - }, 1638 1548 "vite-node@2.1.9": { 1639 1549 "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", 1640 1550 "dependencies": [ ··· 1761 1671 "workspace": { 1762 1672 "packageJson": { 1763 1673 "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", 1768 1674 "npm:@noble/secp256k1@^2.1.0", 1769 1675 "npm:@sveltejs/vite-plugin-svelte@5", 1770 1676 "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", 19 15 "@noble/secp256k1": "^2.1.0", 20 16 "multiformats": "^13.3.1", 21 17 "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 ? $_('common.verifying') : $_('common.verify')} 231 + {loading ? $_('reauth.verifying') : $_('reauth.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 ? $_('common.verifying') : $_('common.verify')} 250 + {loading ? $_('reauth.verifying') : $_('reauth.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>
+306 -64
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' 14 8 15 9 interface ResumeInfo { 16 - direction: 'inbound' 10 + direction: 'inbound' | 'outbound' 17 11 sourceHandle: string 18 12 targetHandle: string 19 13 sourcePdsUrl: string ··· 43 37 let checkingHandle = $state(false) 44 38 let selectedAuthMethod = $state<AuthMethod>('password') 45 39 let passkeyName = $state('') 40 + let appPasswordCopied = $state(false) 41 + let appPasswordAcknowledged = $state(false) 46 42 47 43 const isResuming = $derived(flow.state.needsReauth === true) 48 44 const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:")) ··· 238 234 } 239 235 } 240 236 237 + function copyAppPassword() { 238 + if (flow.state.generatedAppPassword) { 239 + navigator.clipboard.writeText(flow.state.generatedAppPassword) 240 + appPasswordCopied = true 241 + } 242 + } 243 + 241 244 async function handleProceedFromAppPassword() { 242 245 loading = true 243 246 try { ··· 349 352 </label> 350 353 351 354 <div class="button-row"> 352 - <button type="button" class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 353 - <button type="button" disabled={!understood} onclick={() => flow.setStep('source-handle')}> 355 + <button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button> 356 + <button disabled={!understood} onclick={() => flow.setStep('source-handle')}> 354 357 {$_('migration.inbound.common.continue')} 355 358 </button> 356 359 </div> ··· 406 409 </div> 407 410 408 411 {:else if flow.state.step === 'choose-handle'} 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 - /> 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> 432 537 433 538 {:else if flow.state.step === 'review'} 434 539 <div class="step-content"> ··· 515 620 </div> 516 621 517 622 {:else if flow.state.step === 'passkey-setup'} 518 - <PasskeySetupStep 519 - {passkeyName} 520 - {loading} 521 - error={flow.state.error} 522 - onPasskeyNameChange={(n) => passkeyName = n} 523 - onRegister={registerPasskey} 524 - /> 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> 525 652 526 653 {:else if flow.state.step === 'app-password'} 527 - <AppPasswordStep 528 - appPassword={flow.state.generatedAppPassword || ''} 529 - appPasswordName={flow.state.generatedAppPasswordName || ''} 530 - {loading} 531 - onContinue={handleProceedFromAppPassword} 532 - /> 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> 533 683 534 684 {:else if flow.state.step === 'email-verify'} 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 - /> 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> 544 725 545 726 {:else if flow.state.step === 'plc-token'} 546 727 <div class="step-content"> ··· 656 837 </div> 657 838 658 839 {:else if flow.state.step === 'success'} 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> 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> 668 864 669 865 {:else if flow.state.step === 'error'} 670 - <ErrorStep error={flow.state.error} onStartOver={onBack} /> 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> 671 878 {/if} 672 879 </div> 673 880 674 881 <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 + } 675 917 .resume-info { 676 918 margin-bottom: var(--space-5); 677 919 }
-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>
+47 -155
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 - 240 208 async confirmSignup( 241 209 did: string, 242 210 verificationCode: string, ··· 258 226 return xrpc("com.atproto.server.createSession", { 259 227 method: "POST", 260 228 body: { identifier, password }, 261 - }); 262 - }, 263 - 264 - async checkEmailVerified(identifier: string): Promise<{ verified: boolean }> { 265 - return xrpc("_checkEmailVerified", { 266 - method: "POST", 267 - body: { identifier }, 268 229 }); 269 230 }, 270 231 ··· 418 379 signalNumber: string | null; 419 380 signalVerified: boolean; 420 381 }> { 421 - return xrpc("_account.getNotificationPrefs", { token }); 382 + return xrpc("com.tranquil.account.getNotificationPrefs", { token }); 422 383 }, 423 384 424 385 async updateNotificationPrefs(token: string, prefs: { ··· 427 388 telegramUsername?: string; 428 389 signalNumber?: string; 429 390 }): Promise<{ success: boolean }> { 430 - return xrpc("_account.updateNotificationPrefs", { 391 + return xrpc("com.tranquil.account.updateNotificationPrefs", { 431 392 method: "POST", 432 393 token, 433 394 body: prefs, ··· 440 401 identifier: string, 441 402 code: string, 442 403 ): Promise<{ success: boolean }> { 443 - return xrpc("_account.confirmChannelVerification", { 404 + return xrpc("com.tranquil.account.confirmChannelVerification", { 444 405 method: "POST", 445 406 token, 446 407 body: { channel, identifier, code }, ··· 457 418 body: string; 458 419 }>; 459 420 }> { 460 - return xrpc("_account.getNotificationHistory", { token }); 421 + return xrpc("com.tranquil.account.getNotificationHistory", { token }); 461 422 }, 462 423 463 424 async getServerStats(token: string): Promise<{ ··· 466 427 recordCount: number; 467 428 blobStorageBytes: number; 468 429 }> { 469 - return xrpc("_admin.getServerStats", { token }); 430 + return xrpc("com.tranquil.admin.getServerStats", { token }); 470 431 }, 471 432 472 433 async getServerConfig(): Promise<{ ··· 477 438 secondaryColorDark: string | null; 478 439 logoCid: string | null; 479 440 }> { 480 - return xrpc("_server.getConfig"); 441 + return xrpc("com.tranquil.server.getConfig"); 481 442 }, 482 443 483 444 async updateServerConfig( ··· 491 452 logoCid?: string; 492 453 }, 493 454 ): Promise<{ success: boolean }> { 494 - return xrpc("_admin.updateServerConfig", { 455 + return xrpc("com.tranquil.admin.updateServerConfig", { 495 456 method: "POST", 496 457 token, 497 458 body: config, ··· 534 495 currentPassword: string, 535 496 newPassword: string, 536 497 ): Promise<void> { 537 - await xrpc("_account.changePassword", { 498 + await xrpc("com.tranquil.account.changePassword", { 538 499 method: "POST", 539 500 token, 540 501 body: { currentPassword, newPassword }, ··· 542 503 }, 543 504 544 505 async removePassword(token: string): Promise<{ success: boolean }> { 545 - return xrpc("_account.removePassword", { 506 + return xrpc("com.tranquil.account.removePassword", { 546 507 method: "POST", 547 508 token, 548 509 }); 549 510 }, 550 511 551 512 async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> { 552 - return xrpc("_account.getPasswordStatus", { token }); 513 + return xrpc("com.tranquil.account.getPasswordStatus", { token }); 553 514 }, 554 515 555 516 async getLegacyLoginPreference( 556 517 token: string, 557 518 ): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> { 558 - return xrpc("_account.getLegacyLoginPreference", { token }); 519 + return xrpc("com.tranquil.account.getLegacyLoginPreference", { token }); 559 520 }, 560 521 561 522 async updateLegacyLoginPreference( 562 523 token: string, 563 524 allowLegacyLogin: boolean, 564 525 ): Promise<{ allowLegacyLogin: boolean }> { 565 - return xrpc("_account.updateLegacyLoginPreference", { 526 + return xrpc("com.tranquil.account.updateLegacyLoginPreference", { 566 527 method: "POST", 567 528 token, 568 529 body: { allowLegacyLogin }, ··· 573 534 token: string, 574 535 preferredLocale: string, 575 536 ): Promise<{ preferredLocale: string }> { 576 - return xrpc("_account.updateLocale", { 537 + return xrpc("com.tranquil.account.updateLocale", { 577 538 method: "POST", 578 539 token, 579 540 body: { preferredLocale }, ··· 590 551 isCurrent: boolean; 591 552 }>; 592 553 }> { 593 - return xrpc("_account.listSessions", { token }); 554 + return xrpc("com.tranquil.account.listSessions", { token }); 594 555 }, 595 556 596 557 async revokeSession(token: string, sessionId: string): Promise<void> { 597 - await xrpc("_account.revokeSession", { 558 + await xrpc("com.tranquil.account.revokeSession", { 598 559 method: "POST", 599 560 token, 600 561 body: { sessionId }, ··· 602 563 }, 603 564 604 565 async revokeAllSessions(token: string): Promise<{ revokedCount: number }> { 605 - return xrpc("_account.revokeAllSessions", { 566 + return xrpc("com.tranquil.account.revokeAllSessions", { 606 567 method: "POST", 607 568 token, 608 569 }); ··· 907 868 lastSeenAt: string; 908 869 }>; 909 870 }> { 910 - return xrpc("_account.listTrustedDevices", { token }); 871 + return xrpc("com.tranquil.account.listTrustedDevices", { token }); 911 872 }, 912 873 913 874 async revokeTrustedDevice( 914 875 token: string, 915 876 deviceId: string, 916 877 ): Promise<{ success: boolean }> { 917 - return xrpc("_account.revokeTrustedDevice", { 878 + return xrpc("com.tranquil.account.revokeTrustedDevice", { 918 879 method: "POST", 919 880 token, 920 881 body: { deviceId }, ··· 926 887 deviceId: string, 927 888 friendlyName: string, 928 889 ): Promise<{ success: boolean }> { 929 - return xrpc("_account.updateTrustedDevice", { 890 + return xrpc("com.tranquil.account.updateTrustedDevice", { 930 891 method: "POST", 931 892 token, 932 893 body: { deviceId, friendlyName }, ··· 938 899 lastReauthAt: string | null; 939 900 availableMethods: string[]; 940 901 }> { 941 - return xrpc("_account.getReauthStatus", { token }); 902 + return xrpc("com.tranquil.account.getReauthStatus", { token }); 942 903 }, 943 904 944 905 async reauthPassword( 945 906 token: string, 946 907 password: string, 947 908 ): Promise<{ success: boolean; reauthAt: string }> { 948 - return xrpc("_account.reauthPassword", { 909 + return xrpc("com.tranquil.account.reauthPassword", { 949 910 method: "POST", 950 911 token, 951 912 body: { password }, ··· 956 917 token: string, 957 918 code: string, 958 919 ): Promise<{ success: boolean; reauthAt: string }> { 959 - return xrpc("_account.reauthTotp", { 920 + return xrpc("com.tranquil.account.reauthTotp", { 960 921 method: "POST", 961 922 token, 962 923 body: { code }, ··· 964 925 }, 965 926 966 927 async reauthPasskeyStart(token: string): Promise<{ options: unknown }> { 967 - return xrpc("_account.reauthPasskeyStart", { 928 + return xrpc("com.tranquil.account.reauthPasskeyStart", { 968 929 method: "POST", 969 930 token, 970 931 }); ··· 974 935 token: string, 975 936 credential: unknown, 976 937 ): Promise<{ success: boolean; reauthAt: string }> { 977 - return xrpc("_account.reauthPasskeyFinish", { 938 + return xrpc("com.tranquil.account.reauthPasskeyFinish", { 978 939 method: "POST", 979 940 token, 980 941 body: { credential }, ··· 1021 982 setupToken: string; 1022 983 setupExpiresAt: string; 1023 984 }> { 1024 - const url = `${API_BASE}/_account.createPasskeyAccount`; 985 + const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`; 1025 986 const headers: Record<string, string> = { 1026 987 "Content-Type": "application/json", 1027 988 }; ··· 1048 1009 setupToken: string, 1049 1010 friendlyName?: string, 1050 1011 ): Promise<{ options: unknown }> { 1051 - return xrpc("_account.startPasskeyRegistrationForSetup", { 1012 + return xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 1052 1013 method: "POST", 1053 1014 body: { did, setupToken, friendlyName }, 1054 1015 }); ··· 1065 1026 appPassword: string; 1066 1027 appPasswordName: string; 1067 1028 }> { 1068 - return xrpc("_account.completePasskeySetup", { 1029 + return xrpc("com.tranquil.account.completePasskeySetup", { 1069 1030 method: "POST", 1070 1031 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 1071 1032 }); 1072 1033 }, 1073 1034 1074 1035 async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> { 1075 - return xrpc("_account.requestPasskeyRecovery", { 1036 + return xrpc("com.tranquil.account.requestPasskeyRecovery", { 1076 1037 method: "POST", 1077 1038 body: { email }, 1078 1039 }); ··· 1083 1044 recoveryToken: string, 1084 1045 newPassword: string, 1085 1046 ): Promise<{ success: boolean }> { 1086 - return xrpc("_account.recoverPasskeyAccount", { 1047 + return xrpc("com.tranquil.account.recoverPasskeyAccount", { 1087 1048 method: "POST", 1088 1049 body: { did, recoveryToken, newPassword }, 1089 1050 }); ··· 1116 1077 purpose: string; 1117 1078 channel: string; 1118 1079 }> { 1119 - return xrpc("_account.verifyToken", { 1080 + return xrpc("com.tranquil.account.verifyToken", { 1120 1081 method: "POST", 1121 1082 body: { token, identifier }, 1122 1083 token: accessToken, ··· 1124 1085 }, 1125 1086 1126 1087 async getDidDocument(token: string): Promise<DidDocument> { 1127 - return xrpc("_account.getDidDocument", { token }); 1088 + return xrpc("com.tranquil.account.getDidDocument", { token }); 1128 1089 }, 1129 1090 1130 1091 async updateDidDocument( ··· 1135 1096 serviceEndpoint?: string; 1136 1097 }, 1137 1098 ): Promise<{ success: boolean }> { 1138 - return xrpc("_account.updateDidDocument", { 1099 + return xrpc("com.tranquil.account.updateDidDocument", { 1139 1100 method: "POST", 1140 1101 token, 1141 1102 body: params, ··· 1145 1106 async deactivateAccount( 1146 1107 token: string, 1147 1108 deleteAfter?: string, 1109 + migratingTo?: string, 1148 1110 ): Promise<void> { 1149 1111 await xrpc("com.atproto.server.deactivateAccount", { 1150 1112 method: "POST", 1151 1113 token, 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}` }, 1114 + body: { deleteAfter, migratingTo }, 1162 1115 }); 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 1116 }, 1172 1117 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; 1118 + async getMigrationStatus(token: string): Promise<{ 1119 + migratedToPds?: string; 1120 + migratedAt?: string; 1121 + forwardingEnabled: boolean; 1183 1122 }> { 1184 - return xrpc("_backup.listBackups", { token }); 1123 + return xrpc("com.tranquil.account.getMigrationStatus", { token }); 1185 1124 }, 1186 1125 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}` }, 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(); 1200 - }, 1201 - 1202 - async createBackup(token: string): Promise<{ 1203 - id: string; 1204 - repoRev: string; 1205 - sizeBytes: number; 1206 - blockCount: number; 1207 - }> { 1208 - return xrpc("_backup.createBackup", { 1126 + async updateMigrationForwarding( 1127 + token: string, 1128 + forwardingPds?: string, 1129 + ): Promise<{ success: boolean }> { 1130 + return xrpc("com.tranquil.account.updateMigrationForwarding", { 1209 1131 method: "POST", 1210 1132 token, 1211 - }); 1212 - }, 1213 - 1214 - async deleteBackup(token: string, id: string): Promise<void> { 1215 - await xrpc("_backup.deleteBackup", { 1216 - method: "POST", 1217 - token, 1218 - params: { id }, 1133 + body: { forwardingPds }, 1219 1134 }); 1220 1135 }, 1221 1136 1222 - async setBackupEnabled( 1223 - token: string, 1224 - enabled: boolean, 1225 - ): Promise<{ enabled: boolean }> { 1226 - return xrpc("_backup.setEnabled", { 1137 + async clearMigrationForwarding(token: string): Promise<{ success: boolean }> { 1138 + return xrpc("com.tranquil.account.clearMigrationForwarding", { 1227 1139 method: "POST", 1228 1140 token, 1229 - body: { enabled }, 1230 1141 }); 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 - } 1250 1142 }, 1251 1143 };
+145 -25
frontend/src/lib/migration/atproto-client.ts
··· 101 101 let requestBody: BodyInit | undefined; 102 102 if (rawBody) { 103 103 headers["Content-Type"] = contentType ?? "application/octet-stream"; 104 - requestBody = rawBody; 104 + requestBody = rawBody as BodyInit; 105 105 } else if (body) { 106 106 headers["Content-Type"] = "application/json"; 107 107 requestBody = JSON.stringify(body); ··· 372 372 ); 373 373 } 374 374 375 - async deactivateAccount(): Promise<void> { 375 + async deactivateAccount(migratingTo?: string): Promise<void> { 376 376 apiLog( 377 377 "POST", 378 378 `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, 379 + { 380 + migratingTo, 381 + }, 379 382 ); 380 383 const start = Date.now(); 381 384 try { 385 + const body: { migratingTo?: string } = {}; 386 + if (migratingTo) { 387 + body.migratingTo = migratingTo; 388 + } 382 389 await this.xrpc("com.atproto.server.deactivateAccount", { 383 390 httpMethod: "POST", 391 + body, 384 392 }); 385 393 apiLog( 386 394 "POST", ··· 388 396 { 389 397 durationMs: Date.now() - start, 390 398 success: true, 399 + migratingTo, 391 400 }, 392 401 ); 393 402 } catch (e) { ··· 400 409 error: err.message, 401 410 errorCode: err.error, 402 411 status: err.status, 412 + migratingTo, 403 413 }, 404 414 ); 405 415 throw e; ··· 410 420 return this.xrpc("com.atproto.server.checkAccountStatus"); 411 421 } 412 422 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 + 413 450 async resolveHandle(handle: string): Promise<{ did: string }> { 414 451 return this.xrpc("com.atproto.identity.resolveHandle", { 415 452 params: { handle }, ··· 431 468 return session; 432 469 } 433 470 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 - 445 471 async verifyToken( 446 472 token: string, 447 473 identifier: string, 448 474 ): Promise< 449 475 { success: boolean; did: string; purpose: string; channel: string } 450 476 > { 451 - return this.xrpc("_account.verifyToken", { 477 + return this.xrpc("com.tranquil.account.verifyToken", { 452 478 httpMethod: "POST", 453 479 body: { token, identifier }, 454 480 }); ··· 472 498 } 473 499 474 500 const res = await fetch( 475 - `${this.baseUrl}/xrpc/_account.createPasskeyAccount`, 501 + `${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`, 476 502 { 477 503 method: "POST", 478 504 headers, ··· 504 530 setupToken: string, 505 531 friendlyName?: string, 506 532 ): Promise<StartPasskeyRegistrationResponse> { 507 - return this.xrpc("_account.startPasskeyRegistrationForSetup", { 533 + return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", { 508 534 httpMethod: "POST", 509 535 body: { did, setupToken, friendlyName }, 510 536 }); ··· 516 542 passkeyCredential: unknown, 517 543 passkeyFriendlyName?: string, 518 544 ): Promise<CompletePasskeySetupResponse> { 519 - return this.xrpc("_account.completePasskeySetup", { 545 + return this.xrpc("com.tranquil.account.completePasskeySetup", { 520 546 httpMethod: "POST", 521 547 body: { did, setupToken, passkeyCredential, passkeyFriendlyName }, 522 548 }); ··· 546 572 return null; 547 573 } 548 574 549 - const authServerUrl = `${ 550 - authServers[0] 551 - }/.well-known/oauth-authorization-server`; 575 + const authServerUrl = `${authServers[0] 576 + }/.well-known/oauth-authorization-server`; 552 577 const authServerRes = await fetch(authServerUrl); 553 578 if (!authServerRes.ok) { 554 579 return null; ··· 616 641 ...cred, 617 642 id: base64UrlDecode(cred.id as string), 618 643 }), 619 - ), 620 - } as PublicKeyCredentialCreationOptions; 644 + ) as unknown, 645 + } as unknown as PublicKeyCredentialCreationOptions; 621 646 } 622 647 623 648 async function computeAccessTokenHash(accessToken: string): Promise<string> { ··· 662 687 return url.toString(); 663 688 } 664 689 690 + export function buildParAuthorizationUrl( 691 + metadata: OAuthServerMetadata, 692 + clientId: string, 693 + requestUri: string, 694 + ): string { 695 + const url = new URL(metadata.authorization_endpoint); 696 + url.searchParams.set("client_id", clientId); 697 + url.searchParams.set("request_uri", requestUri); 698 + return url.toString(); 699 + } 700 + 701 + export async function pushAuthorizationRequest( 702 + metadata: OAuthServerMetadata, 703 + params: { 704 + clientId: string; 705 + redirectUri: string; 706 + codeChallenge: string; 707 + state: string; 708 + scope?: string; 709 + dpopJkt?: string; 710 + loginHint?: string; 711 + }, 712 + dpopKeyPair: DPoPKeyPair, 713 + ): Promise<{ request_uri: string; expires_in: number }> { 714 + if (!metadata.pushed_authorization_request_endpoint) { 715 + throw new Error("Server does not support PAR"); 716 + } 717 + 718 + const body = new URLSearchParams({ 719 + response_type: "code", 720 + client_id: params.clientId, 721 + redirect_uri: params.redirectUri, 722 + code_challenge: params.codeChallenge, 723 + code_challenge_method: "S256", 724 + state: params.state, 725 + scope: params.scope ?? "atproto", 726 + }); 727 + 728 + if (params.dpopJkt) { 729 + body.set("dpop_jkt", params.dpopJkt); 730 + } 731 + if (params.loginHint) { 732 + body.set("login_hint", params.loginHint); 733 + } 734 + 735 + const makeRequest = async (nonce?: string): Promise<Response> => { 736 + const dpopProof = await createDPoPProof( 737 + dpopKeyPair, 738 + "POST", 739 + metadata.pushed_authorization_request_endpoint!, 740 + nonce, 741 + ); 742 + 743 + return fetch(metadata.pushed_authorization_request_endpoint!, { 744 + method: "POST", 745 + headers: { 746 + "Content-Type": "application/x-www-form-urlencoded", 747 + DPoP: dpopProof, 748 + }, 749 + body: body.toString(), 750 + }); 751 + }; 752 + 753 + let res = await makeRequest(); 754 + 755 + if (!res.ok) { 756 + const err = await res.json().catch(() => ({ 757 + error: "par_error", 758 + error_description: res.statusText, 759 + })); 760 + 761 + if (err.error === "use_dpop_nonce") { 762 + const dpopNonce = res.headers.get("DPoP-Nonce"); 763 + if (dpopNonce) { 764 + res = await makeRequest(dpopNonce); 765 + if (!res.ok) { 766 + const retryErr = await res.json().catch(() => ({ 767 + error: "par_error", 768 + error_description: res.statusText, 769 + })); 770 + throw new Error( 771 + retryErr.error_description || retryErr.error || "PAR request failed", 772 + ); 773 + } 774 + return res.json(); 775 + } 776 + } 777 + 778 + throw new Error( 779 + err.error_description || err.error || "PAR request failed", 780 + ); 781 + } 782 + 783 + return res.json(); 784 + } 785 + 665 786 export async function exchangeOAuthCode( 666 787 metadata: OAuthServerMetadata, 667 788 params: { ··· 721 842 })); 722 843 throw new Error( 723 844 retryErr.error_description || retryErr.error || 724 - "Token exchange failed", 845 + "Token exchange failed", 725 846 ); 726 847 } 727 848 return res.json(); ··· 773 894 774 895 if (handle.endsWith(".bsky.social")) { 775 896 const res = await fetch( 776 - `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${ 777 - encodeURIComponent(handle) 897 + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle) 778 898 }`, 779 899 ); 780 900 if (!res.ok) {
-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 - }
+358 -28
frontend/src/lib/migration/flow.svelte.ts
··· 2 2 InboundMigrationState, 3 3 InboundStep, 4 4 MigrationProgress, 5 + OutboundMigrationState, 6 + OutboundStep, 5 7 PasskeyAccountSetup, 6 8 ServerDescription, 7 9 StoredMigrationState, ··· 9 11 import { 10 12 AtprotoClient, 11 13 buildOAuthAuthorizationUrl, 14 + buildParAuthorizationUrl, 12 15 clearDPoPKey, 13 16 createLocalClient, 14 17 exchangeOAuthCode, ··· 19 22 getMigrationOAuthRedirectUri, 20 23 getOAuthServerMetadata, 21 24 loadDPoPKey, 25 + pushAuthorizationRequest, 22 26 resolvePdsUrl, 23 27 saveDPoPKey, 24 28 } from "./atproto-client"; ··· 28 32 updateProgress, 29 33 updateStep, 30 34 } from "./storage"; 31 - import { migrateBlobs as migrateBlobsUtil } from "./blob-migration"; 32 35 33 36 function migrationLog(stage: string, data?: Record<string, unknown>) { 34 37 const timestamp = new Date().toISOString(); ··· 56 59 } 57 60 58 61 export function createInboundMigrationFlow() { 62 + // @ts-ignore 59 63 let state = $state<InboundMigrationState>({ 60 64 direction: "inbound", 61 65 step: "welcome", ··· 84 88 let sourceClient: AtprotoClient | null = null; 85 89 let localClient: AtprotoClient | null = null; 86 90 let localServerInfo: ServerDescription | null = null; 87 - let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> = 88 - null; 91 + let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> = null; 89 92 90 93 function setStep(step: InboundStep) { 91 94 state.step = step; 92 95 state.error = null; 93 - if (step !== "success") { 94 - saveMigrationState(state); 95 - updateStep(step); 96 - } 96 + saveMigrationState(state); 97 + updateStep(step); 97 98 } 98 99 99 - function setError(error: string) { 100 + function setError(error: string | null) { 100 101 state.error = error; 101 102 saveMigrationState(state); 102 103 } ··· 156 157 localStorage.setItem("migration_source_handle", state.sourceHandle); 157 158 localStorage.setItem("migration_oauth_issuer", metadata.issuer); 158 159 159 - const authUrl = buildOAuthAuthorizationUrl(metadata, { 160 - clientId: getMigrationOAuthClientId(), 161 - redirectUri: getMigrationOAuthRedirectUri(), 162 - codeChallenge, 163 - state: oauthState, 164 - scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*", 165 - dpopJkt: dpopKeyPair.thumbprint, 166 - loginHint: state.sourceHandle, 167 - }); 160 + let authUrl: string; 161 + 162 + if (metadata.pushed_authorization_request_endpoint) { 163 + migrationLog("initiateOAuthLogin: Using PAR flow"); 164 + const parResponse = await pushAuthorizationRequest( 165 + metadata, 166 + { 167 + clientId: getMigrationOAuthClientId(), 168 + redirectUri: getMigrationOAuthRedirectUri(), 169 + codeChallenge, 170 + state: oauthState, 171 + scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*", 172 + dpopJkt: dpopKeyPair.thumbprint, 173 + loginHint: state.sourceHandle, 174 + }, 175 + dpopKeyPair 176 + ); 177 + 178 + authUrl = buildParAuthorizationUrl( 179 + metadata, 180 + getMigrationOAuthClientId(), 181 + parResponse.request_uri 182 + ); 183 + } else { 184 + migrationLog("initiateOAuthLogin: Using standard OAuth flow"); 185 + authUrl = buildOAuthAuthorizationUrl(metadata, { 186 + clientId: getMigrationOAuthClientId(), 187 + redirectUri: getMigrationOAuthRedirectUri(), 188 + codeChallenge, 189 + state: oauthState, 190 + scope: "atproto identity:* rpc:com.atproto.server.createAccount?aud=*", 191 + dpopJkt: dpopKeyPair.thumbprint, 192 + loginHint: state.sourceHandle, 193 + }); 194 + } 168 195 169 196 migrationLog("initiateOAuthLogin: Redirecting to authorization", { 170 197 sourcePdsUrl: state.sourcePdsUrl, ··· 461 488 async function migrateBlobs(): Promise<void> { 462 489 if (!sourceClient || !localClient) return; 463 490 464 - const result = await migrateBlobsUtil( 465 - localClient, 466 - sourceClient, 467 - state.sourceDid, 468 - setProgress, 469 - ); 491 + let cursor: string | undefined; 492 + let migrated = 0; 470 493 471 - state.progress.blobsFailed = result.failed; 494 + do { 495 + const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs( 496 + cursor, 497 + 100, 498 + ); 499 + 500 + for (const blob of blobs) { 501 + try { 502 + setProgress({ 503 + currentOperation: `Migrating blob ${migrated + 1 504 + }/${state.progress.blobsTotal}...`, 505 + }); 506 + 507 + const blobData = await sourceClient.getBlob( 508 + state.sourceDid, 509 + blob.cid, 510 + ); 511 + await localClient.uploadBlob(blobData, "application/octet-stream"); 512 + migrated++; 513 + setProgress({ blobsMigrated: migrated }); 514 + } catch { 515 + state.progress.blobsFailed.push(blob.cid); 516 + } 517 + } 518 + 519 + cursor = nextCursor; 520 + } while (cursor); 472 521 } 473 522 474 523 async function migratePreferences(): Promise<void> { ··· 493 542 setError(null); 494 543 495 544 try { 496 - await localClient.verifyToken(token, state.targetEmail); 545 + await localClient.verifyToken(token, state.targetEmail || ""); 497 546 498 547 if (!sourceClient) { 499 548 setStep("source-handle"); ··· 558 607 559 608 checkingEmailVerification = true; 560 609 try { 561 - const verified = await localClient.checkEmailVerified(state.targetEmail); 562 - if (!verified) return false; 563 - 564 610 await localClient.loginDeactivated( 565 611 state.targetEmail, 566 612 state.targetPassword, ··· 961 1007 }; 962 1008 } 963 1009 1010 + export function createOutboundMigrationFlow() { 1011 + // @ts-ignore 1012 + let state = $state<OutboundMigrationState>({ 1013 + direction: "outbound", 1014 + step: "welcome", 1015 + localDid: "", 1016 + localHandle: "", 1017 + targetPdsUrl: "", 1018 + targetPdsDid: "", 1019 + targetHandle: "", 1020 + targetEmail: "", 1021 + targetPassword: "", 1022 + inviteCode: "", 1023 + targetAccessToken: null, 1024 + targetRefreshToken: null, 1025 + serviceAuthToken: null, 1026 + plcToken: "", 1027 + progress: createInitialProgress(), 1028 + error: null, 1029 + targetServerInfo: null, 1030 + }); 1031 + 1032 + let localClient: AtprotoClient | null = null; 1033 + let targetClient: AtprotoClient | null = null; 1034 + 1035 + function setStep(step: OutboundStep) { 1036 + state.step = step; 1037 + state.error = null; 1038 + saveMigrationState(state); 1039 + updateStep(step); 1040 + } 1041 + 1042 + function setError(error: string) { 1043 + state.error = error; 1044 + saveMigrationState(state); 1045 + } 1046 + 1047 + function setProgress(updates: Partial<MigrationProgress>) { 1048 + state.progress = { ...state.progress, ...updates }; 1049 + updateProgress(updates); 1050 + } 1051 + 1052 + async function validateTargetPds(url: string): Promise<ServerDescription> { 1053 + const normalizedUrl = url.replace(/\/$/, ""); 1054 + targetClient = new AtprotoClient(normalizedUrl); 1055 + 1056 + try { 1057 + const serverInfo = await targetClient.describeServer(); 1058 + state.targetPdsUrl = normalizedUrl; 1059 + state.targetPdsDid = serverInfo.did; 1060 + state.targetServerInfo = serverInfo; 1061 + return serverInfo; 1062 + } catch (e) { 1063 + throw new Error(`Could not connect to PDS: ${(e as Error).message}`); 1064 + } 1065 + } 1066 + 1067 + function initLocalClient( 1068 + accessToken: string, 1069 + did?: string, 1070 + handle?: string, 1071 + ): void { 1072 + localClient = createLocalClient(); 1073 + localClient.setAccessToken(accessToken); 1074 + if (did) { 1075 + state.localDid = did; 1076 + } 1077 + if (handle) { 1078 + state.localHandle = handle; 1079 + } 1080 + } 1081 + 1082 + async function startMigration(currentDid: string): Promise<void> { 1083 + if (!localClient || !targetClient) { 1084 + throw new Error("Not connected to PDSes"); 1085 + } 1086 + 1087 + setStep("migrating"); 1088 + setProgress({ currentOperation: "Getting service auth token..." }); 1089 + 1090 + try { 1091 + const { token } = await localClient.getServiceAuth( 1092 + state.targetPdsDid, 1093 + "com.atproto.server.createAccount", 1094 + ); 1095 + state.serviceAuthToken = token; 1096 + 1097 + setProgress({ currentOperation: "Creating account on new PDS..." }); 1098 + 1099 + const accountParams = { 1100 + did: currentDid, 1101 + handle: state.targetHandle, 1102 + email: state.targetEmail, 1103 + password: state.targetPassword, 1104 + inviteCode: state.inviteCode || undefined, 1105 + }; 1106 + 1107 + const session = await targetClient.createAccount(accountParams, token); 1108 + state.targetAccessToken = session.accessJwt; 1109 + state.targetRefreshToken = session.refreshJwt; 1110 + targetClient.setAccessToken(session.accessJwt); 1111 + 1112 + setProgress({ currentOperation: "Exporting repository..." }); 1113 + 1114 + const car = await localClient.getRepo(currentDid); 1115 + setProgress({ 1116 + repoExported: true, 1117 + currentOperation: "Importing repository...", 1118 + }); 1119 + 1120 + await targetClient.importRepo(car); 1121 + setProgress({ 1122 + repoImported: true, 1123 + currentOperation: "Counting blobs...", 1124 + }); 1125 + 1126 + const accountStatus = await targetClient.checkAccountStatus(); 1127 + setProgress({ 1128 + blobsTotal: accountStatus.expectedBlobs, 1129 + currentOperation: "Migrating blobs...", 1130 + }); 1131 + 1132 + await migrateBlobs(currentDid); 1133 + 1134 + setProgress({ currentOperation: "Migrating preferences..." }); 1135 + await migratePreferences(); 1136 + 1137 + setProgress({ currentOperation: "Requesting PLC operation token..." }); 1138 + await localClient.requestPlcOperationSignature(); 1139 + 1140 + setStep("plc-token"); 1141 + } catch (e) { 1142 + const err = e as Error & { error?: string; status?: number }; 1143 + const message = err.message || err.error || 1144 + `Unknown error (status ${err.status || "unknown"})`; 1145 + setError(message); 1146 + setStep("error"); 1147 + } 1148 + } 1149 + 1150 + async function migrateBlobs(did: string): Promise<void> { 1151 + if (!localClient || !targetClient) return; 1152 + 1153 + let cursor: string | undefined; 1154 + let migrated = 0; 1155 + 1156 + do { 1157 + const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs( 1158 + cursor, 1159 + 100, 1160 + ); 1161 + 1162 + for (const blob of blobs) { 1163 + try { 1164 + setProgress({ 1165 + currentOperation: `Migrating blob ${migrated + 1 1166 + }/${state.progress.blobsTotal}...`, 1167 + }); 1168 + 1169 + const blobData = await localClient.getBlob(did, blob.cid); 1170 + await targetClient.uploadBlob(blobData, "application/octet-stream"); 1171 + migrated++; 1172 + setProgress({ blobsMigrated: migrated }); 1173 + } catch { 1174 + state.progress.blobsFailed.push(blob.cid); 1175 + } 1176 + } 1177 + 1178 + cursor = nextCursor; 1179 + } while (cursor); 1180 + } 1181 + 1182 + async function migratePreferences(): Promise<void> { 1183 + if (!localClient || !targetClient) return; 1184 + 1185 + try { 1186 + const prefs = await localClient.getPreferences(); 1187 + await targetClient.putPreferences(prefs); 1188 + setProgress({ prefsMigrated: true }); 1189 + } catch { /* optional, best-effort */ } 1190 + } 1191 + 1192 + async function submitPlcToken(token: string): Promise<void> { 1193 + if (!localClient || !targetClient) { 1194 + throw new Error("Not connected to PDSes"); 1195 + } 1196 + 1197 + state.plcToken = token; 1198 + setStep("finalizing"); 1199 + setProgress({ currentOperation: "Signing PLC operation..." }); 1200 + 1201 + try { 1202 + const credentials = await targetClient.getRecommendedDidCredentials(); 1203 + 1204 + const { operation } = await localClient.signPlcOperation({ 1205 + token, 1206 + ...credentials, 1207 + }); 1208 + 1209 + setProgress({ 1210 + plcSigned: true, 1211 + currentOperation: "Submitting PLC operation...", 1212 + }); 1213 + 1214 + await targetClient.submitPlcOperation(operation); 1215 + 1216 + setProgress({ currentOperation: "Activating account on new PDS..." }); 1217 + await targetClient.activateAccount(); 1218 + setProgress({ activated: true }); 1219 + 1220 + setProgress({ currentOperation: "Deactivating old account..." }); 1221 + try { 1222 + await localClient.deactivateAccount(state.targetPdsUrl); 1223 + setProgress({ deactivated: true }); 1224 + } catch { /* optional, best-effort */ } 1225 + 1226 + setStep("success"); 1227 + clearMigrationState(); 1228 + } catch (e) { 1229 + const err = e as Error & { error?: string; status?: number }; 1230 + const message = err.message || err.error || 1231 + `Unknown error (status ${err.status || "unknown"})`; 1232 + setError(message); 1233 + setStep("plc-token"); 1234 + } 1235 + } 1236 + 1237 + async function resendPlcToken(): Promise<void> { 1238 + if (!localClient) { 1239 + throw new Error("Not connected to local PDS"); 1240 + } 1241 + await localClient.requestPlcOperationSignature(); 1242 + } 1243 + 1244 + function reset(): void { 1245 + state = { 1246 + direction: "outbound", 1247 + step: "welcome", 1248 + localDid: "", 1249 + localHandle: "", 1250 + targetPdsUrl: "", 1251 + targetPdsDid: "", 1252 + targetHandle: "", 1253 + targetEmail: "", 1254 + targetPassword: "", 1255 + inviteCode: "", 1256 + targetAccessToken: null, 1257 + targetRefreshToken: null, 1258 + serviceAuthToken: null, 1259 + plcToken: "", 1260 + progress: createInitialProgress(), 1261 + error: null, 1262 + targetServerInfo: null, 1263 + }; 1264 + localClient = null; 1265 + targetClient = null; 1266 + clearMigrationState(); 1267 + } 1268 + 1269 + return { 1270 + get state() { 1271 + return state; 1272 + }, 1273 + setStep, 1274 + setError, 1275 + validateTargetPds, 1276 + initLocalClient, 1277 + startMigration, 1278 + submitPlcToken, 1279 + resendPlcToken, 1280 + reset, 1281 + 1282 + updateField<K extends keyof OutboundMigrationState>( 1283 + field: K, 1284 + value: OutboundMigrationState[K], 1285 + ) { 1286 + state[field] = value; 1287 + }, 1288 + }; 1289 + } 1290 + 964 1291 export type InboundMigrationFlow = ReturnType< 965 1292 typeof createInboundMigrationFlow 966 1293 >; 1294 + export type OutboundMigrationFlow = ReturnType< 1295 + typeof createOutboundMigrationFlow 1296 + >;
+2 -8
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"; 5 4 export { 6 5 createInboundMigrationFlow, 6 + createOutboundMigrationFlow, 7 7 type InboundMigrationFlow, 8 + type OutboundMigrationFlow, 8 9 } 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();
+23 -35
frontend/src/lib/migration/types.ts
··· 13 13 | "success" 14 14 | "error"; 15 15 16 - export type OfflineInboundStep = 16 + export type AuthMethod = "password" | "passkey"; 17 + 18 + export type OutboundStep = 17 19 | "welcome" 18 - | "provide-did" 19 - | "upload-car" 20 - | "provide-rotation-key" 21 - | "choose-handle" 20 + | "target-pds" 21 + | "new-account" 22 22 | "review" 23 - | "creating" 24 - | "importing" 25 - | "migrating-blobs" 26 - | "plc-signing" 27 - | "email-verify" 28 - | "passkey-setup" 29 - | "app-password" 23 + | "migrating" 24 + | "plc-token" 30 25 | "finalizing" 31 26 | "success" 32 27 | "error"; 33 28 34 - export type AuthMethod = "password" | "passkey"; 35 - 36 - export type MigrationDirection = "inbound"; 29 + export type MigrationDirection = "inbound" | "outbound"; 37 30 38 31 export interface MigrationProgress { 39 32 repoExported: boolean; ··· 75 68 resumeToStep?: InboundStep; 76 69 } 77 70 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; 71 + export interface OutboundMigrationState { 72 + direction: "outbound"; 73 + step: OutboundStep; 74 + localDid: string; 75 + localHandle: string; 76 + targetPdsUrl: string; 77 + targetPdsDid: string; 89 78 targetHandle: string; 90 79 targetEmail: string; 91 80 targetPassword: string; 92 81 inviteCode: 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; 82 + targetAccessToken: string | null; 83 + targetRefreshToken: string | null; 84 + serviceAuthToken: string | null; 85 + plcToken: string; 100 86 progress: MigrationProgress; 101 87 error: string | null; 102 - plcUpdatedTemporarily: boolean; 88 + targetServerInfo: ServerDescription | null; 103 89 } 104 90 105 - export type MigrationState = InboundMigrationState; 91 + export type MigrationState = InboundMigrationState | OutboundMigrationState; 106 92 107 93 export interface StoredMigrationState { 108 94 version: 1; ··· 254 240 issuer: string; 255 241 authorization_endpoint: string; 256 242 token_endpoint: string; 243 + pushed_authorization_request_endpoint?: string; 244 + require_pushed_authorization_requests?: boolean; 257 245 scopes_supported?: string[]; 258 246 response_types_supported?: string[]; 259 247 grant_types_supported?: string[];
+98 -152
frontend/src/locales/en.json
··· 17 17 "dashboard": "Dashboard", 18 18 "backToDashboard": "← Dashboard", 19 19 "copied": "Copied!", 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" 20 + "copyToClipboard": "Copy to Clipboard" 67 21 }, 68 22 "login": { 69 23 "title": "Sign In", ··· 95 49 "codeLabel": "Verification Code", 96 50 "codePlaceholder": "Enter 6-digit code", 97 51 "verifyButton": "Verify Account", 98 - "resent": "Verification code resent!" 52 + "verifying": "Verifying...", 53 + "resendButton": "Resend Code", 54 + "resending": "Resending...", 55 + "resent": "Verification code resent!", 56 + "backToLogin": "Back to Login" 99 57 }, 100 58 "register": { 101 59 "title": "Create Account", ··· 166 124 "inviteCodePlaceholder": "Enter your invite code", 167 125 "inviteCodeRequired": "required", 168 126 "createButton": "Create Account", 127 + "creating": "Creating account...", 169 128 "alreadyHaveAccount": "Already have an account?", 170 129 "signIn": "Sign in", 171 130 "wantPasswordless": "Want passwordless security?", ··· 220 179 "navAdminDesc": "Server stats and admin operations", 221 180 "navDidDocument": "DID Document", 222 181 "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", 228 182 "migrated": "Migrated", 229 183 "migratedTitle": "Account Migrated", 230 184 "migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.", ··· 254 208 "serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.", 255 209 "currentPds": "Current PDS URL", 256 210 "save": "Save Changes", 211 + "saving": "Saving...", 257 212 "success": "DID document updated successfully", 258 213 "saveFailed": "Failed to save DID document", 259 214 "loadFailed": "Failed to load DID document", ··· 291 246 "yourDomain": "Your Domain", 292 247 "yourDomainPlaceholder": "example.com", 293 248 "verifyAndUpdate": "Verify & Update Handle", 249 + "verifying": "Verifying...", 294 250 "newHandle": "New Handle", 295 251 "newHandlePlaceholder": "yourhandle", 296 252 "changeHandleButton": "Change Handle", ··· 306 262 "exportData": "Export Data", 307 263 "exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.", 308 264 "downloadRepo": "Download Repository", 309 - "downloadBlobs": "Download Media", 310 265 "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 - }, 337 266 "deleteAccount": "Delete Account", 338 267 "deleteWarning": "This action is irreversible. All your data will be permanently deleted.", 339 268 "requestDeletion": "Request Account Deletion", ··· 362 291 "deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.", 363 292 "deletionFailed": "Failed to delete account", 364 293 "repoExported": "Repository exported successfully", 365 - "blobsExported": "Media files exported successfully", 366 - "noBlobsToExport": "No media files to export", 367 - "exportFailed": "Failed to export", 294 + "exportFailed": "Failed to export repository", 368 295 "confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone." 369 296 } 370 297 }, ··· 379 306 "noPasswords": "No app passwords yet", 380 307 "revoke": "Revoke", 381 308 "revoking": "Revoking...", 309 + "creating": "Creating...", 382 310 "revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.", 383 311 "saveWarningTitle": "Important: Save this app password!", 384 312 "saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.", ··· 426 354 "used": "Used by @{handle}", 427 355 "disabled": "Disabled", 428 356 "usedBy": "Used by", 357 + "creating": "Creating...", 429 358 "disableConfirm": "Disable this invite code? It can no longer be used.", 430 359 "created": "Invite Code Created", 431 360 "copy": "Copy", ··· 553 482 "verifyButton": "Verify", 554 483 "verifyCodePlaceholder": "Enter verification code", 555 484 "submit": "Submit", 485 + "saving": "Saving...", 556 486 "savePreferences": "Save Preferences", 557 487 "preferencesSaved": "Communication preferences saved", 558 488 "verifiedSuccess": "{channel} verified successfully", ··· 591 521 "noCollectionsYet": "No collections yet. Create your first record to get started.", 592 522 "loadMore": "Load More", 593 523 "recordJson": "Record JSON", 524 + "saving": "Saving...", 594 525 "updateRecord": "Update Record", 595 526 "collectionNsid": "Collection (NSID)", 596 527 "recordKeyOptional": "Record Key (optional)", 597 528 "autoGenerated": "Auto-generated if empty (TID)", 598 529 "autoGeneratedHint": "Leave empty to auto-generate a TID-based key", 530 + "creating": "Creating...", 599 531 "demoPostText": "Hello from my PDS! This is my first post.", 600 532 "demoDisplayName": "Your Display Name", 601 533 "demoBio": "A short bio about yourself." ··· 619 551 "secondaryLight": "Secondary (Light Mode)", 620 552 "secondaryDark": "Secondary (Dark Mode)", 621 553 "configSaved": "Server configuration saved", 554 + "saving": "Saving...", 622 555 "saveConfig": "Save Configuration", 623 556 "serverStats": "Server Statistics", 624 557 "users": "Users", ··· 706 639 "title": "Two-Factor Authentication", 707 640 "subtitle": "Additional verification is required", 708 641 "usePasskey": "Use Passkey", 709 - "useTotp": "Use Authenticator App" 642 + "useTotp": "Use Authenticator App", 643 + "verifying": "Verifying..." 710 644 }, 711 645 "twoFactorCode": { 712 646 "title": "Two-Factor Authentication", 713 647 "subtitle": "A verification code has been sent to your {channel}. Enter the code below to continue.", 714 648 "codeLabel": "Verification Code", 715 649 "codePlaceholder": "Enter 6-digit code", 650 + "verify": "Verify", 651 + "verifying": "Verifying...", 716 652 "errors": { 717 653 "missingRequestUri": "Missing request_uri parameter", 718 654 "verificationFailed": "Verification failed", ··· 724 660 "title": "Enter Authenticator Code", 725 661 "subtitle": "Enter the 6-digit code from your authenticator app", 726 662 "codePlaceholder": "Enter 6-digit code", 663 + "verify": "Verify", 664 + "verifying": "Verifying...", 727 665 "useBackupCode": "Use backup code instead", 728 666 "backupCodePlaceholder": "Enter backup code", 729 667 "trustDevice": "Trust this device for 30 days", ··· 753 691 "codeLabel": "Verification Code", 754 692 "codeHelp": "Copy the entire code from your message, including dashes", 755 693 "verifyButton": "Verify Account", 694 + "verify": "Verify", 695 + "verifying": "Verifying...", 756 696 "pleaseWait": "Please wait...", 697 + "resendCode": "Resend Code", 698 + "resending": "Resending...", 699 + "sending": "Sending...", 757 700 "codeResent": "Verification code resent!", 758 701 "codeResentDetail": "Verification code sent! Check your inbox.", 702 + "backToLogin": "Back to Login", 703 + "backToSettings": "Back to Settings", 759 704 "verifyingAccount": "Verifying account: @{handle}", 760 705 "startOver": "Start over with a different account", 761 706 "noPending": "No pending verification found.", ··· 801 746 "resetButton": "Reset Password", 802 747 "resetting": "Resetting...", 803 748 "success": "Password reset successfully!", 749 + "backToLogin": "Back to Sign In", 804 750 "requestNewCode": "Request New Code", 805 751 "passwordsMismatch": "Passwords do not match", 806 752 "passwordLength": "Password must be at least 8 characters" ··· 844 790 "howItWorks": "How it works", 845 791 "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.", 846 792 "sendRecoveryLink": "Send Recovery Link", 847 - "sending": "Sending..." 793 + "sending": "Sending...", 794 + "backToLogin": "Back to Sign In" 848 795 }, 849 796 "registerPasskey": { 850 797 "title": "Create Passkey Account", ··· 867 814 "inviteCode": "Invite Code", 868 815 "inviteCodePlaceholder": "Enter your invite code", 869 816 "createButton": "Create Account", 817 + "creating": "Creating...", 870 818 "continue": "Continue", 871 819 "back": "Back", 872 820 "alreadyHaveAccount": "Already have an account?", ··· 963 911 "useTotp": "Use Authenticator", 964 912 "passwordPlaceholder": "Enter your password", 965 913 "totpPlaceholder": "Enter 6-digit code", 914 + "verify": "Verify", 915 + "verifying": "Verifying...", 966 916 "authenticating": "Authenticating...", 967 917 "passkeyPrompt": "Click the button below to authenticate with your passkey.", 968 918 "cancel": "Cancel" ··· 997 947 "handle": "Handle", 998 948 "emailOptional": "Email (optional)", 999 949 "yourAccessLevel": "Your Access Level", 950 + "creating": "Creating...", 1000 951 "createAccount": "Create Account", 1001 952 "createDelegatedAccountButton": "+ Create Delegated Account", 1002 953 "accountCreated": "Created delegated account: {handle}", ··· 1108 1059 "navDesc": "Move your account to or from another PDS", 1109 1060 "migrateHere": "Migrate Here", 1110 1061 "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", 1111 1065 "bringDid": "Bring your DID and identity", 1112 1066 "transferData": "Transfer all your data", 1113 1067 "keepFollowers": "Keep your followers", 1068 + "exportRepo": "Export your repository", 1069 + "transferToPds": "Transfer to new PDS", 1070 + "updateIdentity": "Update your identity", 1114 1071 "whatIsMigration": "What is account migration?", 1115 1072 "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.", 1116 1073 "beforeMigrate": "Before you migrate", ··· 1120 1077 "beforeMigrate4": "Your old PDS will be notified to deactivate your account", 1121 1078 "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.", 1122 1079 "learnMore": "Learn more about migration risks", 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", 1080 + "comingSoon": "Coming soon", 1128 1081 "oauthCompleting": "Completing authentication...", 1129 1082 "oauthFailed": "Authentication Failed", 1130 1083 "tryAgain": "Try Again", ··· 1133 1086 "incomplete": "You have an incomplete migration in progress:", 1134 1087 "direction": "Direction", 1135 1088 "migratingHere": "Migrating here", 1089 + "migratingAway": "Migrating away", 1136 1090 "from": "From", 1137 1091 "to": "To", 1138 1092 "progress": "Progress", ··· 1275 1229 "error": { 1276 1230 "title": "Migration Error", 1277 1231 "desc": "An error occurred during migration.", 1278 - "startOver": "Start Over", 1279 - "unknown": "An unknown error occurred." 1232 + "startOver": "Start Over" 1280 1233 }, 1281 1234 "common": { 1282 1235 "back": "Back", ··· 1294 1247 "warning3": "Your old account will be deactivated after migration" 1295 1248 } 1296 1249 }, 1297 - "offline": { 1250 + "outbound": { 1298 1251 "welcome": { 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" 1308 - }, 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." 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" 1322 1258 }, 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.", 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", 1335 1265 "validating": "Validating...", 1336 - "validate": "Validate Key" 1266 + "connected": "Connected to {name}", 1267 + "inviteRequired": "Invite code required", 1268 + "privacyPolicy": "Privacy Policy", 1269 + "termsOfService": "Terms of Service" 1337 1270 }, 1338 - "chooseHandle": { 1339 - "migratingDid": "Restoring DID" 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" 1340 1280 }, 1341 1281 "review": { 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." 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" 1348 1290 }, 1349 1291 "migrating": { 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" 1292 + "title": "Migrating Your Account", 1293 + "desc": "Please wait while we transfer your data..." 1356 1294 }, 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." 1295 + "plcToken": { 1296 + "title": "Verify Your Identity", 1297 + "desc": "A verification code has been sent to your email." 1298 + }, 1299 + "finalizing": { 1300 + "title": "Finalizing Migration", 1301 + "desc": "Please wait while we complete the migration...", 1302 + "updatingForwarding": "Updating DID document forwarding..." 1365 1303 }, 1366 1304 "success": { 1367 - "desc": "Your account has been successfully restored to this PDS." 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..." 1368 1314 } 1369 1315 }, 1370 1316 "progress": {
+100 -154
frontend/src/locales/fi.json
··· 17 17 "dashboard": "Hallintapaneeli", 18 18 "backToDashboard": "← Hallintapaneeli", 19 19 "copied": "Kopioitu!", 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ä" 20 + "copyToClipboard": "Kopioi" 67 21 }, 68 22 "login": { 69 23 "title": "Kirjaudu sisään", ··· 95 49 "codeLabel": "Vahvistuskoodi", 96 50 "codePlaceholder": "Syötä 6-numeroinen koodi", 97 51 "verifyButton": "Vahvista tili", 98 - "resent": "Vahvistuskoodi lähetetty uudelleen!" 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" 99 57 }, 100 58 "register": { 101 59 "title": "Luo tili", ··· 166 124 "inviteCodePlaceholder": "Syötä kutsukoodisi", 167 125 "inviteCodeRequired": "vaaditaan", 168 126 "createButton": "Luo tili", 127 + "creating": "Luodaan tiliä...", 169 128 "alreadyHaveAccount": "Onko sinulla jo tili?", 170 129 "signIn": "Kirjaudu sisään", 171 130 "wantPasswordless": "Haluatko salasanattoman turvallisuuden?", ··· 220 179 "navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot", 221 180 "navDidDocument": "DID-dokumentti", 222 181 "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", 228 182 "migrated": "Siirretty", 229 183 "migratedTitle": "Tili siirretty", 230 184 "migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isännöidään edelleen täällä, ja voit päivittää sen tulevia siirtoja varten.", ··· 254 208 "serviceEndpointDesc": "PDS, joka tällä hetkellä isännöi tilitietojasi. Päivitä tämä siirron yhteydessä.", 255 209 "currentPds": "Nykyinen PDS-URL", 256 210 "save": "Tallenna muutokset", 211 + "saving": "Tallennetaan...", 257 212 "success": "DID-dokumentti päivitetty onnistuneesti", 258 213 "saveFailed": "DID-dokumentin tallennus epäonnistui", 259 214 "loadFailed": "DID-dokumentin lataus epäonnistui", ··· 291 246 "yourDomain": "Verkkotunnuksesi", 292 247 "yourDomainPlaceholder": "esimerkki.fi", 293 248 "verifyAndUpdate": "Vahvista ja päivitä käyttäjänimi", 249 + "verifying": "Vahvistetaan...", 294 250 "newHandle": "Uusi käyttäjänimi", 295 251 "newHandlePlaceholder": "käyttäjänimesi", 296 252 "changeHandleButton": "Vaihda käyttäjänimi", ··· 306 262 "exportData": "Vie tiedot", 307 263 "exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tämä sisältää kaikki julkaisusi, tykkäyksesi, seuraamisesi ja muut tiedot.", 308 264 "downloadRepo": "Lataa tietovarasto", 309 - "downloadBlobs": "Lataa media", 310 265 "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 - }, 337 266 "deleteAccount": "Poista tili", 338 267 "deleteWarning": "Tämä toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvästi.", 339 268 "requestDeletion": "Pyydä tilin poistoa", ··· 362 291 "deleteConfirmation": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua.", 363 292 "deletionFailed": "Tilin poisto epäonnistui", 364 293 "repoExported": "Tietovarasto viety", 365 - "blobsExported": "Mediatiedostot viety", 366 - "noBlobsToExport": "Ei vietäviä mediatiedostoja", 367 - "exportFailed": "Vienti epäonnistui", 294 + "exportFailed": "Tietovaraston vienti epäonnistui", 368 295 "confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua." 369 296 } 370 297 }, ··· 379 306 "noPasswords": "Ei vielä sovellusten salasanoja", 380 307 "revoke": "Peruuta", 381 308 "revoking": "Peruutetaan...", 309 + "creating": "Luodaan...", 382 310 "revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.", 383 311 "saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!", 384 312 "saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.", ··· 426 354 "used": "Käyttänyt @{handle}", 427 355 "disabled": "Poistettu käytöstä", 428 356 "usedBy": "Käyttänyt", 357 + "creating": "Luodaan...", 429 358 "disableConfirm": "Poista tämä kutsukoodi käytöstä? Sitä ei voi enää käyttää.", 430 359 "created": "Kutsukoodi luotu", 431 360 "copy": "Kopioi", ··· 553 482 "verifyButton": "Vahvista", 554 483 "verifyCodePlaceholder": "Syötä vahvistuskoodi", 555 484 "submit": "Lähetä", 485 + "saving": "Tallennetaan...", 556 486 "savePreferences": "Tallenna asetukset", 557 487 "preferencesSaved": "Viestintäasetukset tallennettu", 558 488 "verifiedSuccess": "{channel} vahvistettu", ··· 591 521 "noCollectionsYet": "Ei vielä kokoelmia. Luo ensimmäinen tietueesi aloittaaksesi.", 592 522 "loadMore": "Lataa lisää", 593 523 "recordJson": "Tietueen JSON", 524 + "saving": "Tallennetaan...", 594 525 "updateRecord": "Päivitä tietue", 595 526 "collectionNsid": "Kokoelma (NSID)", 596 527 "recordKeyOptional": "Tietueavain (valinnainen)", 597 528 "autoGenerated": "Luodaan automaattisesti jos tyhjä (TID)", 598 529 "autoGeneratedHint": "Jätä tyhjäksi luodaksesi TID-pohjaisen avaimen automaattisesti", 530 + "creating": "Luodaan...", 599 531 "demoPostText": "Hei PDS:ltäni! Tämä on ensimmäinen julkaisuni.", 600 532 "demoDisplayName": "Näyttönimesi", 601 533 "demoBio": "Lyhyt kuvaus itsestäsi." ··· 616 548 "primaryLight": "Ensisijainen (vaalea tila)", 617 549 "primaryDark": "Ensisijainen (tumma tila)", 618 550 "configSaved": "Palvelinasetukset tallennettu", 551 + "saving": "Tallennetaan...", 619 552 "saveConfig": "Tallenna asetukset", 620 553 "serverStats": "Palvelintilastot", 621 554 "users": "Käyttäjät", ··· 706 639 "title": "Kaksivaiheinen tunnistautuminen", 707 640 "subtitle": "Lisävahvistus vaaditaan", 708 641 "usePasskey": "Käytä pääsyavainta", 709 - "useTotp": "Käytä todentajasovellusta" 642 + "useTotp": "Käytä todentajasovellusta", 643 + "verifying": "Vahvistetaan..." 710 644 }, 711 645 "twoFactorCode": { 712 646 "title": "Kaksivaiheinen tunnistautuminen", 713 647 "subtitle": "Vahvistuskoodi on lähetetty {channel}. Syötä koodi alla jatkaaksesi.", 714 648 "codeLabel": "Vahvistuskoodi", 715 649 "codePlaceholder": "Syötä 6-numeroinen koodi", 650 + "verify": "Vahvista", 651 + "verifying": "Vahvistetaan...", 716 652 "errors": { 717 653 "missingRequestUri": "Puuttuva request_uri-parametri", 718 654 "verificationFailed": "Vahvistus epäonnistui", ··· 724 660 "title": "Syötä todentajakoodi", 725 661 "subtitle": "Syötä 6-numeroinen koodi todentajasovelluksestasi", 726 662 "codePlaceholder": "Syötä 6-numeroinen koodi", 663 + "verify": "Vahvista", 664 + "verifying": "Vahvistetaan...", 727 665 "useBackupCode": "Käytä varakoodia sen sijaan", 728 666 "backupCodePlaceholder": "Syötä varakoodi", 729 667 "trustDevice": "Luota tähän laitteeseen 30 päivää", ··· 753 691 "codeLabel": "Vahvistuskoodi", 754 692 "codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat", 755 693 "verifyButton": "Vahvista tili", 694 + "verify": "Vahvista", 695 + "verifying": "Vahvistetaan...", 756 696 "pleaseWait": "Odota...", 697 + "sending": "Lähetetään...", 698 + "resendCode": "Lähetä koodi uudelleen", 699 + "resending": "Lähetetään uudelleen...", 757 700 "codeResent": "Vahvistuskoodi lähetetty uudelleen!", 758 701 "codeResentDetail": "Vahvistuskoodi lähetetty! Tarkista saapuneet-kansiosi.", 759 702 "verified": "Vahvistettu!", ··· 763 706 "identifierLabel": "Sähköposti tai tunniste", 764 707 "identifierPlaceholder": "sinä@esimerkki.fi", 765 708 "identifierHelp": "Sähköpostiosoite tai tunniste, johon koodi lähetettiin", 709 + "backToLogin": "Takaisin kirjautumiseen", 766 710 "verifyingAccount": "Vahvistetaan tiliä: @{handle}", 767 711 "startOver": "Aloita alusta toisella tilillä", 768 712 "noPending": "Odottavaa vahvistusta ei löytynyt.", 769 713 "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.", 770 714 "createAccount": "Luo tili", 771 715 "signIn": "Kirjaudu sisään", 716 + "backToSettings": "Takaisin asetuksiin", 772 717 "emailUpdateCodeHelp": "Koodi lähetettiin nykyiseen sähköpostiosoitteeseesi", 773 718 "emailUpdateFailed": "Sähköpostiosoitteen päivitys epäonnistui", 774 719 "emailUpdateRequiresAuth": "Sinun on kirjauduttava sisään päivittääksesi sähköpostiosoitteesi.", ··· 801 746 "resetButton": "Palauta salasana", 802 747 "resetting": "Palautetaan...", 803 748 "success": "Salasana palautettu!", 749 + "backToLogin": "Takaisin kirjautumiseen", 804 750 "requestNewCode": "Pyydä uusi koodi", 805 751 "passwordsMismatch": "Salasanat eivät täsmää", 806 752 "passwordLength": "Salasanan on oltava vähintään 8 merkkiä" ··· 844 790 "howItWorks": "Miten se toimii", 845 791 "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.", 846 792 "sendRecoveryLink": "Lähetä palautuslinkki", 847 - "sending": "Lähetetään..." 793 + "sending": "Lähetetään...", 794 + "backToLogin": "Takaisin kirjautumiseen" 848 795 }, 849 796 "registerPasskey": { 850 797 "title": "Luo pääsyavaintili", ··· 865 812 "externalDid": "Sinun did:web", 866 813 "externalDidPlaceholder": "did:web:verkkotunnuksesi.fi", 867 814 "createButton": "Luo tili", 815 + "creating": "Luodaan...", 868 816 "alreadyHaveAccount": "Onko sinulla jo tili?", 869 817 "signIn": "Kirjaudu sisään", 870 818 "wantPassword": "Haluatko käyttää salasanaa?", ··· 963 911 "useTotp": "Käytä todentajaa", 964 912 "passwordPlaceholder": "Syötä salasanasi", 965 913 "totpPlaceholder": "Syötä 6-numeroinen koodi", 914 + "verify": "Vahvista", 915 + "verifying": "Vahvistetaan...", 966 916 "authenticating": "Todennetaan...", 967 917 "passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.", 968 918 "cancel": "Peruuta" ··· 1017 967 "handle": "Käyttäjänimi", 1018 968 "emailOptional": "Sähköposti (valinnainen)", 1019 969 "yourAccessLevel": "Käyttöoikeustasosi", 970 + "creating": "Luodaan...", 1020 971 "createAccount": "Luo tili", 1021 972 "createDelegatedAccountButton": "+ Luo delegoitu tili", 1022 973 "accountCreated": "Delegoitu tili luotu: {handle}", ··· 1108 1059 "navDesc": "Siirrä tilisi toiseen tai toisesta PDS:stä", 1109 1060 "migrateHere": "Siirrä tänne", 1110 1061 "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", 1111 1065 "bringDid": "Tuo DID ja identiteettisi", 1112 1066 "transferData": "Siirrä kaikki tietosi", 1113 1067 "keepFollowers": "Säilytä seuraajasi", 1068 + "exportRepo": "Vie tietovarastosi", 1069 + "transferToPds": "Siirrä uuteen PDS:ään", 1070 + "updateIdentity": "Päivitä identiteettisi", 1114 1071 "whatIsMigration": "Mikä on tilin siirto?", 1115 1072 "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.", 1116 1073 "beforeMigrate": "Ennen siirtoa", ··· 1120 1077 "beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista", 1121 1078 "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ä.", 1122 1079 "learnMore": "Lue lisää siirron riskeistä", 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", 1080 + "comingSoon": "Tulossa pian", 1128 1081 "oauthCompleting": "Viimeistellään todennusta...", 1129 1082 "oauthFailed": "Todennus epäonnistui", 1130 1083 "tryAgain": "Yritä uudelleen", ··· 1133 1086 "incomplete": "Sinulla on keskeneräinen siirto:", 1134 1087 "direction": "Suunta", 1135 1088 "migratingHere": "Siirretään tänne", 1089 + "migratingAway": "Siirretään pois", 1136 1090 "from": "Mistä", 1137 1091 "to": "Minne", 1138 1092 "progress": "Edistyminen", ··· 1275 1229 "error": { 1276 1230 "title": "Siirtovirhe", 1277 1231 "desc": "Siirron aikana tapahtui virhe.", 1278 - "startOver": "Aloita alusta", 1279 - "unknown": "Tuntematon virhe tapahtui." 1232 + "startOver": "Aloita alusta" 1280 1233 }, 1281 1234 "common": { 1282 1235 "back": "Takaisin", ··· 1294 1247 "warning3": "Vanha tilisi deaktivoidaan siirron jälkeen" 1295 1248 } 1296 1249 }, 1297 - "offline": { 1250 + "outbound": { 1298 1251 "welcome": { 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" 1308 - }, 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)" 1314 - }, 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ä." 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" 1322 1258 }, 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" 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" 1337 1270 }, 1338 - "chooseHandle": { 1339 - "migratingDid": "Palautetaan DID" 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" 1340 1280 }, 1341 1281 "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." 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" 1348 1290 }, 1349 1291 "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" 1292 + "title": "Siirretään tiliäsi", 1293 + "desc": "Odota, kun siirrämme tietojasi..." 1356 1294 }, 1357 - "success": { 1358 - "desc": "Tilisi on palautettu onnistuneesti tähän PDS:ään." 1295 + "plcToken": { 1296 + "title": "Vahvista henkilöllisyytesi", 1297 + "desc": "Vahvistuskoodi on lähetetty sähköpostiisi." 1359 1298 }, 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." 1299 + "finalizing": { 1300 + "title": "Viimeistellään siirtoa", 1301 + "desc": "Odota, kun viimeistelemme siirtoa...", 1302 + "updatingForwarding": "Päivitetään DID-dokumentin uudelleenohjausta..." 1303 + }, 1304 + "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..." 1368 1314 } 1369 1315 }, 1370 1316 "progress": {
+100 -147
frontend/src/locales/ja.json
··· 17 17 "dashboard": "ダッシュボード", 18 18 "backToDashboard": "← ダッシュボード", 19 19 "copied": "コピーしました!", 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文字以上必要です" 20 + "copyToClipboard": "クリップボードにコピー" 60 21 }, 61 22 "login": { 62 23 "title": "サインイン", ··· 88 49 "codeLabel": "確認コード", 89 50 "codePlaceholder": "6桁のコードを入力", 90 51 "verifyButton": "確認する", 91 - "resent": "確認コードを再送信しました!" 52 + "verifying": "確認中...", 53 + "resendButton": "コードを再送信", 54 + "resending": "送信中...", 55 + "resent": "確認コードを再送信しました!", 56 + "backToLogin": "ログインに戻る" 92 57 }, 93 58 "register": { 94 59 "title": "アカウント作成", ··· 159 124 "inviteCodePlaceholder": "招待コードを入力", 160 125 "inviteCodeRequired": "必須", 161 126 "createButton": "アカウントを作成", 127 + "creating": "作成中...", 162 128 "alreadyHaveAccount": "すでにアカウントをお持ちですか?", 163 129 "signIn": "サインイン", 164 130 "wantPasswordless": "パスワードレスをご希望ですか?", ··· 213 179 "navAdminDesc": "サーバー統計と管理操作", 214 180 "navDidDocument": "DID ドキュメント", 215 181 "navDidDocumentDesc": "DID ドキュメントとキーを管理", 216 - "navDidDocumentDescActive": "DID ドキュメント設定を編集", 217 - "navBackup": "バックアップをダウンロード", 218 - "navBackupDesc": "リポジトリを CAR ファイルとしてダウンロード", 219 - "downloadingBackup": "ダウンロード中...", 220 - "backupFailed": "バックアップのダウンロードに失敗しました", 221 182 "migrated": "移行済み", 222 183 "migratedTitle": "アカウント移行済み", 223 184 "migratedMessage": "アカウントは {pds} に移行されました。DID ドキュメントは引き続きここでホストされています。", ··· 247 208 "serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。", 248 209 "currentPds": "現在のPDS URL", 249 210 "save": "変更を保存", 211 + "saving": "保存中...", 250 212 "success": "DID ドキュメントを更新しました", 251 213 "saveFailed": "DIDドキュメントの保存に失敗しました", 252 214 "loadFailed": "DIDドキュメントの読み込みに失敗しました", ··· 284 246 "yourDomain": "ドメイン", 285 247 "yourDomainPlaceholder": "example.com", 286 248 "verifyAndUpdate": "確認してハンドルを更新", 249 + "verifying": "確認中...", 287 250 "newHandle": "新しいハンドル", 288 251 "newHandlePlaceholder": "yourhandle", 289 252 "changeHandleButton": "ハンドルを変更", ··· 299 262 "exportData": "データエクスポート", 300 263 "exportDataDescription": "リポジトリ全体を CAR(Content Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。", 301 264 "downloadRepo": "リポジトリをダウンロード", 302 - "downloadBlobs": "メディアをダウンロード", 303 265 "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 - }, 330 266 "deleteAccount": "アカウント削除", 331 267 "deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。", 332 268 "requestDeletion": "アカウント削除をリクエスト", ··· 355 291 "deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。", 356 292 "deletionFailed": "アカウントの削除に失敗しました", 357 293 "repoExported": "リポジトリをエクスポートしました", 358 - "blobsExported": "メディアファイルをエクスポートしました", 359 - "noBlobsToExport": "エクスポートするメディアファイルがありません", 360 - "exportFailed": "エクスポートに失敗しました", 294 + "exportFailed": "リポジトリのエクスポートに失敗しました", 361 295 "confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。" 362 296 } 363 297 }, ··· 372 306 "noPasswords": "アプリパスワードはまだありません", 373 307 "revoke": "取り消す", 374 308 "revoking": "取り消し中...", 309 + "creating": "作成中...", 375 310 "revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。", 376 311 "saveWarningTitle": "重要: このアプリパスワードを保存してください!", 377 312 "saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。", ··· 419 354 "used": "@{handle} が使用済み", 420 355 "disabled": "無効", 421 356 "usedBy": "使用者", 357 + "creating": "作成中...", 422 358 "disableConfirm": "この招待コードを無効にしますか?使用できなくなります。", 423 359 "created": "招待コードを作成しました", 424 360 "copy": "コピー", ··· 546 482 "verifyButton": "確認", 547 483 "verifyCodePlaceholder": "確認コードを入力", 548 484 "submit": "送信", 485 + "saving": "保存中...", 549 486 "savePreferences": "設定を保存", 550 487 "preferencesSaved": "連絡設定を保存しました", 551 488 "verifiedSuccess": "{channel} を確認しました", ··· 584 521 "noCollectionsYet": "コレクションがまだありません。最初のレコードを作成して開始しましょう。", 585 522 "loadMore": "さらに読み込む", 586 523 "recordJson": "レコード JSON", 524 + "saving": "保存中...", 587 525 "updateRecord": "レコードを更新", 588 526 "collectionNsid": "コレクション (NSID)", 589 527 "recordKeyOptional": "レコードキー(任意)", 590 528 "autoGenerated": "空白で自動生成 (TID)", 591 529 "autoGeneratedHint": "空白にすると TID ベースのキーが自動生成されます", 530 + "creating": "作成中...", 592 531 "demoPostText": "こんにちは、私の PDS からの初投稿です!", 593 532 "demoDisplayName": "表示名", 594 533 "demoBio": "自己紹介を書いてください。" ··· 609 548 "primaryLight": "プライマリ(ライトモード)", 610 549 "primaryDark": "プライマリ(ダークモード)", 611 550 "configSaved": "サーバー設定を保存しました", 551 + "saving": "保存中...", 612 552 "saveConfig": "設定を保存", 613 553 "serverStats": "サーバー統計", 614 554 "users": "ユーザー", ··· 699 639 "title": "二要素認証", 700 640 "subtitle": "追加の確認が必要です", 701 641 "usePasskey": "パスキーを使用", 702 - "useTotp": "認証アプリを使用" 642 + "useTotp": "認証アプリを使用", 643 + "verifying": "確認中..." 703 644 }, 704 645 "twoFactorCode": { 705 646 "title": "二要素認証", 706 647 "subtitle": "{channel} に確認コードを送信しました。以下にコードを入力して続行してください。", 707 648 "codeLabel": "確認コード", 708 649 "codePlaceholder": "6桁のコードを入力", 650 + "verify": "確認", 651 + "verifying": "確認中...", 709 652 "errors": { 710 653 "missingRequestUri": "request_uri パラメータがありません", 711 654 "verificationFailed": "確認に失敗しました", ··· 717 660 "title": "認証コードを入力", 718 661 "subtitle": "認証アプリの6桁のコードを入力", 719 662 "codePlaceholder": "6桁のコードを入力", 663 + "verify": "確認", 664 + "verifying": "確認中...", 720 665 "useBackupCode": "バックアップコードを使用", 721 666 "backupCodePlaceholder": "バックアップコードを入力", 722 667 "trustDevice": "このデバイスを30日間信頼する", ··· 746 691 "codeLabel": "確認コード", 747 692 "codeHelp": "ダッシュを含む完全なコードをメッセージからコピーしてください", 748 693 "verifyButton": "アカウントを確認", 694 + "verify": "確認", 695 + "verifying": "確認中...", 749 696 "pleaseWait": "お待ちください...", 697 + "sending": "送信中...", 698 + "resendCode": "コードを再送信", 699 + "resending": "送信中...", 750 700 "codeResent": "確認コードを再送信しました!", 751 701 "codeResentDetail": "確認コードを送信しました!受信トレイを確認してください。", 752 702 "verified": "確認完了!", ··· 756 706 "identifierLabel": "メールまたは識別子", 757 707 "identifierPlaceholder": "you@example.com", 758 708 "identifierHelp": "コードが送信されたメールアドレスまたは識別子", 709 + "backToLogin": "ログインに戻る", 759 710 "verifyingAccount": "確認中のアカウント: @{handle}", 760 711 "startOver": "別のアカウントでやり直す", 761 712 "noPending": "保留中の確認が見つかりません。", 762 713 "noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。", 763 714 "createAccount": "アカウントを作成", 764 715 "signIn": "サインイン", 716 + "backToSettings": "設定に戻る", 765 717 "emailUpdateCodeHelp": "コードは現在のメールアドレスに送信されました", 766 718 "emailUpdateFailed": "メールアドレスの更新に失敗しました", 767 719 "emailUpdateRequiresAuth": "メールアドレスを更新するにはサインインが必要です。", ··· 794 746 "resetButton": "パスワードをリセット", 795 747 "resetting": "リセット中...", 796 748 "success": "パスワードをリセットしました!", 749 + "backToLogin": "サインインに戻る", 797 750 "requestNewCode": "新しいコードをリクエスト", 798 751 "passwordsMismatch": "パスワードが一致しません", 799 752 "passwordLength": "パスワードは8文字以上である必要があります" ··· 837 790 "howItWorks": "仕組み", 838 791 "howItWorksDetail": "登録された通知チャンネルに安全なリンクを送信します。リンクをクリックして一時パスワードを設定します。その後サインインして新しいパスキーを追加できます。", 839 792 "sendRecoveryLink": "復旧リンクを送信", 840 - "sending": "送信中..." 793 + "sending": "送信中...", 794 + "backToLogin": "サインインに戻る" 841 795 }, 842 796 "registerPasskey": { 843 797 "title": "パスキーアカウントを作成", ··· 858 812 "externalDid": "あなたの did:web", 859 813 "externalDidPlaceholder": "did:web:yourdomain.com", 860 814 "createButton": "アカウントを作成", 815 + "creating": "作成中...", 861 816 "alreadyHaveAccount": "すでにアカウントをお持ちですか?", 862 817 "signIn": "サインイン", 863 818 "wantPassword": "パスワードを使用しますか?", ··· 956 911 "useTotp": "認証アプリを使用", 957 912 "passwordPlaceholder": "パスワードを入力", 958 913 "totpPlaceholder": "6桁のコードを入力", 914 + "verify": "確認", 915 + "verifying": "確認中...", 959 916 "authenticating": "認証中...", 960 917 "passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。", 961 918 "cancel": "キャンセル" ··· 1028 985 "createAccount": "アカウントを作成", 1029 986 "createDelegatedAccount": "委任アカウントを作成", 1030 987 "createDelegatedAccountButton": "+ 委任アカウントを作成", 988 + "creating": "作成中...", 1031 989 "emailOptional": "メール(任意)", 1032 990 "failedToAddController": "コントローラーの追加に失敗しました", 1033 991 "failedToCreateAccount": "委任アカウントの作成に失敗しました", ··· 1101 1059 "navDesc": "別のPDSへ、または別のPDSからアカウントを移動", 1102 1060 "migrateHere": "ここに移行", 1103 1061 "migrateHereDesc": "既存のAT ProtocolアカウントをこのPDSに移動します。", 1062 + "migrateAway": "別の場所に移行", 1063 + "migrateAwayDesc": "このPDSから別のサーバーにアカウントを移動します。", 1064 + "loginRequired": "ログインが必要です", 1104 1065 "bringDid": "DIDとアイデンティティを持ち込む", 1105 1066 "transferData": "すべてのデータを転送", 1106 1067 "keepFollowers": "フォロワーを維持", 1068 + "exportRepo": "リポジトリをエクスポート", 1069 + "transferToPds": "新しいPDSに転送", 1070 + "updateIdentity": "アイデンティティを更新", 1107 1071 "whatIsMigration": "アカウント移行とは?", 1108 1072 "whatIsMigrationDesc": "アカウント移行により、AT Protocolアイデンティティをパーソナルデータサーバー(PDS)間で移動できます。DID(分散型識別子)は変わらないため、フォロワーやソーシャルコネクションは維持されます。", 1109 1073 "beforeMigrate": "移行前の確認事項", ··· 1113 1077 "beforeMigrate4": "古いPDSにアカウントの無効化が通知されます", 1114 1078 "importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。", 1115 1079 "learnMore": "移行のリスクについて詳しく", 1116 - "offlineRestore": "オフライン復元", 1117 - "offlineRestoreDesc": "旧PDSが利用できない場合にバックアップから復元します。", 1118 - "offlineFeature1": "CARファイルバックアップを使用", 1119 - "offlineFeature2": "ローテーションキーで所有権を証明", 1120 - "offlineFeature3": "シャットダウンしたサーバーの復旧", 1080 + "comingSoon": "近日公開", 1121 1081 "oauthCompleting": "認証を完了しています...", 1122 1082 "oauthFailed": "認証に失敗しました", 1123 1083 "tryAgain": "再試行", ··· 1126 1086 "incomplete": "未完了の移行があります:", 1127 1087 "direction": "方向", 1128 1088 "migratingHere": "ここに移行中", 1089 + "migratingAway": "別の場所に移行中", 1129 1090 "from": "移行元", 1130 1091 "to": "移行先", 1131 1092 "progress": "進行状況", ··· 1268 1229 "error": { 1269 1230 "title": "移行エラー", 1270 1231 "desc": "移行中にエラーが発生しました。", 1271 - "startOver": "最初からやり直す", 1272 - "unknown": "不明なエラーが発生しました。" 1232 + "startOver": "最初からやり直す" 1273 1233 }, 1274 1234 "common": { 1275 1235 "back": "戻る", ··· 1287 1247 "warning3": "移行後、古いアカウントは無効化されます" 1288 1248 } 1289 1249 }, 1290 - "offline": { 1250 + "outbound": { 1291 1251 "welcome": { 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": "理解し、続行します" 1301 - }, 1302 - "provideDid": { 1303 - "title": "DIDを入力", 1304 - "desc": "復元するアカウントのDIDを入力してください。", 1305 - "label": "あなたのDID", 1306 - "hint": "分散型識別子(例:did:plc:abc123)" 1307 - }, 1308 - "uploadCar": { 1309 - "title": "CARファイルをアップロード", 1310 - "desc": "リポジトリバックアップファイルをアップロードしてください。", 1311 - "label": "CARファイル", 1312 - "hint": "バックアップから.carファイルを選択", 1313 - "reuploadWarningTitle": "CARファイルが必要です", 1314 - "reuploadWarning": "セッションは復元されましたが、CARファイルを再アップロードする必要があります。セキュリティ上の理由から、ファイルの内容はセッション間で保存されません。" 1252 + "title": "このPDSから移行", 1253 + "desc": "アカウントを別のパーソナルデータサーバーに移動します。", 1254 + "warning": "移行後、ここでのアカウントは無効化されます。", 1255 + "didWebNotice": "did:web移行のお知らせ", 1256 + "didWebNoticeDesc": "あなたのアカウントはdid:web識別子({did})を使用しています。移行後、このPDSは新しいPDSを指すDIDドキュメントを引き続き提供します。このサーバーがオンラインである限り、アイデンティティは機能し続けます。", 1257 + "understand": "リスクを理解し、続行します" 1315 1258 }, 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": "キーを検証" 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": "利用規約" 1330 1270 }, 1331 - "chooseHandle": { 1332 - "migratingDid": "DIDを復元中" 1271 + "newAccount": { 1272 + "title": "新しいアカウントの詳細", 1273 + "desc": "新しいPDSでアカウントを設定します。", 1274 + "handle": "ハンドル", 1275 + "availableDomains": "利用可能なドメイン", 1276 + "email": "メール", 1277 + "password": "パスワード", 1278 + "confirmPassword": "パスワードを確認", 1279 + "inviteCode": "招待コード" 1333 1280 }, 1334 1281 "review": { 1335 - "desc": "オフライン復元の詳細を確認してください。", 1336 - "carFile": "CARファイル", 1337 - "rotationKey": "ローテーションキー", 1338 - "warning": "復元を開始すると、アイデンティティがこのPDSを指すように更新されます。これは簡単に元に戻すことができません。", 1339 - "plcWarningTitle": "引き返せないポイント", 1340 - "plcWarning": "開始すると、DIDドキュメントがこのPDSを指すように更新されます。問題が発生した場合はローテーションキーを使用して回復できますが、壊れたアイデンティティ状態を避けるために移行を完了する必要があります。" 1282 + "title": "移行の確認", 1283 + "desc": "移行の詳細を確認してください。", 1284 + "currentHandle": "現在のハンドル", 1285 + "newHandle": "新しいハンドル", 1286 + "sourcePds": "このPDS", 1287 + "targetPds": "移行先PDS", 1288 + "confirm": "アカウントを移行することを確認します", 1289 + "startMigration": "移行を開始" 1341 1290 }, 1342 1291 "migrating": { 1343 - "title": "アカウントを復元中", 1344 - "desc": "アカウントを復元しています...", 1345 - "creating": "アカウントを作成中", 1346 - "importing": "リポジトリをインポート中", 1347 - "plcSigning": "アイデンティティを更新中", 1348 - "activating": "アカウントをアクティベート中" 1292 + "title": "アカウントを移行中", 1293 + "desc": "データを転送しています..." 1349 1294 }, 1350 - "success": { 1351 - "desc": "アカウントはこのPDSに正常に復元されました。" 1295 + "plcToken": { 1296 + "title": "本人確認", 1297 + "desc": "確認コードがメールに送信されました。" 1352 1298 }, 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に接続してメディアファイルを取得できませんでした。シャットダウンしたサーバーからの移行ではよくあることです。投稿は機能しますが、一部の画像が欠落する可能性があります。" 1299 + "finalizing": { 1300 + "title": "移行を完了中", 1301 + "desc": "移行を完了しています...", 1302 + "updatingForwarding": "DIDドキュメントの転送先を更新中..." 1303 + }, 1304 + "success": { 1305 + "title": "移行完了!", 1306 + "desc": "アカウントは新しいPDSに正常に移行されました。", 1307 + "newHandle": "新しいハンドル", 1308 + "newPds": "新しいPDS", 1309 + "nextSteps": "次のステップ", 1310 + "nextSteps1": "新しいPDSにサインイン", 1311 + "nextSteps2": "アプリの認証情報を更新", 1312 + "nextSteps3": "フォロワーは自動的に新しい場所を確認できます", 1313 + "loggingOut": "{seconds}秒後にログアウトします..." 1361 1314 } 1362 1315 }, 1363 1316 "progress": {
+100 -147
frontend/src/locales/ko.json
··· 17 17 "dashboard": "대시보드", 18 18 "backToDashboard": "← 대시보드", 19 19 "copied": "복사됨!", 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자 이상이어야 합니다" 20 + "copyToClipboard": "클립보드에 복사" 60 21 }, 61 22 "login": { 62 23 "title": "로그인", ··· 88 49 "codeLabel": "인증 코드", 89 50 "codePlaceholder": "6자리 코드 입력", 90 51 "verifyButton": "계정 인증", 91 - "resent": "인증 코드를 다시 보냈습니다!" 52 + "verifying": "인증 중...", 53 + "resendButton": "코드 다시 보내기", 54 + "resending": "전송 중...", 55 + "resent": "인증 코드를 다시 보냈습니다!", 56 + "backToLogin": "로그인으로 돌아가기" 92 57 }, 93 58 "register": { 94 59 "title": "계정 만들기", ··· 159 124 "inviteCodePlaceholder": "초대 코드 입력", 160 125 "inviteCodeRequired": "필수", 161 126 "createButton": "계정 만들기", 127 + "creating": "계정 생성 중...", 162 128 "alreadyHaveAccount": "이미 계정이 있으신가요?", 163 129 "signIn": "로그인", 164 130 "wantPasswordless": "비밀번호 없는 보안을 원하시나요?", ··· 213 179 "navAdminDesc": "서버 통계 및 관리 작업", 214 180 "navDidDocument": "DID 문서", 215 181 "navDidDocumentDesc": "DID 문서 및 키 관리", 216 - "navDidDocumentDescActive": "DID 문서 설정 편집", 217 - "navBackup": "백업 다운로드", 218 - "navBackupDesc": "저장소를 CAR 파일로 다운로드", 219 - "downloadingBackup": "다운로드 중...", 220 - "backupFailed": "백업 다운로드 실패", 221 182 "migrated": "마이그레이션됨", 222 183 "migratedTitle": "계정 마이그레이션됨", 223 184 "migratedMessage": "계정이 {pds}로 마이그레이션되었습니다. DID 문서는 여전히 여기에서 호스팅됩니다.", ··· 247 208 "serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.", 248 209 "currentPds": "현재 PDS URL", 249 210 "save": "변경사항 저장", 211 + "saving": "저장 중...", 250 212 "success": "DID 문서가 업데이트되었습니다", 251 213 "saveFailed": "DID 문서 저장에 실패했습니다", 252 214 "loadFailed": "DID 문서 로드에 실패했습니다", ··· 284 246 "yourDomain": "도메인", 285 247 "yourDomainPlaceholder": "example.com", 286 248 "verifyAndUpdate": "확인 후 핸들 업데이트", 249 + "verifying": "확인 중...", 287 250 "newHandle": "새 핸들", 288 251 "newHandlePlaceholder": "yourhandle", 289 252 "changeHandleButton": "핸들 변경", ··· 299 262 "exportData": "데이터 내보내기", 300 263 "exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.", 301 264 "downloadRepo": "저장소 다운로드", 302 - "downloadBlobs": "미디어 다운로드", 303 265 "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 - }, 330 266 "deleteAccount": "계정 삭제", 331 267 "deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.", 332 268 "requestDeletion": "계정 삭제 요청", ··· 355 291 "deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", 356 292 "deletionFailed": "계정 삭제에 실패했습니다", 357 293 "repoExported": "저장소를 내보냈습니다", 358 - "blobsExported": "미디어 파일을 내보냈습니다", 359 - "noBlobsToExport": "내보낼 미디어 파일이 없습니다", 360 - "exportFailed": "내보내기에 실패했습니다", 294 + "exportFailed": "저장소 내보내기에 실패했습니다", 361 295 "confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." 362 296 } 363 297 }, ··· 372 306 "noPasswords": "앱 비밀번호가 아직 없습니다", 373 307 "revoke": "취소", 374 308 "revoking": "취소 중...", 309 + "creating": "생성 중...", 375 310 "revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.", 376 311 "saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!", 377 312 "saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.", ··· 419 354 "used": "@{handle}이(가) 사용함", 420 355 "disabled": "비활성화됨", 421 356 "usedBy": "사용자", 357 + "creating": "생성 중...", 422 358 "disableConfirm": "이 초대 코드를 비활성화하시겠습니까? 더 이상 사용할 수 없습니다.", 423 359 "created": "초대 코드가 생성되었습니다", 424 360 "copy": "복사", ··· 546 482 "verifyButton": "인증", 547 483 "verifyCodePlaceholder": "인증 코드 입력", 548 484 "submit": "제출", 485 + "saving": "저장 중...", 549 486 "savePreferences": "설정 저장", 550 487 "preferencesSaved": "통신 설정이 저장되었습니다", 551 488 "verifiedSuccess": "{channel} 인증 완료", ··· 584 521 "noCollectionsYet": "컬렉션이 아직 없습니다. 첫 번째 레코드를 만들어 시작하세요.", 585 522 "loadMore": "더 불러오기", 586 523 "recordJson": "레코드 JSON", 524 + "saving": "저장 중...", 587 525 "updateRecord": "레코드 업데이트", 588 526 "collectionNsid": "컬렉션 (NSID)", 589 527 "recordKeyOptional": "레코드 키 (선택사항)", 590 528 "autoGenerated": "비워두면 자동 생성 (TID)", 591 529 "autoGeneratedHint": "비워두면 TID 기반 키가 자동 생성됩니다", 530 + "creating": "생성 중...", 592 531 "demoPostText": "안녕하세요, 제 PDS에서 보내는 첫 번째 게시물입니다!", 593 532 "demoDisplayName": "표시 이름", 594 533 "demoBio": "간단한 자기소개를 작성하세요." ··· 609 548 "primaryLight": "기본 (라이트 모드)", 610 549 "primaryDark": "기본 (다크 모드)", 611 550 "configSaved": "서버 설정이 저장되었습니다", 551 + "saving": "저장 중...", 612 552 "saveConfig": "설정 저장", 613 553 "serverStats": "서버 통계", 614 554 "users": "사용자", ··· 699 639 "title": "2단계 인증", 700 640 "subtitle": "추가 확인이 필요합니다", 701 641 "usePasskey": "패스키 사용", 702 - "useTotp": "인증 앱 사용" 642 + "useTotp": "인증 앱 사용", 643 + "verifying": "확인 중..." 703 644 }, 704 645 "twoFactorCode": { 705 646 "title": "2단계 인증", 706 647 "subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 코드를 입력하여 계속하세요.", 707 648 "codeLabel": "인증 코드", 708 649 "codePlaceholder": "6자리 코드 입력", 650 + "verify": "확인", 651 + "verifying": "확인 중...", 709 652 "errors": { 710 653 "missingRequestUri": "request_uri 매개변수가 없습니다", 711 654 "verificationFailed": "인증에 실패했습니다", ··· 717 660 "title": "인증 코드 입력", 718 661 "subtitle": "인증 앱의 6자리 코드를 입력하세요", 719 662 "codePlaceholder": "6자리 코드 입력", 663 + "verify": "확인", 664 + "verifying": "확인 중...", 720 665 "useBackupCode": "백업 코드 사용", 721 666 "backupCodePlaceholder": "백업 코드 입력", 722 667 "trustDevice": "이 기기를 30일간 신뢰", ··· 746 691 "codeLabel": "인증 코드", 747 692 "codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요", 748 693 "verifyButton": "계정 인증", 694 + "verify": "인증", 695 + "verifying": "인증 중...", 749 696 "pleaseWait": "잠시 기다려 주세요...", 697 + "sending": "전송 중...", 698 + "resendCode": "코드 다시 보내기", 699 + "resending": "전송 중...", 750 700 "codeResent": "인증 코드를 다시 보냈습니다!", 751 701 "codeResentDetail": "인증 코드가 전송되었습니다! 받은 편지함을 확인하세요.", 752 702 "verified": "인증 완료!", ··· 756 706 "identifierLabel": "이메일 또는 식별자", 757 707 "identifierPlaceholder": "you@example.com", 758 708 "identifierHelp": "코드가 전송된 이메일 주소 또는 식별자", 709 + "backToLogin": "로그인으로 돌아가기", 759 710 "verifyingAccount": "인증 중인 계정: @{handle}", 760 711 "startOver": "다른 계정으로 다시 시작", 761 712 "noPending": "보류 중인 인증이 없습니다.", 762 713 "noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.", 763 714 "createAccount": "계정 만들기", 764 715 "signIn": "로그인", 716 + "backToSettings": "설정으로 돌아가기", 765 717 "emailUpdateCodeHelp": "코드가 현재 이메일 주소로 전송되었습니다", 766 718 "emailUpdateFailed": "이메일 주소 업데이트 실패", 767 719 "emailUpdateRequiresAuth": "이메일 주소를 업데이트하려면 로그인해야 합니다.", ··· 794 746 "resetButton": "비밀번호 재설정", 795 747 "resetting": "재설정 중...", 796 748 "success": "비밀번호가 재설정되었습니다!", 749 + "backToLogin": "로그인으로 돌아가기", 797 750 "requestNewCode": "새 코드 요청", 798 751 "passwordsMismatch": "비밀번호가 일치하지 않습니다", 799 752 "passwordLength": "비밀번호는 8자 이상이어야 합니다" ··· 837 790 "howItWorks": "작동 방식", 838 791 "howItWorksDetail": "등록된 알림 채널로 보안 링크를 보냅니다. 링크를 클릭하여 임시 비밀번호를 설정합니다. 그런 다음 로그인하여 새 패스키를 추가할 수 있습니다.", 839 792 "sendRecoveryLink": "복구 링크 보내기", 840 - "sending": "전송 중..." 793 + "sending": "전송 중...", 794 + "backToLogin": "로그인으로 돌아가기" 841 795 }, 842 796 "registerPasskey": { 843 797 "title": "패스키 계정 만들기", ··· 858 812 "externalDid": "귀하의 did:web", 859 813 "externalDidPlaceholder": "did:web:yourdomain.com", 860 814 "createButton": "계정 만들기", 815 + "creating": "생성 중...", 861 816 "alreadyHaveAccount": "이미 계정이 있으신가요?", 862 817 "signIn": "로그인", 863 818 "wantPassword": "비밀번호를 사용하시겠습니까?", ··· 956 911 "useTotp": "인증 앱 사용", 957 912 "passwordPlaceholder": "비밀번호 입력", 958 913 "totpPlaceholder": "6자리 코드 입력", 914 + "verify": "확인", 915 + "verifying": "확인 중...", 959 916 "authenticating": "인증 중...", 960 917 "passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.", 961 918 "cancel": "취소" ··· 1028 985 "createAccount": "계정 생성", 1029 986 "createDelegatedAccount": "위임 계정 생성", 1030 987 "createDelegatedAccountButton": "+ 위임 계정 생성", 988 + "creating": "생성 중...", 1031 989 "emailOptional": "이메일 (선택사항)", 1032 990 "failedToAddController": "컨트롤러 추가에 실패했습니다", 1033 991 "failedToCreateAccount": "위임 계정 생성에 실패했습니다", ··· 1101 1059 "navDesc": "다른 PDS로 또는 다른 PDS에서 계정 이동", 1102 1060 "migrateHere": "여기로 마이그레이션", 1103 1061 "migrateHereDesc": "기존 AT Protocol 계정을 다른 서버에서 이 PDS로 이동합니다.", 1062 + "migrateAway": "다른 곳으로 마이그레이션", 1063 + "migrateAwayDesc": "이 PDS에서 다른 서버로 계정을 이동합니다.", 1064 + "loginRequired": "로그인 필요", 1104 1065 "bringDid": "DID와 아이덴티티 가져오기", 1105 1066 "transferData": "모든 데이터 전송", 1106 1067 "keepFollowers": "팔로워 유지", 1068 + "exportRepo": "저장소 내보내기", 1069 + "transferToPds": "새 PDS로 전송", 1070 + "updateIdentity": "아이덴티티 업데이트", 1107 1071 "whatIsMigration": "계정 마이그레이션이란?", 1108 1072 "whatIsMigrationDesc": "계정 마이그레이션을 통해 AT Protocol 아이덴티티를 개인 데이터 서버(PDS) 간에 이동할 수 있습니다. DID(분산 식별자)는 동일하게 유지되므로 팔로워와 소셜 연결이 보존됩니다.", 1109 1073 "beforeMigrate": "마이그레이션 전 확인사항", ··· 1113 1077 "beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다", 1114 1078 "importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.", 1115 1079 "learnMore": "마이그레이션 위험에 대해 자세히 알아보기", 1116 - "offlineRestore": "오프라인 복원", 1117 - "offlineRestoreDesc": "이전 PDS를 사용할 수 없을 때 백업에서 복원합니다.", 1118 - "offlineFeature1": "CAR 파일 백업 사용", 1119 - "offlineFeature2": "회전 키로 소유권 증명", 1120 - "offlineFeature3": "종료된 서버 복구", 1080 + "comingSoon": "곧 출시 예정", 1121 1081 "oauthCompleting": "인증 완료 중...", 1122 1082 "oauthFailed": "인증 실패", 1123 1083 "tryAgain": "다시 시도", ··· 1126 1086 "incomplete": "완료되지 않은 마이그레이션이 있습니다:", 1127 1087 "direction": "방향", 1128 1088 "migratingHere": "여기로 마이그레이션 중", 1089 + "migratingAway": "다른 곳으로 마이그레이션 중", 1129 1090 "from": "출발지", 1130 1091 "to": "목적지", 1131 1092 "progress": "진행 상황", ··· 1268 1229 "error": { 1269 1230 "title": "마이그레이션 오류", 1270 1231 "desc": "마이그레이션 중 오류가 발생했습니다.", 1271 - "startOver": "처음부터 다시 시작", 1272 - "unknown": "알 수 없는 오류가 발생했습니다." 1232 + "startOver": "처음부터 다시 시작" 1273 1233 }, 1274 1234 "common": { 1275 1235 "back": "뒤로", ··· 1287 1247 "warning3": "마이그레이션 후 이전 계정은 비활성화됩니다" 1288 1248 } 1289 1249 }, 1290 - "offline": { 1250 + "outbound": { 1291 1251 "welcome": { 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": "이해하고 계속 진행합니다" 1301 - }, 1302 - "provideDid": { 1303 - "title": "DID 입력", 1304 - "desc": "복원할 계정의 DID를 입력하세요.", 1305 - "label": "당신의 DID", 1306 - "hint": "분산 식별자 (예: did:plc:abc123)" 1307 - }, 1308 - "uploadCar": { 1309 - "title": "CAR 파일 업로드", 1310 - "desc": "저장소 백업 파일을 업로드하세요.", 1311 - "label": "CAR 파일", 1312 - "hint": "백업에서 .car 파일을 선택하세요", 1313 - "reuploadWarningTitle": "CAR 파일 필요", 1314 - "reuploadWarning": "세션이 복원되었지만 CAR 파일을 다시 업로드해야 합니다. 보안상의 이유로 파일 내용은 세션 간에 저장되지 않습니다." 1252 + "title": "이 PDS에서 마이그레이션", 1253 + "desc": "계정을 다른 개인 데이터 서버로 이동합니다.", 1254 + "warning": "마이그레이션 후 이 PDS에서 계정이 비활성화됩니다.", 1255 + "didWebNotice": "did:web 마이그레이션 알림", 1256 + "didWebNoticeDesc": "귀하의 계정은 did:web 식별자({did})를 사용합니다. 마이그레이션 후 이 PDS는 새 PDS를 가리키는 DID 문서를 계속 제공합니다. 이 서버가 온라인인 한 아이덴티티는 계속 작동합니다.", 1257 + "understand": "위험을 이해하고 계속 진행합니다" 1315 1258 }, 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": "키 검증" 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": "서비스 약관" 1330 1270 }, 1331 - "chooseHandle": { 1332 - "migratingDid": "DID 복원 중" 1271 + "newAccount": { 1272 + "title": "새 계정 세부 정보", 1273 + "desc": "새 PDS에서 계정을 설정합니다.", 1274 + "handle": "핸들", 1275 + "availableDomains": "사용 가능한 도메인", 1276 + "email": "이메일", 1277 + "password": "비밀번호", 1278 + "confirmPassword": "비밀번호 확인", 1279 + "inviteCode": "초대 코드" 1333 1280 }, 1334 1281 "review": { 1335 - "desc": "오프라인 복원 세부 정보를 확인하세요.", 1336 - "carFile": "CAR 파일", 1337 - "rotationKey": "회전 키", 1338 - "warning": "복원을 시작하면 아이덴티티가 이 PDS를 가리키도록 업데이트됩니다. 이것은 쉽게 되돌릴 수 없습니다.", 1339 - "plcWarningTitle": "되돌릴 수 없는 지점", 1340 - "plcWarning": "시작하면 DID 문서가 이 PDS를 가리키도록 업데이트됩니다. 문제가 발생하면 회전 키를 사용하여 복구할 수 있지만, 손상된 아이덴티티 상태를 피하려면 마이그레이션을 완료해야 합니다." 1282 + "title": "마이그레이션 검토", 1283 + "desc": "마이그레이션 세부 정보를 검토하고 확인하세요.", 1284 + "currentHandle": "현재 핸들", 1285 + "newHandle": "새 핸들", 1286 + "sourcePds": "이 PDS", 1287 + "targetPds": "대상 PDS", 1288 + "confirm": "계정 마이그레이션을 확인합니다", 1289 + "startMigration": "마이그레이션 시작" 1341 1290 }, 1342 1291 "migrating": { 1343 - "title": "계정 복원 중", 1344 - "desc": "계정을 복원하는 중입니다...", 1345 - "creating": "계정 생성 중", 1346 - "importing": "저장소 가져오는 중", 1347 - "plcSigning": "아이덴티티 업데이트 중", 1348 - "activating": "계정 활성화 중" 1292 + "title": "계정 마이그레이션 중", 1293 + "desc": "데이터를 전송하는 중입니다..." 1349 1294 }, 1350 - "success": { 1351 - "desc": "계정이 이 PDS에 성공적으로 복원되었습니다." 1295 + "plcToken": { 1296 + "title": "신원 확인", 1297 + "desc": "이메일로 인증 코드가 전송되었습니다." 1352 1298 }, 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에 연결하여 미디어 파일을 가져올 수 없습니다. 종료된 서버에서 마이그레이션할 때 흔히 발생합니다. 게시물은 작동하지만 일부 이미지가 누락될 수 있습니다." 1299 + "finalizing": { 1300 + "title": "마이그레이션 완료 중", 1301 + "desc": "마이그레이션을 완료하는 중입니다...", 1302 + "updatingForwarding": "DID 문서 포워딩 업데이트 중..." 1303 + }, 1304 + "success": { 1305 + "title": "마이그레이션 완료!", 1306 + "desc": "계정이 새 PDS로 성공적으로 마이그레이션되었습니다.", 1307 + "newHandle": "새 핸들", 1308 + "newPds": "새 PDS", 1309 + "nextSteps": "다음 단계", 1310 + "nextSteps1": "새 PDS에 로그인", 1311 + "nextSteps2": "새 인증 정보로 앱 업데이트", 1312 + "nextSteps3": "팔로워가 자동으로 새 위치를 확인할 수 있습니다", 1313 + "loggingOut": "{seconds}초 후 로그아웃됩니다..." 1361 1314 } 1362 1315 }, 1363 1316 "progress": {
+100 -147
frontend/src/locales/sv.json
··· 17 17 "dashboard": "Kontrollpanel", 18 18 "backToDashboard": "← Kontrollpanel", 19 19 "copied": "Kopierat!", 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" 20 + "copyToClipboard": "Kopiera" 60 21 }, 61 22 "login": { 62 23 "title": "Logga in", ··· 88 49 "codeLabel": "Verifieringskod", 89 50 "codePlaceholder": "Ange 6-siffrig kod", 90 51 "verifyButton": "Verifiera konto", 91 - "resent": "Verifieringskod skickad igen!" 52 + "verifying": "Verifierar...", 53 + "resendButton": "Skicka kod igen", 54 + "resending": "Skickar igen...", 55 + "resent": "Verifieringskod skickad igen!", 56 + "backToLogin": "Tillbaka till inloggning" 92 57 }, 93 58 "register": { 94 59 "title": "Skapa konto", ··· 159 124 "inviteCodePlaceholder": "Ange din inbjudningskod", 160 125 "inviteCodeRequired": "krävs", 161 126 "createButton": "Skapa konto", 127 + "creating": "Skapar konto...", 162 128 "alreadyHaveAccount": "Har du redan ett konto?", 163 129 "signIn": "Logga in", 164 130 "wantPasswordless": "Vill du ha lösenordsfri säkerhet?", ··· 213 179 "navAdminDesc": "Serverstatistik och administratörsoperationer", 214 180 "navDidDocument": "DID-dokument", 215 181 "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", 221 182 "migrated": "Flyttad", 222 183 "migratedTitle": "Konto flyttat", 223 184 "migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande här.", ··· 247 208 "serviceEndpointDesc": "PDS som för närvarande lagrar din kontodata. Uppdatera detta vid migrering.", 248 209 "currentPds": "Nuvarande PDS-URL", 249 210 "save": "Spara ändringar", 211 + "saving": "Sparar...", 250 212 "success": "DID-dokumentet har uppdaterats", 251 213 "saveFailed": "Kunde inte spara DID-dokument", 252 214 "loadFailed": "Kunde inte ladda DID-dokument", ··· 284 246 "yourDomain": "Din domän", 285 247 "yourDomainPlaceholder": "exempel.se", 286 248 "verifyAndUpdate": "Verifiera och uppdatera användarnamn", 249 + "verifying": "Verifierar...", 287 250 "newHandle": "Nytt användarnamn", 288 251 "newHandlePlaceholder": "dittanvändarnamn", 289 252 "changeHandleButton": "Ändra användarnamn", ··· 299 262 "exportData": "Exportera data", 300 263 "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.", 301 264 "downloadRepo": "Ladda ner arkiv", 302 - "downloadBlobs": "Ladda ner media", 303 265 "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 - }, 330 266 "deleteAccount": "Radera konto", 331 267 "deleteWarning": "Denna åtgärd är oåterkallelig. All din data kommer att raderas permanent.", 332 268 "requestDeletion": "Begär kontoradering", ··· 355 291 "deleteConfirmation": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras.", 356 292 "deletionFailed": "Kunde inte radera kontot", 357 293 "repoExported": "Arkiv exporterat", 358 - "blobsExported": "Mediafiler exporterade", 359 - "noBlobsToExport": "Inga mediafiler att exportera", 360 - "exportFailed": "Export misslyckades", 294 + "exportFailed": "Kunde inte exportera arkiv", 361 295 "confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras." 362 296 } 363 297 }, ··· 372 306 "noPasswords": "Inga applösenord ännu", 373 307 "revoke": "Återkalla", 374 308 "revoking": "Återkallar...", 309 + "creating": "Skapar...", 375 310 "revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.", 376 311 "saveWarningTitle": "Viktigt: Spara detta applösenord!", 377 312 "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.", ··· 419 354 "used": "Använd av @{handle}", 420 355 "disabled": "Inaktiverad", 421 356 "usedBy": "Använd av", 357 + "creating": "Skapar...", 422 358 "disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte längre användas.", 423 359 "created": "Inbjudningskod skapad", 424 360 "copy": "Kopiera", ··· 546 482 "verifyButton": "Verifiera", 547 483 "verifyCodePlaceholder": "Ange verifieringskod", 548 484 "submit": "Skicka", 485 + "saving": "Sparar...", 549 486 "savePreferences": "Spara inställningar", 550 487 "preferencesSaved": "Kommunikationsinställningar sparade", 551 488 "verifiedSuccess": "{channel} verifierad", ··· 584 521 "noCollectionsYet": "Inga samlingar ännu. Skapa din första post för att komma igång.", 585 522 "loadMore": "Ladda fler", 586 523 "recordJson": "Post-JSON", 524 + "saving": "Sparar...", 587 525 "updateRecord": "Uppdatera post", 588 526 "collectionNsid": "Samling (NSID)", 589 527 "recordKeyOptional": "Postnyckel (valfri)", 590 528 "autoGenerated": "Genereras automatiskt om tom (TID)", 591 529 "autoGeneratedHint": "Lämna tom för att automatiskt generera en TID-baserad nyckel", 530 + "creating": "Skapar...", 592 531 "demoPostText": "Hej från min PDS! Detta är mitt första inlägg.", 593 532 "demoDisplayName": "Ditt visningsnamn", 594 533 "demoBio": "En kort presentation om dig själv." ··· 609 548 "primaryLight": "Primär (ljust läge)", 610 549 "primaryDark": "Primär (mörkt läge)", 611 550 "configSaved": "Serverkonfiguration sparad", 551 + "saving": "Sparar...", 612 552 "saveConfig": "Spara konfiguration", 613 553 "serverStats": "Serverstatistik", 614 554 "users": "Användare", ··· 699 639 "title": "Tvåfaktorsautentisering", 700 640 "subtitle": "Ytterligare verifiering krävs", 701 641 "usePasskey": "Använd nyckel", 702 - "useTotp": "Använd autentiseringsapp" 642 + "useTotp": "Använd autentiseringsapp", 643 + "verifying": "Verifierar..." 703 644 }, 704 645 "twoFactorCode": { 705 646 "title": "Tvåfaktorsautentisering", 706 647 "subtitle": "En verifieringskod har skickats till din {channel}. Ange koden nedan för att fortsätta.", 707 648 "codeLabel": "Verifieringskod", 708 649 "codePlaceholder": "Ange 6-siffrig kod", 650 + "verify": "Verifiera", 651 + "verifying": "Verifierar...", 709 652 "errors": { 710 653 "missingRequestUri": "Saknar request_uri-parameter", 711 654 "verificationFailed": "Verifiering misslyckades", ··· 717 660 "title": "Ange autentiseringskod", 718 661 "subtitle": "Ange den 6-siffriga koden från din autentiseringsapp", 719 662 "codePlaceholder": "Ange 6-siffrig kod", 663 + "verify": "Verifiera", 664 + "verifying": "Verifierar...", 720 665 "useBackupCode": "Använd reservkod istället", 721 666 "backupCodePlaceholder": "Ange reservkod", 722 667 "trustDevice": "Lita på denna enhet i 30 dagar", ··· 746 691 "codeLabel": "Verifieringskod", 747 692 "codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck", 748 693 "verifyButton": "Verifiera konto", 694 + "verify": "Verifiera", 695 + "verifying": "Verifierar...", 749 696 "pleaseWait": "Vänta...", 697 + "sending": "Skickar...", 698 + "resendCode": "Skicka kod igen", 699 + "resending": "Skickar igen...", 750 700 "codeResent": "Verifieringskod skickad igen!", 751 701 "codeResentDetail": "Verifieringskod skickad! Kontrollera din inkorg.", 752 702 "verified": "Verifierad!", ··· 756 706 "identifierLabel": "E-post eller identifierare", 757 707 "identifierPlaceholder": "du@exempel.se", 758 708 "identifierHelp": "E-postadressen eller identifieraren koden skickades till", 709 + "backToLogin": "Tillbaka till inloggning", 759 710 "verifyingAccount": "Verifierar konto: @{handle}", 760 711 "startOver": "Börja om med ett annat konto", 761 712 "noPending": "Ingen väntande verifiering hittades.", 762 713 "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.", 763 714 "createAccount": "Skapa konto", 764 715 "signIn": "Logga in", 716 + "backToSettings": "Tillbaka till inställningar", 765 717 "emailUpdateCodeHelp": "Koden skickades till din nuvarande e-postadress", 766 718 "emailUpdateFailed": "Kunde inte uppdatera e-postadress", 767 719 "emailUpdateRequiresAuth": "Du måste vara inloggad för att uppdatera din e-postadress.", ··· 794 746 "resetButton": "Återställ lösenord", 795 747 "resetting": "Återställer...", 796 748 "success": "Lösenord återställt!", 749 + "backToLogin": "Tillbaka till inloggning", 797 750 "requestNewCode": "Begär ny kod", 798 751 "passwordsMismatch": "Lösenorden matchar inte", 799 752 "passwordLength": "Lösenordet måste vara minst 8 tecken" ··· 837 790 "howItWorks": "Så fungerar det", 838 791 "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.", 839 792 "sendRecoveryLink": "Skicka återställningslänk", 840 - "sending": "Skickar..." 793 + "sending": "Skickar...", 794 + "backToLogin": "Tillbaka till inloggning" 841 795 }, 842 796 "registerPasskey": { 843 797 "title": "Skapa nyckelkonto", ··· 858 812 "externalDid": "Din did:web", 859 813 "externalDidPlaceholder": "did:web:dindomän.se", 860 814 "createButton": "Skapa konto", 815 + "creating": "Skapar...", 861 816 "alreadyHaveAccount": "Har du redan ett konto?", 862 817 "signIn": "Logga in", 863 818 "wantPassword": "Vill du använda ett lösenord?", ··· 956 911 "useTotp": "Använd autentiserare", 957 912 "passwordPlaceholder": "Ange ditt lösenord", 958 913 "totpPlaceholder": "Ange 6-siffrig kod", 914 + "verify": "Verifiera", 915 + "verifying": "Verifierar...", 959 916 "authenticating": "Autentiserar...", 960 917 "passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.", 961 918 "cancel": "Avbryt" ··· 1028 985 "createAccount": "Skapa konto", 1029 986 "createDelegatedAccount": "Skapa delegerat konto", 1030 987 "createDelegatedAccountButton": "+ Skapa delegerat konto", 988 + "creating": "Skapar...", 1031 989 "emailOptional": "E-post (valfritt)", 1032 990 "failedToAddController": "Kunde inte lägga till kontrollant", 1033 991 "failedToCreateAccount": "Kunde inte skapa delegerat konto", ··· 1101 1059 "navDesc": "Flytta ditt konto till eller från en annan PDS", 1102 1060 "migrateHere": "Flytta hit", 1103 1061 "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", 1104 1065 "bringDid": "Ta med din DID och identitet", 1105 1066 "transferData": "Överför all din data", 1106 1067 "keepFollowers": "Behåll dina följare", 1068 + "exportRepo": "Exportera ditt arkiv", 1069 + "transferToPds": "Överför till ny PDS", 1070 + "updateIdentity": "Uppdatera din identitet", 1107 1071 "whatIsMigration": "Vad är kontoflyttning?", 1108 1072 "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.", 1109 1073 "beforeMigrate": "Innan du flyttar", ··· 1113 1077 "beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering", 1114 1078 "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.", 1115 1079 "learnMore": "Läs mer om flyttningsrisker", 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", 1080 + "comingSoon": "Kommer snart", 1121 1081 "oauthCompleting": "Slutför autentisering...", 1122 1082 "oauthFailed": "Autentisering misslyckades", 1123 1083 "tryAgain": "Försök igen", ··· 1126 1086 "incomplete": "Du har en ofullständig flytt pågående:", 1127 1087 "direction": "Riktning", 1128 1088 "migratingHere": "Flyttar hit", 1089 + "migratingAway": "Flyttar bort", 1129 1090 "from": "Från", 1130 1091 "to": "Till", 1131 1092 "progress": "Framsteg", ··· 1268 1229 "error": { 1269 1230 "title": "Flyttfel", 1270 1231 "desc": "Ett fel uppstod under flytten.", 1271 - "startOver": "Börja om", 1272 - "unknown": "Ett okänt fel uppstod." 1232 + "startOver": "Börja om" 1273 1233 }, 1274 1234 "common": { 1275 1235 "back": "Tillbaka", ··· 1287 1247 "warning3": "Ditt gamla konto kommer att inaktiveras efter flytten" 1288 1248 } 1289 1249 }, 1290 - "offline": { 1250 + "outbound": { 1291 1251 "welcome": { 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" 1301 - }, 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)" 1307 - }, 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." 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" 1315 1258 }, 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" 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" 1330 1270 }, 1331 - "chooseHandle": { 1332 - "migratingDid": "Återställer DID" 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" 1333 1280 }, 1334 1281 "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." 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" 1341 1290 }, 1342 1291 "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" 1292 + "title": "Flyttar ditt konto", 1293 + "desc": "Vänta medan vi överför din data..." 1349 1294 }, 1350 - "success": { 1351 - "desc": "Ditt konto har framgångsrikt återställts till denna PDS." 1295 + "plcToken": { 1296 + "title": "Verifiera din identitet", 1297 + "desc": "En verifieringskod har skickats till din e-post." 1352 1298 }, 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." 1299 + "finalizing": { 1300 + "title": "Slutför flytt", 1301 + "desc": "Vänta medan vi slutför flytten...", 1302 + "updatingForwarding": "Uppdaterar DID-dokumentvidarebefordran..." 1303 + }, 1304 + "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..." 1361 1314 } 1362 1315 }, 1363 1316 "progress": {
+100 -147
frontend/src/locales/zh.json
··· 17 17 "dashboard": "控制台", 18 18 "backToDashboard": "← 返回控制台", 19 19 "copied": "已复制!", 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个字符" 20 + "copyToClipboard": "复制" 60 21 }, 61 22 "login": { 62 23 "title": "登录", ··· 88 49 "codeLabel": "验证码", 89 50 "codePlaceholder": "输入6位验证码", 90 51 "verifyButton": "验证账户", 91 - "resent": "验证码已重新发送!" 52 + "verifying": "验证中...", 53 + "resendButton": "重新发送验证码", 54 + "resending": "发送中...", 55 + "resent": "验证码已重新发送!", 56 + "backToLogin": "返回登录" 92 57 }, 93 58 "register": { 94 59 "title": "创建账户", ··· 159 124 "inviteCodePlaceholder": "输入您的邀请码", 160 125 "inviteCodeRequired": "必填", 161 126 "createButton": "创建账户", 127 + "creating": "正在创建...", 162 128 "alreadyHaveAccount": "已有账户?", 163 129 "signIn": "立即登录", 164 130 "wantPasswordless": "想要无密码登录?", ··· 213 179 "navAdminDesc": "服务器统计和管理操作", 214 180 "navDidDocument": "DID 文档", 215 181 "navDidDocumentDesc": "管理您的 DID 文档和密钥", 216 - "navDidDocumentDescActive": "编辑您的 DID 文档设置", 217 - "navBackup": "下载备份", 218 - "navBackupDesc": "将您的存储库下载为 CAR 文件", 219 - "downloadingBackup": "下载中...", 220 - "backupFailed": "下载备份失败", 221 182 "migrated": "已迁移", 222 183 "migratedTitle": "账户已迁移", 223 184 "migratedMessage": "您的账户已迁移到 {pds}。您的 DID 文档仍在此处托管。", ··· 247 208 "serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。", 248 209 "currentPds": "当前 PDS URL", 249 210 "save": "保存更改", 211 + "saving": "保存中...", 250 212 "success": "DID 文档已更新", 251 213 "saveFailed": "保存 DID 文档失败", 252 214 "loadFailed": "加载 DID 文档失败", ··· 284 246 "yourDomain": "您的域名", 285 247 "yourDomainPlaceholder": "example.com", 286 248 "verifyAndUpdate": "验证并更新用户名", 249 + "verifying": "验证中...", 287 250 "newHandle": "新用户名", 288 251 "newHandlePlaceholder": "yourhandle", 289 252 "changeHandleButton": "更改用户名", ··· 299 262 "exportData": "导出数据", 300 263 "exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。", 301 264 "downloadRepo": "下载数据", 302 - "downloadBlobs": "下载媒体文件", 303 265 "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 - }, 330 266 "deleteAccount": "删除账户", 331 267 "deleteWarning": "此操作不可逆。您的所有数据将被永久删除。", 332 268 "requestDeletion": "请求删除账户", ··· 355 291 "deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。", 356 292 "deletionFailed": "账户删除失败", 357 293 "repoExported": "数据导出成功", 358 - "blobsExported": "媒体文件导出成功", 359 - "noBlobsToExport": "没有可导出的媒体文件", 360 - "exportFailed": "导出失败", 294 + "exportFailed": "数据导出失败", 361 295 "confirmDelete": "您确定要删除账户吗?此操作无法撤销。" 362 296 } 363 297 }, ··· 372 306 "noPasswords": "暂无应用专用密码", 373 307 "revoke": "撤销", 374 308 "revoking": "撤销中...", 309 + "creating": "创建中...", 375 310 "revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。", 376 311 "saveWarningTitle": "重要:请保存此应用专用密码!", 377 312 "saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。", ··· 419 354 "used": "已被 @{handle} 使用", 420 355 "disabled": "已禁用", 421 356 "usedBy": "使用者", 357 + "creating": "创建中...", 422 358 "disableConfirm": "禁用此邀请码?它将无法再被使用。", 423 359 "created": "邀请码已创建", 424 360 "copy": "复制", ··· 546 482 "verifyButton": "验证", 547 483 "verifyCodePlaceholder": "输入验证码", 548 484 "submit": "提交", 485 + "saving": "保存中...", 549 486 "savePreferences": "保存偏好设置", 550 487 "preferencesSaved": "通讯偏好已保存", 551 488 "verifiedSuccess": "{channel} 验证成功", ··· 584 521 "noCollectionsYet": "暂无集合。创建您的第一条记录开始使用。", 585 522 "loadMore": "加载更多", 586 523 "recordJson": "记录 JSON", 524 + "saving": "保存中...", 587 525 "updateRecord": "更新记录", 588 526 "collectionNsid": "集合 (NSID)", 589 527 "recordKeyOptional": "记录键(可选)", 590 528 "autoGenerated": "留空自动生成 (TID)", 591 529 "autoGeneratedHint": "留空将自动生成基于 TID 的键", 530 + "creating": "创建中...", 592 531 "demoPostText": "你好,这是我的第一条帖子!来自我的 PDS。", 593 532 "demoDisplayName": "你的显示名称", 594 533 "demoBio": "写一段简短的自我介绍。" ··· 612 551 "secondaryLight": "副色(浅色模式)", 613 552 "secondaryDark": "副色(深色模式)", 614 553 "configSaved": "服务器配置已保存", 554 + "saving": "保存中...", 615 555 "saveConfig": "保存配置", 616 556 "serverStats": "服务器统计", 617 557 "users": "用户", ··· 699 639 "title": "双重身份验证", 700 640 "subtitle": "需要额外验证", 701 641 "usePasskey": "使用通行密钥", 702 - "useTotp": "使用身份验证器" 642 + "useTotp": "使用身份验证器", 643 + "verifying": "验证中..." 703 644 }, 704 645 "twoFactorCode": { 705 646 "title": "双重身份验证", 706 647 "subtitle": "验证码已发送到您的 {channel}。请在下方输入验证码继续。", 707 648 "codeLabel": "验证码", 708 649 "codePlaceholder": "输入6位验证码", 650 + "verify": "验证", 651 + "verifying": "验证中...", 709 652 "errors": { 710 653 "missingRequestUri": "缺少 request_uri 参数", 711 654 "verificationFailed": "验证失败", ··· 717 660 "title": "输入验证码", 718 661 "subtitle": "请输入身份验证器应用中的6位验证码", 719 662 "codePlaceholder": "输入6位验证码", 663 + "verify": "验证", 664 + "verifying": "验证中...", 720 665 "useBackupCode": "使用备用验证码", 721 666 "backupCodePlaceholder": "输入备用验证码", 722 667 "trustDevice": "信任此设备30天", ··· 746 691 "codeLabel": "验证码", 747 692 "codeHelp": "复制消息中的完整验证码,包括横线", 748 693 "verifyButton": "验证账户", 694 + "verify": "验证", 695 + "verifying": "验证中...", 749 696 "pleaseWait": "请稍候...", 697 + "resendCode": "重新发送验证码", 698 + "resending": "发送中...", 699 + "sending": "发送中...", 750 700 "codeResent": "验证码已重新发送!", 751 701 "codeResentDetail": "验证码已发送!请查收。", 702 + "backToLogin": "返回登录", 752 703 "verifyingAccount": "正在验证账户:@{handle}", 753 704 "startOver": "使用其他账户重新开始", 754 705 "noPending": "未找到待验证的账户", ··· 762 713 "identifierLabel": "邮箱或标识符", 763 714 "identifierPlaceholder": "you@example.com", 764 715 "identifierHelp": "接收验证码的邮箱地址或标识符", 716 + "backToSettings": "返回设置", 765 717 "emailUpdateCodeHelp": "验证码已发送到您当前的邮箱地址", 766 718 "emailUpdateFailed": "更新邮箱地址失败", 767 719 "emailUpdateRequiresAuth": "您需要登录才能更新邮箱地址。", ··· 794 746 "resetButton": "重置密码", 795 747 "resetting": "重置中...", 796 748 "success": "密码重置成功!", 749 + "backToLogin": "返回登录", 797 750 "requestNewCode": "重新获取验证码", 798 751 "passwordsMismatch": "两次输入的密码不一致", 799 752 "passwordLength": "密码至少需要8位字符" ··· 837 790 "howItWorks": "如何恢复", 838 791 "howItWorksDetail": "我们将向您注册的通知渠道发送安全链接。点击链接设置临时密码,然后您就可以登录并添加新的通行密钥。", 839 792 "sendRecoveryLink": "发送恢复链接", 840 - "sending": "发送中..." 793 + "sending": "发送中...", 794 + "backToLogin": "返回登录" 841 795 }, 842 796 "registerPasskey": { 843 797 "title": "创建通行密钥账户", ··· 860 814 "inviteCode": "邀请码", 861 815 "inviteCodePlaceholder": "输入您的邀请码", 862 816 "createButton": "创建账户", 817 + "creating": "创建中...", 863 818 "continue": "继续", 864 819 "back": "返回", 865 820 "alreadyHaveAccount": "已有账户?", ··· 956 911 "useTotp": "使用身份验证器", 957 912 "passwordPlaceholder": "输入您的密码", 958 913 "totpPlaceholder": "输入6位验证码", 914 + "verify": "验证", 915 + "verifying": "验证中...", 959 916 "authenticating": "正在验证...", 960 917 "passkeyPrompt": "点击下方按钮使用通行密钥进行验证。", 961 918 "cancel": "取消" ··· 1029 986 "createAccount": "创建账户", 1030 987 "createDelegatedAccount": "创建委托账户", 1031 988 "createDelegatedAccountButton": "+ 创建委托账户", 989 + "creating": "创建中...", 1032 990 "emailOptional": "邮箱(可选)", 1033 991 "failedToAddController": "添加控制者失败", 1034 992 "failedToCreateAccount": "创建委托账户失败", ··· 1101 1059 "navDesc": "将您的账户移至其他PDS或从其他PDS移入", 1102 1060 "migrateHere": "迁移到此处", 1103 1061 "migrateHereDesc": "将您现有的AT Protocol账户从其他服务器移至此PDS。", 1062 + "migrateAway": "迁移离开", 1063 + "migrateAwayDesc": "将您的账户从此PDS移至其他服务器。", 1064 + "loginRequired": "需要登录", 1104 1065 "bringDid": "携带您的DID和身份", 1105 1066 "transferData": "转移所有数据", 1106 1067 "keepFollowers": "保留您的关注者", 1068 + "exportRepo": "导出您的存储库", 1069 + "transferToPds": "转移到新PDS", 1070 + "updateIdentity": "更新您的身份", 1107 1071 "whatIsMigration": "什么是账户迁移?", 1108 1072 "whatIsMigrationDesc": "账户迁移允许您在个人数据服务器(PDS)之间移动AT Protocol身份。您的DID(去中心化标识符)保持不变,因此您的关注者和社交连接得以保留。", 1109 1073 "beforeMigrate": "迁移前须知", ··· 1113 1077 "beforeMigrate4": "您的旧PDS将收到账户停用通知", 1114 1078 "importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。", 1115 1079 "learnMore": "了解更多迁移风险", 1116 - "offlineRestore": "离线恢复", 1117 - "offlineRestoreDesc": "当旧 PDS 不可用时从备份恢复。", 1118 - "offlineFeature1": "使用 CAR 文件备份", 1119 - "offlineFeature2": "使用轮换密钥证明所有权", 1120 - "offlineFeature3": "用于已关闭服务器的恢复", 1080 + "comingSoon": "即将推出", 1121 1081 "oauthCompleting": "正在完成身份验证...", 1122 1082 "oauthFailed": "身份验证失败", 1123 1083 "tryAgain": "重试", ··· 1126 1086 "incomplete": "您有一个未完成的迁移:", 1127 1087 "direction": "方向", 1128 1088 "migratingHere": "正在迁移到此处", 1089 + "migratingAway": "正在迁移离开", 1129 1090 "from": "从", 1130 1091 "to": "到", 1131 1092 "progress": "进度", ··· 1268 1229 "error": { 1269 1230 "title": "迁移错误", 1270 1231 "desc": "迁移过程中发生错误。", 1271 - "startOver": "重新开始", 1272 - "unknown": "发生未知错误。" 1232 + "startOver": "重新开始" 1273 1233 }, 1274 1234 "common": { 1275 1235 "back": "返回", ··· 1287 1247 "warning3": "迁移后您的旧账户将被停用" 1288 1248 } 1289 1249 }, 1290 - "offline": { 1250 + "outbound": { 1291 1251 "welcome": { 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": "我了解并希望继续" 1301 - }, 1302 - "provideDid": { 1303 - "title": "输入您的 DID", 1304 - "desc": "输入您要恢复的账户的 DID。", 1305 - "label": "您的 DID", 1306 - "hint": "您的去中心化标识符(例如 did:plc:abc123)" 1307 - }, 1308 - "uploadCar": { 1309 - "title": "上传 CAR 文件", 1310 - "desc": "上传您的存储库备份文件。", 1311 - "label": "CAR 文件", 1312 - "hint": "从您的备份中选择 .car 文件", 1313 - "reuploadWarningTitle": "需要 CAR 文件", 1314 - "reuploadWarning": "您的会话已恢复,但您需要重新上传 CAR 文件。出于安全原因,文件内容不会在会话之间保存。" 1252 + "title": "从此PDS迁移离开", 1253 + "desc": "将您的账户移至另一个个人数据服务器。", 1254 + "warning": "迁移后,您在此处的账户将被停用。", 1255 + "didWebNotice": "did:web迁移通知", 1256 + "didWebNoticeDesc": "您的账户使用did:web标识符({did})。迁移后,此PDS将继续提供指向新PDS的DID文档。只要此服务器在线,您的身份将继续有效。", 1257 + "understand": "我了解风险并希望继续" 1315 1258 }, 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": "验证密钥" 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": "服务条款" 1330 1270 }, 1331 - "chooseHandle": { 1332 - "migratingDid": "恢复 DID" 1271 + "newAccount": { 1272 + "title": "新账户详情", 1273 + "desc": "在新PDS上设置您的账户。", 1274 + "handle": "用户名", 1275 + "availableDomains": "可用域名", 1276 + "email": "邮箱", 1277 + "password": "密码", 1278 + "confirmPassword": "确认密码", 1279 + "inviteCode": "邀请码" 1333 1280 }, 1334 1281 "review": { 1335 - "desc": "检查您的离线恢复详情。", 1336 - "carFile": "CAR 文件", 1337 - "rotationKey": "轮换密钥", 1338 - "warning": "开始恢复后,您的身份将更新为指向此 PDS。此操作无法轻易撤销。", 1339 - "plcWarningTitle": "不可逆转点", 1340 - "plcWarning": "一旦开始,您的 DID 文档将更新为指向此 PDS。如果出现问题,您可以使用轮换密钥恢复,但您应该完成迁移以避免身份状态损坏。" 1282 + "title": "检查迁移", 1283 + "desc": "请检查并确认您的迁移详情。", 1284 + "currentHandle": "当前用户名", 1285 + "newHandle": "新用户名", 1286 + "sourcePds": "此PDS", 1287 + "targetPds": "目标PDS", 1288 + "confirm": "我确认要迁移我的账户", 1289 + "startMigration": "开始迁移" 1341 1290 }, 1342 1291 "migrating": { 1343 - "title": "恢复账户", 1344 - "desc": "请稍候,正在恢复您的账户...", 1345 - "creating": "创建账户", 1346 - "importing": "导入存储库", 1347 - "plcSigning": "更新身份", 1348 - "activating": "激活账户" 1292 + "title": "正在迁移您的账户", 1293 + "desc": "请稍候,正在转移您的数据..." 1349 1294 }, 1350 - "success": { 1351 - "desc": "您的账户已成功恢复到此 PDS。" 1295 + "plcToken": { 1296 + "title": "验证您的身份", 1297 + "desc": "验证码已发送到您的邮箱。" 1352 1298 }, 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 来获取媒体文件。从已关闭的服务器迁移时这很常见。您的帖子将正常工作,但部分图片可能会丢失。" 1299 + "finalizing": { 1300 + "title": "正在完成迁移", 1301 + "desc": "请稍候,正在完成迁移...", 1302 + "updatingForwarding": "正在更新DID文档转发..." 1303 + }, 1304 + "success": { 1305 + "title": "迁移完成!", 1306 + "desc": "您的账户已成功迁移到新PDS。", 1307 + "newHandle": "新用户名", 1308 + "newPds": "新PDS", 1309 + "nextSteps": "后续步骤", 1310 + "nextSteps1": "登录到您的新PDS", 1311 + "nextSteps2": "使用新凭据更新您的应用", 1312 + "nextSteps3": "您的关注者将自动看到您的新位置", 1313 + "loggingOut": "{seconds}秒后退出登录..." 1361 1314 } 1362 1315 }, 1363 1316 "progress": {
+1 -1
frontend/src/routes/ActAs.svelte
··· 37 37 38 38 try { 39 39 const response = await fetch( 40 - `/xrpc/_delegation.listControlledAccounts`, 40 + `/xrpc/com.tranquil.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 ? $_('common.saving') : $_('admin.saveConfig')} 438 + {serverConfigLoading ? $_('admin.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 ? $_('common.creating') : $_('common.create')} 158 + {creating ? $_('appPasswords.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 ? $_('common.saving') : $_('comms.savePreferences')} 344 + {saving ? $_('comms.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/_delegation.listControllers', { 78 + const response = await fetch('/xrpc/com.tranquil.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/_delegation.listControlledAccounts', { 93 + const response = await fetch('/xrpc/com.tranquil.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/_delegation.getScopePresets') 107 + const response = await fetch('/xrpc/com.tranquil.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/_delegation.addController', { 124 + const response = await fetch('/xrpc/com.tranquil.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/_delegation.removeController', { 162 + const response = await fetch('/xrpc/com.tranquil.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/_delegation.createDelegatedAccount', { 191 + const response = await fetch('/xrpc/com.tranquil.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 ? $_('common.creating') : $_('delegation.createAccount')} 410 + {creatingDelegated ? $_('delegation.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 - 15 13 onMount(async () => { 16 14 try { 17 15 const serverInfo = await api.describeServer() ··· 178 176 <h3>{$_('dashboard.navSecurity')}</h3> 179 177 <p>{$_('dashboard.navSecurityDesc')}</p> 180 178 </a> 181 - <a href="#/settings" class="nav-card"> 182 - <h3>{$_('dashboard.navSettings')}</h3> 183 - <p>{$_('dashboard.navSettingsDesc')}</p> 184 - </a> 185 179 <a href="#/migrate" class="nav-card"> 186 180 <h3>{$_('dashboard.navMigrateAgain')}</h3> 187 181 <p>{$_('dashboard.navMigrateAgainDesc')}</p> ··· 221 215 <h3>{$_('dashboard.navDelegation')}</h3> 222 216 <p>{$_('dashboard.navDelegationDesc')}</p> 223 217 </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} 230 218 <a href="#/migrate" class="nav-card"> 231 219 <h3>{$_('migration.navTitle')}</h3> 232 220 <p>{$_('migration.navDesc')}</p> ··· 515 503 516 504 .nav-card.migrated-card h3 { 517 505 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); 527 506 } 528 507 </style>
+1 -1
frontend/src/routes/DelegationAudit.svelte
··· 41 41 42 42 try { 43 43 const response = await fetch( 44 - `/xrpc/_delegation.getAuditLog?limit=${limit}&offset=${offset}`, 44 + `/xrpc/com.tranquil.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 ? $_('common.saving') : $_('common.save')} 233 + {saving ? $_('didEditor.saving') : $_('didEditor.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> 191 186 </div> 192 187 193 188 <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 ? $_('common.creating') : $_('inviteCodes.createNew')} 114 + {creating ? $_('inviteCodes.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 ? $_('common.verifying') : $_('common.verify')} 110 + {submitting ? $_('verification.verifying') : $_('verification.verifyButton')} 111 111 </button> 112 112 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 113 - {resendingCode ? $_('common.sending') : $_('common.resendCode')} 113 + {resendingCode ? $_('verification.resending') : $_('verification.resendButton')} 114 114 </button> 115 115 <button type="button" class="tertiary" onclick={backToLogin}> 116 - {$_('common.backToLogin')} 116 + {$_('verification.backToLogin')} 117 117 </button> 118 118 </div> 119 119 </form>
+69 -63
frontend/src/routes/Migration.svelte
··· 1 1 <script lang="ts"> 2 - import { setSession } from '../lib/auth.svelte' 2 + import { getAuthState, logout, 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 - createOfflineInboundMigrationFlow, 7 + createOutboundMigrationFlow, 8 8 hasPendingMigration, 9 - hasPendingOfflineMigration, 10 9 getResumeInfo, 11 - getOfflineResumeInfo, 12 10 clearMigrationState, 13 - clearOfflineState, 14 11 loadMigrationState, 15 12 } from '../lib/migration' 16 13 import InboundWizard from '../components/migration/InboundWizard.svelte' 17 - import OfflineInboundWizard from '../components/migration/OfflineInboundWizard.svelte' 14 + import OutboundWizard from '../components/migration/OutboundWizard.svelte' 15 + 16 + const auth = getAuthState() 18 17 19 - type Direction = 'select' | 'inbound' | 'offline-inbound' 18 + type Direction = 'select' | 'inbound' | 'outbound' 20 19 let direction = $state<Direction>('select') 21 20 let showResumeModal = $state(false) 22 21 let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null) ··· 24 23 let oauthLoading = $state(false) 25 24 26 25 let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null) 27 - let offlineFlow = $state<ReturnType<typeof createOfflineInboundMigrationFlow> | null>(null) 26 + let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null) 28 27 let oauthCallbackProcessed = $state(false) 29 28 30 29 $effect(() => { ··· 67 66 const urlParams = new URLSearchParams(window.location.search) 68 67 const hasOAuthCallback = urlParams.has('code') || urlParams.has('error') 69 68 70 - if (!hasOAuthCallback) { 71 - if (hasPendingMigration()) { 72 - resumeInfo = getResumeInfo() 73 - if (resumeInfo) { 74 - if (resumeInfo.step === 'success') { 75 - clearMigrationState() 76 - resumeInfo = null 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) 77 78 } else { 78 - const stored = loadMigrationState() 79 - if (stored && stored.direction === 'inbound') { 80 - direction = 'inbound' 81 - inboundFlow = createInboundMigrationFlow() 82 - inboundFlow.resumeFromState(stored) 83 - } 79 + direction = 'outbound' 80 + outboundFlow = createOutboundMigrationFlow() 84 81 } 85 82 } 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 - } 95 83 } 96 84 } 97 85 ··· 100 88 inboundFlow = createInboundMigrationFlow() 101 89 } 102 90 103 - function selectOfflineInbound() { 104 - direction = 'offline-inbound' 105 - offlineFlow = createOfflineInboundMigrationFlow() 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) 106 99 } 107 100 108 101 function handleResume() { ··· 115 108 direction = 'inbound' 116 109 inboundFlow = createInboundMigrationFlow() 117 110 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) 118 119 } 119 120 } 120 121 ··· 129 130 inboundFlow.reset() 130 131 inboundFlow = null 131 132 } 132 - if (offlineFlow) { 133 - offlineFlow.reset() 134 - offlineFlow = null 133 + if (outboundFlow) { 134 + outboundFlow.reset() 135 + outboundFlow = null 135 136 } 136 137 direction = 'select' 137 138 } ··· 149 150 navigate('/dashboard') 150 151 } 151 152 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') 153 + async function handleOutboundComplete() { 154 + await logout() 155 + navigate('/login') 163 156 } 164 157 </script> 165 158 ··· 172 165 <div class="resume-details"> 173 166 <div class="detail-row"> 174 167 <span class="label">{$_('migration.resume.direction')}:</span> 175 - <span class="value">{$_('migration.resume.migratingHere')}</span> 168 + <span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span> 176 169 </div> 177 170 {#if resumeInfo.sourceHandle} 178 171 <div class="detail-row"> ··· 219 212 220 213 <div class="direction-cards"> 221 214 <button class="direction-card ghost" onclick={selectInbound}> 215 + <div class="card-icon">↓</div> 222 216 <h2>{$_('migration.migrateHere')}</h2> 223 217 <p>{$_('migration.migrateHereDesc')}</p> 224 218 <ul class="features"> ··· 228 222 </ul> 229 223 </button> 230 224 231 - <button class="direction-card ghost offline-card" onclick={selectOfflineInbound}> 232 - <h2>{$_('migration.offlineRestore')}</h2> 233 - <p>{$_('migration.offlineRestoreDesc')}</p> 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> 234 229 <ul class="features"> 235 - <li>{$_('migration.offlineFeature1')}</li> 236 - <li>{$_('migration.offlineFeature2')}</li> 237 - <li>{$_('migration.offlineFeature3')}</li> 230 + <li>{$_('migration.exportRepo')}</li> 231 + <li>{$_('migration.transferToPds')}</li> 232 + <li>{$_('migration.updateIdentity')}</li> 238 233 </ul> 234 + <p class="login-required">{$_('migration.comingSoon')}</p> 239 235 </button> 240 236 </div> 241 237 ··· 267 263 onComplete={handleInboundComplete} 268 264 /> 269 265 270 - {:else if direction === 'offline-inbound' && offlineFlow} 271 - <OfflineInboundWizard 272 - flow={offlineFlow} 266 + {:else if direction === 'outbound' && outboundFlow} 267 + <OutboundWizard 268 + flow={outboundFlow} 273 269 onBack={handleBack} 274 - onComplete={handleOfflineComplete} 270 + onComplete={handleOutboundComplete} 275 271 /> 276 272 {/if} 277 273 </div> ··· 306 302 } 307 303 308 304 .direction-card { 309 - display: flex; 310 - flex-direction: column; 311 - align-items: stretch; 312 305 background: var(--bg-secondary); 313 306 border: 1px solid var(--border); 314 307 border-radius: var(--radius-xl); ··· 329 322 cursor: not-allowed; 330 323 } 331 324 325 + .card-icon { 326 + font-size: var(--text-3xl); 327 + margin-bottom: var(--space-4); 328 + color: var(--accent); 329 + } 330 + 332 331 .direction-card h2 { 333 332 margin: 0 0 var(--space-3) 0; 334 333 font-size: var(--text-xl); ··· 350 349 351 350 .features li { 352 351 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); 353 358 } 354 359 355 360 .info-section { ··· 397 402 } 398 403 399 404 .warning-box a { 400 - display: inline; 401 - margin-top: var(--space-2); 405 + display: block; 406 + margin-top: var(--space-3); 407 + color: var(--accent); 402 408 } 403 409 404 410 .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 ? $_('common.verifying') : $_('common.verify')} 108 + {submitting ? $_('oauth.twoFactorCode.verifying') : $_('oauth.twoFactorCode.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 - {$_('common.backToLogin')} 174 + {$_('verify.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 ? $_('common.verifying') : $_('common.verify')} 124 + {submitting ? $_('oauth.totp.verifying') : $_('oauth.totp.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 $_('common.creating') 148 + case 'creating': return $_('register.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 ? $_('common.creating') : $_('register.createButton')} 378 + {flow.state.submitting ? $_('register.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">{$_('common.creating')}</p> 416 + <p class="loading">{$_('register.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 ? $_('common.creating') : $_('registerPasskey.continue')} 411 + {flow.state.submitting ? $_('registerPasskey.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 ? $_('common.saving') : $_('repoExplorer.updateRecord')} 420 + {saving ? $_('repoExplorer.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 ? $_('common.creating') : $_('repoExplorer.createRecord')} 467 + {saving ? $_('repoExplorer.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')}>{$_('common.backToLogin')}</button> 39 + <button onclick={() => navigate('/login')}>{$_('requestPasskeyRecovery.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">{$_('common.backToLogin')}</a> 74 + <a href="#/login">{$_('requestPasskeyRecovery.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">{$_('common.backToLogin')}</a> 144 + <a href="#/login">{$_('resetPassword.backToLogin')}</a> 145 145 </p> 146 146 </div> 147 147
+3 -341
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) 44 43 let passwordLoading = $state(false) 45 44 let currentPassword = $state('') 46 45 let newPassword = $state('') ··· 174 173 exportLoading = false 175 174 } 176 175 } 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 - 343 176 async function handleChangePassword(e: Event) { 344 177 e.preventDefault() 345 178 if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return ··· 490 323 /> 491 324 </div> 492 325 <button type="submit" disabled={handleLoading || !newHandle}> 493 - {handleLoading ? $_('common.verifying') : $_('settings.verifyAndUpdate')} 326 + {handleLoading ? $_('settings.verifying') : $_('settings.verifyAndUpdate')} 494 327 </button> 495 328 </form> 496 329 </div> ··· 561 394 <section> 562 395 <h2>{$_('settings.exportData')}</h2> 563 396 <p class="description">{$_('settings.exportDataDescription')}</p> 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')} 397 + <button onclick={handleExportRepo} disabled={exportLoading}> 398 + {exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')} 610 399 </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} 635 400 </section> 636 401 </div> 637 402 <section class="danger-zone"> ··· 893 658 white-space: nowrap; 894 659 border-left: 1px solid var(--border-color); 895 660 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 - } 999 661 } 1000 662 </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>{$_('common.verifying')}</h1> 228 + <h1>{$_('verify.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">{$_('common.backToSettings')}</a> 238 + <a href="#/settings" class="btn">{$_('verify.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">{$_('common.backToSettings')}</a> 304 + <a href="#/settings">{$_('verify.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 ? $_('common.verifying') : $_('common.verify')} 350 + {submitting ? $_('verify.verifying') : $_('verify.verify')} 351 351 </button> 352 352 353 353 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}> 354 - {resendingCode ? $_('common.sending') : $_('common.resendCode')} 354 + {resendingCode ? $_('verify.sending') : $_('verify.resendCode')} 355 355 </button> 356 356 </form> 357 357 358 358 <p class="link-text"> 359 - <a href="#/login">{$_('common.backToLogin')}</a> 359 + <a href="#/login">{$_('verify.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 ? $_('common.verifying') : $_('common.verify')} 393 + {submitting ? $_('verify.verifying') : $_('verify.verifyButton')} 394 394 </button> 395 395 396 396 <button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}> 397 - {resendingCode ? $_('common.sending') : $_('common.resendCode')} 397 + {resendingCode ? $_('verify.resending') : $_('verify.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(--accent); 58 - text-decoration: underline; 59 - text-underline-offset: 2px; 57 + color: var(--secondary); 58 + text-decoration: none; 59 + transition: color 0.3s ease; 60 60 } 61 61 62 62 a:hover { 63 - color: var(--accent-hover); 63 + color: var(--secondary-hover); 64 + text-decoration: none; 64 65 } 65 66 66 67 ::selection { ··· 371 372 color: var(--text-secondary); 372 373 font-size: var(--text-sm); 373 374 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); 199 193 } 200 194 201 195 .review-card { ··· 274 268 text-align: center; 275 269 color: var(--text-secondary); 276 270 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; 302 271 } 303 272 304 273 .success-content { ··· 598 567 font-size: var(--text-sm); 599 568 font-style: italic; 600 569 } 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 - "_account.getNotificationPrefs", 32 + "com.tranquil.account.getNotificationPrefs", 33 33 () => jsonResponse(mockData.notificationPrefs()), 34 34 ); 35 35 mockEndpoint( ··· 37 37 () => jsonResponse(mockData.describeServer()), 38 38 ); 39 39 mockEndpoint( 40 - "_account.getNotificationHistory", 40 + "com.tranquil.account.getNotificationHistory", 41 41 () => jsonResponse({ notifications: [] }), 42 42 ); 43 43 }); ··· 67 67 () => jsonResponse(mockData.describeServer()), 68 68 ); 69 69 mockEndpoint( 70 - "_account.getNotificationHistory", 70 + "com.tranquil.account.getNotificationHistory", 71 71 () => jsonResponse({ notifications: [] }), 72 72 ); 73 73 }); 74 74 it("shows loading text while fetching preferences", async () => { 75 - mockEndpoint("_account.getNotificationPrefs", async () => { 75 + mockEndpoint("com.tranquil.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 - "_account.getNotificationHistory", 91 + "com.tranquil.account.getNotificationHistory", 92 92 () => jsonResponse({ notifications: [] }), 93 93 ); 94 94 }); 95 95 it("displays all four channel options", async () => { 96 96 mockEndpoint( 97 - "_account.getNotificationPrefs", 97 + "com.tranquil.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 - "_account.getNotificationPrefs", 114 + "com.tranquil.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 - "_account.getNotificationPrefs", 125 + "com.tranquil.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 - "_account.getNotificationPrefs", 136 + "com.tranquil.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 - "_account.getNotificationPrefs", 148 + "com.tranquil.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 - "_account.getNotificationPrefs", 159 + "com.tranquil.account.getNotificationPrefs", 160 160 () => 161 161 jsonResponse( 162 162 mockData.notificationPrefs({ preferredChannel: "email" }), ··· 179 179 () => jsonResponse(mockData.describeServer()), 180 180 ); 181 181 mockEndpoint( 182 - "_account.getNotificationHistory", 182 + "com.tranquil.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 - "_account.getNotificationPrefs", 188 + "com.tranquil.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 - "_account.getNotificationPrefs", 202 + "com.tranquil.account.getNotificationPrefs", 203 203 () => 204 204 jsonResponse(mockData.notificationPrefs({ 205 205 discordId: "123456789", ··· 231 231 () => jsonResponse(mockData.describeServer()), 232 232 ); 233 233 mockEndpoint( 234 - "_account.getNotificationHistory", 234 + "com.tranquil.account.getNotificationHistory", 235 235 () => jsonResponse({ notifications: [] }), 236 236 ); 237 237 }); 238 238 it("shows Primary badge for email", async () => { 239 239 mockEndpoint( 240 - "_account.getNotificationPrefs", 240 + "com.tranquil.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 - "_account.getNotificationPrefs", 250 + "com.tranquil.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 - "_account.getNotificationPrefs", 265 + "com.tranquil.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 - "_account.getNotificationPrefs", 279 + "com.tranquil.account.getNotificationPrefs", 280 280 () => jsonResponse(mockData.notificationPrefs()), 281 281 ); 282 282 render(Comms); ··· 294 294 () => jsonResponse(mockData.describeServer()), 295 295 ); 296 296 mockEndpoint( 297 - "_account.getNotificationHistory", 297 + "com.tranquil.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 - "_account.getNotificationPrefs", 304 + "com.tranquil.account.getNotificationPrefs", 305 305 () => jsonResponse(mockData.notificationPrefs()), 306 306 ); 307 307 mockEndpoint( 308 - "_account.updateNotificationPrefs", 308 + "com.tranquil.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 - "_account.getNotificationPrefs", 332 + "com.tranquil.account.getNotificationPrefs", 333 333 () => jsonResponse(mockData.notificationPrefs()), 334 334 ); 335 - mockEndpoint("_account.updateNotificationPrefs", async () => { 335 + mockEndpoint("com.tranquil.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 - "_account.getNotificationPrefs", 353 + "com.tranquil.account.getNotificationPrefs", 354 354 () => jsonResponse(mockData.notificationPrefs()), 355 355 ); 356 356 mockEndpoint( 357 - "_account.updateNotificationPrefs", 357 + "com.tranquil.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 - "_account.getNotificationPrefs", 375 + "com.tranquil.account.getNotificationPrefs", 376 376 () => jsonResponse(mockData.notificationPrefs()), 377 377 ); 378 378 mockEndpoint( 379 - "_account.updateNotificationPrefs", 379 + "com.tranquil.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("_account.getNotificationPrefs", () => { 403 + mockEndpoint("com.tranquil.account.getNotificationPrefs", () => { 404 404 loadCount++; 405 405 return jsonResponse(mockData.notificationPrefs()); 406 406 }); 407 407 mockEndpoint( 408 - "_account.updateNotificationPrefs", 408 + "com.tranquil.account.updateNotificationPrefs", 409 409 () => jsonResponse({ success: true }), 410 410 ); 411 411 render(Comms); ··· 430 430 () => jsonResponse(mockData.describeServer()), 431 431 ); 432 432 mockEndpoint( 433 - "_account.getNotificationHistory", 433 + "com.tranquil.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 - "_account.getNotificationPrefs", 439 + "com.tranquil.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 - "_account.getNotificationPrefs", 456 + "com.tranquil.account.getNotificationPrefs", 457 457 () => 458 458 jsonResponse(mockData.notificationPrefs({ 459 459 discordId: "123456789", ··· 480 480 () => jsonResponse(mockData.describeServer()), 481 481 ); 482 482 mockEndpoint( 483 - "_account.getNotificationHistory", 483 + "com.tranquil.account.getNotificationHistory", 484 484 () => jsonResponse({ notifications: [] }), 485 485 ); 486 486 }); 487 487 it("shows error when loading preferences fails", async () => { 488 488 mockEndpoint( 489 - "_account.getNotificationPrefs", 489 + "com.tranquil.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 - setupDefaultMocks, 11 + setupFetchMock, 12 12 setupUnauthenticatedUser, 13 13 } from "./mocks"; 14 14 describe("Settings", () => { 15 15 beforeEach(() => { 16 16 clearMocks(); 17 - setupDefaultMocks(); 17 + setupFetchMock(); 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 - });
+3 -7
frontend/src/tests/mocks.ts
··· 206 206 () => jsonResponse({ code: "new-invite-" + Date.now() }), 207 207 ); 208 208 mockEndpoint( 209 - "_account.getNotificationPrefs", 209 + "com.tranquil.account.getNotificationPrefs", 210 210 () => jsonResponse(mockData.notificationPrefs()), 211 211 ); 212 212 mockEndpoint( 213 - "_account.updateNotificationPrefs", 213 + "com.tranquil.account.updateNotificationPrefs", 214 214 () => jsonResponse({ success: true }), 215 215 ); 216 216 mockEndpoint( 217 - "_account.getNotificationHistory", 217 + "com.tranquil.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: [] }), 247 243 ); 248 244 } 249 245 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);
+3 -6
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 buckets..." 47 + log_info "Removing minio bucket..." 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 51 50 mc alias remove local 2>/dev/null || true 52 51 fi 53 52 systemctl stop minio 2>/dev/null || true ··· 79 78 echo " - PostgreSQL database 'pds' and all data" 80 79 echo " - All Tranquil PDS configuration and credentials" 81 80 echo " - All source code in /opt/tranquil-pds" 82 - echo " - MinIO buckets 'pds-blobs' and 'pds-backups' and all data" 81 + echo " - MinIO bucket 'pds-blobs' and all blobs" 83 82 echo "" 84 83 read -p "Type 'NUKE' to confirm: " CONFIRM_NUKE 85 84 if [[ "$CONFIRM_NUKE" == "NUKE" ]]; then ··· 275 274 mc alias remove local 2>/dev/null || true 276 275 mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4 277 276 mc mb local/pds-blobs --ignore-existing 278 - mc mb local/pds-backups --ignore-existing 279 - log_success "minio buckets created" 277 + log_success "minio bucket created" 280 278 281 279 log_info "Installing rust..." 282 280 if [[ -f "$HOME/.cargo/env" ]]; then ··· 384 382 S3_ENDPOINT=http://localhost:9000 385 383 AWS_REGION=us-east-1 386 384 S3_BUCKET=pds-blobs 387 - BACKUP_S3_BUCKET=pds-backups 388 385 AWS_ACCESS_KEY_ID=minioadmin 389 386 AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD} 390 387 VALKEY_URL=redis://localhost:6379
+1 -5
scripts/test-infra.sh
··· 83 83 echo "Waiting for Valkey... ($i/30)" 84 84 sleep 1 85 85 done 86 - echo "Creating MinIO buckets..." 86 + echo "Creating MinIO bucket..." 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 93 90 cat > "$INFRA_FILE" << EOF 94 91 export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres" 95 92 export TEST_DB_PORT="${PG_PORT}" 96 93 export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}" 97 94 export S3_BUCKET="test-bucket" 98 - export BACKUP_S3_BUCKET="test-backups" 99 95 export AWS_ACCESS_KEY_ID="minioadmin" 100 96 export AWS_SECRET_ACCESS_KEY="minioadmin" 101 97 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; 5 4 pub mod delegation; 6 5 pub mod error; 7 6 pub mod identity;
+7 -26
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 - 197 185 let notifications = rows 198 186 .iter() 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 - } 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(), 213 194 }) 214 195 .collect(); 215 196
+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 315 + LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = rb.repo_id 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
+2 -4
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); 349 348 let mut record_bytes = Vec::new(); 350 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 349 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { 351 350 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); 352 351 } 353 352 let record_cid = match tracking_store.put(&record_bytes).await { ··· 410 409 } 411 410 }; 412 411 all_blob_cids.extend(extract_blob_cids(value)); 413 - let record_ipld = crate::util::json_to_ipld(value); 414 412 let mut record_bytes = Vec::new(); 415 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 413 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() { 416 414 return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response(); 417 415 } 418 416 let record_cid = match tracking_store.put(&record_bytes).await {
+134 -10
src/api/repo/record/read.rs
··· 133 133 .into_response(); 134 134 } 135 135 } 136 - return ( 137 - StatusCode::NOT_FOUND, 138 - Json(json!({"error": "RepoNotFound", "message": "Repo not found"})), 139 - ) 140 - .into_response(); 136 + let appview_endpoint = std::env::var("BSKY_APPVIEW_ENDPOINT") 137 + .unwrap_or_else(|_| "https://api.bsky.app".to_string()); 138 + let mut url = format!( 139 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 140 + appview_endpoint.trim_end_matches('/'), 141 + urlencoding::encode(&input.repo), 142 + urlencoding::encode(&input.collection), 143 + urlencoding::encode(&input.rkey) 144 + ); 145 + if let Some(cid) = &input.cid { 146 + url.push_str(&format!("&cid={}", urlencoding::encode(cid))); 147 + } 148 + info!( 149 + "Repo not found locally. Proxying getRecord for {} to AppView: {}", 150 + input.repo, url 151 + ); 152 + match proxy_client().get(&url).send().await { 153 + Ok(resp) => { 154 + let status = resp.status(); 155 + let body = match resp.bytes().await { 156 + Ok(b) => b, 157 + Err(e) => { 158 + error!("Error reading AppView proxy response: {:?}", e); 159 + return ( 160 + StatusCode::BAD_GATEWAY, 161 + Json(json!({ 162 + "error": "UpstreamFailure", 163 + "message": "Error reading upstream response from AppView" 164 + })), 165 + ) 166 + .into_response(); 167 + } 168 + }; 169 + return Response::builder() 170 + .status(status) 171 + .header("content-type", "application/json") 172 + .body(axum::body::Body::from(body)) 173 + .unwrap_or_else(|_| { 174 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() 175 + }); 176 + } 177 + Err(e) => { 178 + error!("Error proxying request to AppView: {:?}", e); 179 + return ( 180 + StatusCode::BAD_GATEWAY, 181 + Json(json!({ 182 + "error": "UpstreamFailure", 183 + "message": "Failed to reach AppView" 184 + })), 185 + ) 186 + .into_response(); 187 + } 188 + } 141 189 } 142 190 Err(_) => { 143 191 return ( ··· 158 206 let record_cid_str: String = match record_row { 159 207 Ok(Some(row)) => row.record_cid, 160 208 _ => { 161 - return ( 162 - StatusCode::NOT_FOUND, 163 - Json(json!({"error": "RecordNotFound", "message": "Record not found"})), 164 - ) 165 - .into_response(); 209 + let did = if input.repo.starts_with("did:") { 210 + input.repo.clone() 211 + } else { 212 + info!("Resolving handle {} to DID for P2P proxy", input.repo); 213 + match crate::handle::resolve_handle(&input.repo).await { 214 + Ok(d) => d, 215 + Err(e) => { 216 + error!("Failed to resolve handle {}: {}", input.repo, e); 217 + return ( 218 + StatusCode::NOT_FOUND, 219 + Json(json!({"error": "RepoNotFound", "message": "Could not resolve handle"})), 220 + ) 221 + .into_response(); 222 + } 223 + } 224 + }; 225 + 226 + let resolved_service = match state.did_resolver.resolve_did(&did).await { 227 + Some(s) => s, 228 + None => { 229 + error!("Failed to resolve PDS for DID {}", did); 230 + return ( 231 + StatusCode::NOT_FOUND, 232 + Json(json!({"error": "RepoNotFound", "message": "Could not resolve DID service endpoint"})), 233 + ) 234 + .into_response(); 235 + } 236 + }; 237 + 238 + let mut url = format!( 239 + "{}/xrpc/com.atproto.repo.getRecord?repo={}&collection={}&rkey={}", 240 + resolved_service.url.trim_end_matches('/'), 241 + urlencoding::encode(&input.repo), 242 + urlencoding::encode(&input.collection), 243 + urlencoding::encode(&input.rkey) 244 + ); 245 + if let Some(cid) = &input.cid { 246 + url.push_str(&format!("&cid={}", urlencoding::encode(cid))); 247 + } 248 + info!( 249 + "Record not found locally. Proxying getRecord for {} (DID: {}) to PDS: {}", 250 + input.repo, did, url 251 + ); 252 + 253 + match proxy_client().get(&url).send().await { 254 + Ok(resp) => { 255 + let status = resp.status(); 256 + let body = match resp.bytes().await { 257 + Ok(b) => b, 258 + Err(e) => { 259 + error!("Error reading upstream PDS response: {:?}", e); 260 + return ( 261 + StatusCode::BAD_GATEWAY, 262 + Json(json!({ 263 + "error": "UpstreamFailure", 264 + "message": "Error reading upstream response" 265 + })), 266 + ) 267 + .into_response(); 268 + } 269 + }; 270 + return Response::builder() 271 + .status(status) 272 + .header("content-type", "application/json") 273 + .body(axum::body::Body::from(body)) 274 + .unwrap_or_else(|_| { 275 + (StatusCode::INTERNAL_SERVER_ERROR, "Internal error").into_response() 276 + }); 277 + } 278 + Err(e) => { 279 + error!("Error proxying request to upstream PDS: {:?}", e); 280 + return ( 281 + StatusCode::BAD_GATEWAY, 282 + Json(json!({ 283 + "error": "UpstreamFailure", 284 + "message": "Failed to reach upstream PDS" 285 + })), 286 + ) 287 + .into_response(); 288 + } 289 + } 166 290 } 167 291 }; 168 292 if let Some(expected_cid) = &input.cid
+1 -2
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); 386 385 let mut record_bytes = Vec::new(); 387 - serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld) 386 + serde_ipld_dagcbor::to_writer(&mut record_bytes, record) 388 387 .map_err(|e| format!("Failed to serialize record: {:?}", e))?; 389 388 let record_cid = tracking_store 390 389 .put(&record_bytes)
+2 -4
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); 301 300 let mut record_bytes = Vec::new(); 302 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 301 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() { 303 302 return ( 304 303 StatusCode::BAD_REQUEST, 305 304 Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})), ··· 551 550 } 552 551 } 553 552 let existing_cid = mst.get(&key).await.ok().flatten(); 554 - let record_ipld = crate::util::json_to_ipld(&input.record); 555 553 let mut record_bytes = Vec::new(); 556 - if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() { 554 + if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() { 557 555 return ( 558 556 StatusCode::BAD_REQUEST, 559 557 Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
+44 -15
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>, 570 571 } 571 572 572 573 pub async fn deactivate_account( ··· 617 618 618 619 let did = auth_user.did; 619 620 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 + 620 636 let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did) 621 637 .fetch_optional(&state.db) 622 638 .await 623 639 .ok() 624 640 .flatten(); 625 641 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; 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 + }; 633 666 634 667 match result { 635 668 Ok(_) => { 636 669 if let Some(ref h) = handle { 637 670 let _ = state.cache.delete(&format!("handle:{}", h)).await; 638 671 } 639 - if let Err(e) = crate::api::repo::record::sequence_account_event( 640 - &state, 641 - &did, 642 - false, 643 - Some("deactivated"), 644 - ) 645 - .await 672 + if let Err(e) = 673 + crate::api::repo::record::sequence_account_event(&state, &did, false, Some(status)) 674 + .await 646 675 { 647 - warn!("Failed to sequence account deactivated event: {}", e); 676 + warn!("Failed to sequence account {} event: {}", status, e); 648 677 } 649 678 (StatusCode::OK, Json(json!({}))).into_response() 650 679 }
-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 - }
+241 -6
src/api/server/migration.rs
··· 6 6 http::StatusCode, 7 7 response::{IntoResponse, Response}, 8 8 }; 9 - use chrono::Utc; 9 + use chrono::{DateTime, 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 + 13 241 #[derive(Debug, Clone, Serialize, Deserialize)] 14 242 #[serde(rename_all = "camelCase")] 15 243 pub struct VerificationMethod { ··· 47 275 }; 48 276 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 49 277 let http_uri = format!( 50 - "https://{}/xrpc/_account.updateDidDocument", 278 + "https://{}/xrpc/com.tranquil.account.updateDidDocument", 51 279 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 52 280 ); 53 281 let auth_user = match crate::auth::validate_token_with_dpop( ··· 77 305 } 78 306 79 307 let user = match sqlx::query!( 80 - "SELECT id, handle, deactivated_at FROM users WHERE did = $1", 308 + "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1", 81 309 auth_user.did 82 310 ) 83 311 .fetch_optional(&state.db) ··· 91 319 } 92 320 }; 93 321 94 - if user.deactivated_at.is_some() { 95 - return ApiError::AccountDeactivated.into_response(); 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(); 96 331 } 97 332 98 333 if let Some(ref methods) = input.verification_methods { ··· 217 452 }; 218 453 let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok()); 219 454 let http_uri = format!( 220 - "https://{}/xrpc/_account.getDidDocument", 455 + "https://{}/xrpc/com.tranquil.account.getDidDocument", 221 456 std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string()) 222 457 ); 223 458 let auth_user = match crate::auth::validate_token_with_dpop(
+5 -2
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::{check_email_verified, confirm_email, request_email_update, update_email}; 25 + pub use email::{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::{get_did_document, update_did_document}; 29 + pub use migration::{ 30 + clear_migration_forwarding, get_did_document, get_migration_status, update_did_document, 31 + update_migration_forwarding, 32 + }; 30 33 pub use passkey_account::{ 31 34 complete_passkey_setup, create_passkey_account, recover_passkey_account, 32 35 request_passkey_recovery, start_passkey_registration_for_setup,
+55 -59
src/lib.rs
··· 57 57 get(api::server::get_session), 58 58 ) 59 59 .route( 60 - "/xrpc/_account.listSessions", 60 + "/xrpc/com.tranquil.account.listSessions", 61 61 get(api::server::list_sessions), 62 62 ) 63 63 .route( 64 - "/xrpc/_account.revokeSession", 64 + "/xrpc/com.tranquil.account.revokeSession", 65 65 post(api::server::revoke_session), 66 66 ) 67 67 .route( 68 - "/xrpc/_account.revokeAllSessions", 68 + "/xrpc/com.tranquil.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/_account.changePassword", 211 + "/xrpc/com.tranquil.account.changePassword", 212 212 post(api::server::change_password), 213 213 ) 214 214 .route( 215 - "/xrpc/_account.removePassword", 215 + "/xrpc/com.tranquil.account.removePassword", 216 216 post(api::server::remove_password), 217 217 ) 218 218 .route( 219 - "/xrpc/_account.getPasswordStatus", 219 + "/xrpc/com.tranquil.account.getPasswordStatus", 220 220 get(api::server::get_password_status), 221 221 ) 222 222 .route( 223 - "/xrpc/_account.getReauthStatus", 223 + "/xrpc/com.tranquil.account.getReauthStatus", 224 224 get(api::server::get_reauth_status), 225 225 ) 226 226 .route( 227 - "/xrpc/_account.reauthPassword", 227 + "/xrpc/com.tranquil.account.reauthPassword", 228 228 post(api::server::reauth_password), 229 229 ) 230 - .route("/xrpc/_account.reauthTotp", post(api::server::reauth_totp)) 231 230 .route( 232 - "/xrpc/_account.reauthPasskeyStart", 231 + "/xrpc/com.tranquil.account.reauthTotp", 232 + post(api::server::reauth_totp), 233 + ) 234 + .route( 235 + "/xrpc/com.tranquil.account.reauthPasskeyStart", 233 236 post(api::server::reauth_passkey_start), 234 237 ) 235 238 .route( 236 - "/xrpc/_account.reauthPasskeyFinish", 239 + "/xrpc/com.tranquil.account.reauthPasskeyFinish", 237 240 post(api::server::reauth_passkey_finish), 238 241 ) 239 242 .route( 240 - "/xrpc/_account.getLegacyLoginPreference", 243 + "/xrpc/com.tranquil.account.getLegacyLoginPreference", 241 244 get(api::server::get_legacy_login_preference), 242 245 ) 243 246 .route( 244 - "/xrpc/_account.updateLegacyLoginPreference", 247 + "/xrpc/com.tranquil.account.updateLegacyLoginPreference", 245 248 post(api::server::update_legacy_login_preference), 246 249 ) 247 250 .route( 248 - "/xrpc/_account.updateLocale", 251 + "/xrpc/com.tranquil.account.updateLocale", 249 252 post(api::server::update_locale), 250 253 ) 251 254 .route( 252 - "/xrpc/_account.listTrustedDevices", 255 + "/xrpc/com.tranquil.account.listTrustedDevices", 253 256 get(api::server::list_trusted_devices), 254 257 ) 255 258 .route( 256 - "/xrpc/_account.revokeTrustedDevice", 259 + "/xrpc/com.tranquil.account.revokeTrustedDevice", 257 260 post(api::server::revoke_trusted_device), 258 261 ) 259 262 .route( 260 - "/xrpc/_account.updateTrustedDevice", 263 + "/xrpc/com.tranquil.account.updateTrustedDevice", 261 264 post(api::server::update_trusted_device), 262 265 ) 263 266 .route( 264 - "/xrpc/_account.createPasskeyAccount", 267 + "/xrpc/com.tranquil.account.createPasskeyAccount", 265 268 post(api::server::create_passkey_account), 266 269 ) 267 270 .route( 268 - "/xrpc/_account.startPasskeyRegistrationForSetup", 271 + "/xrpc/com.tranquil.account.startPasskeyRegistrationForSetup", 269 272 post(api::server::start_passkey_registration_for_setup), 270 273 ) 271 274 .route( 272 - "/xrpc/_account.completePasskeySetup", 275 + "/xrpc/com.tranquil.account.completePasskeySetup", 273 276 post(api::server::complete_passkey_setup), 274 277 ) 275 278 .route( 276 - "/xrpc/_account.requestPasskeyRecovery", 279 + "/xrpc/com.tranquil.account.requestPasskeyRecovery", 277 280 post(api::server::request_passkey_recovery), 278 281 ) 279 282 .route( 280 - "/xrpc/_account.recoverPasskeyAccount", 283 + "/xrpc/com.tranquil.account.recoverPasskeyAccount", 281 284 post(api::server::recover_passkey_account), 282 285 ) 283 286 .route( 284 - "/xrpc/_account.updateDidDocument", 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", 285 300 post(api::server::update_did_document), 286 301 ) 287 302 .route( 288 - "/xrpc/_account.getDidDocument", 303 + "/xrpc/com.tranquil.account.getDidDocument", 289 304 get(api::server::get_did_document), 290 305 ) 291 306 .route( 292 307 "/xrpc/com.atproto.server.requestEmailUpdate", 293 308 post(api::server::request_email_update), 294 - ) 295 - .route( 296 - "/xrpc/_checkEmailVerified", 297 - post(api::server::check_email_verified), 298 309 ) 299 310 .route( 300 311 "/xrpc/com.atproto.server.confirmEmail", ··· 421 432 get(api::admin::get_invite_codes), 422 433 ) 423 434 .route( 424 - "/xrpc/_admin.getServerStats", 435 + "/xrpc/com.tranquil.admin.getServerStats", 425 436 get(api::admin::get_server_stats), 426 437 ) 427 438 .route( 428 - "/xrpc/_server.getConfig", 439 + "/xrpc/com.tranquil.server.getConfig", 429 440 get(api::admin::get_server_config), 430 441 ) 431 442 .route( 432 - "/xrpc/_admin.updateServerConfig", 443 + "/xrpc/com.tranquil.admin.updateServerConfig", 433 444 post(api::admin::update_server_config), 434 445 ) 435 446 .route( ··· 564 575 post(api::temp::dereference_scope), 565 576 ) 566 577 .route( 567 - "/xrpc/_account.getNotificationPrefs", 578 + "/xrpc/com.tranquil.account.getNotificationPrefs", 568 579 get(api::notification_prefs::get_notification_prefs), 569 580 ) 570 581 .route( 571 - "/xrpc/_account.updateNotificationPrefs", 582 + "/xrpc/com.tranquil.account.updateNotificationPrefs", 572 583 post(api::notification_prefs::update_notification_prefs), 573 584 ) 574 585 .route( 575 - "/xrpc/_account.getNotificationHistory", 586 + "/xrpc/com.tranquil.account.getNotificationHistory", 576 587 get(api::notification_prefs::get_notification_history), 577 588 ) 578 589 .route( 579 - "/xrpc/_account.confirmChannelVerification", 590 + "/xrpc/com.tranquil.account.confirmChannelVerification", 580 591 post(api::verification::confirm_channel_verification), 581 592 ) 582 593 .route( 583 - "/xrpc/_account.verifyToken", 594 + "/xrpc/com.tranquil.account.verifyToken", 584 595 post(api::server::verify_token), 585 596 ) 586 597 .route( 587 - "/xrpc/_delegation.listControllers", 598 + "/xrpc/com.tranquil.delegation.listControllers", 588 599 get(api::delegation::list_controllers), 589 600 ) 590 601 .route( 591 - "/xrpc/_delegation.addController", 602 + "/xrpc/com.tranquil.delegation.addController", 592 603 post(api::delegation::add_controller), 593 604 ) 594 605 .route( 595 - "/xrpc/_delegation.removeController", 606 + "/xrpc/com.tranquil.delegation.removeController", 596 607 post(api::delegation::remove_controller), 597 608 ) 598 609 .route( 599 - "/xrpc/_delegation.updateControllerScopes", 610 + "/xrpc/com.tranquil.delegation.updateControllerScopes", 600 611 post(api::delegation::update_controller_scopes), 601 612 ) 602 613 .route( 603 - "/xrpc/_delegation.listControlledAccounts", 614 + "/xrpc/com.tranquil.delegation.listControlledAccounts", 604 615 get(api::delegation::list_controlled_accounts), 605 616 ) 606 617 .route( 607 - "/xrpc/_delegation.getAuditLog", 618 + "/xrpc/com.tranquil.delegation.getAuditLog", 608 619 get(api::delegation::get_audit_log), 609 620 ) 610 621 .route( 611 - "/xrpc/_delegation.getScopePresets", 622 + "/xrpc/com.tranquil.delegation.getScopePresets", 612 623 get(api::delegation::get_scope_presets), 613 624 ) 614 625 .route( 615 - "/xrpc/_delegation.createDelegatedAccount", 626 + "/xrpc/com.tranquil.delegation.createDelegatedAccount", 616 627 post(api::delegation::create_delegated_account), 617 628 ) 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)) 633 629 .route( 634 630 "/xrpc/app.bsky.ageassurance.getState", 635 631 get(api::age_assurance::get_state),
+1 -18
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_backup_tasks, start_scheduled_tasks, 10 + 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 - 99 86 let scheduled_handle = tokio::spawn(start_scheduled_tasks( 100 87 state.db.clone(), 101 88 state.blob_store.clone(), ··· 127 114 comms_handle.await.ok(); 128 115 129 116 if let Some(handle) = crawlers_handle { 130 - handle.await.ok(); 131 - } 132 - 133 - if let Some(handle) = backup_handle { 134 117 handle.await.ok(); 135 118 } 136 119
-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>, 36 35 } 37 36 38 37 impl Default for RateLimiters { ··· 92 91 .unwrap() 93 92 .allow_burst(NonZeroU32::new(50).unwrap()), 94 93 )), 95 - verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute( 96 - NonZeroU32::new(60).unwrap(), 97 - ))), 98 94 } 99 95 } 100 96
+1 -311
src/scheduled.rs
··· 11 11 use tracing::{debug, error, info, warn}; 12 12 13 13 use crate::repo::PostgresBlockStore; 14 - use crate::storage::{BackupStorage, BlobStorage}; 15 - use crate::sync::car::encode_car_header; 14 + use crate::storage::BlobStorage; 16 15 17 16 pub async fn backfill_genesis_commit_blocks(db: &PgPool, block_store: PostgresBlockStore) { 18 17 let broken_genesis_commits = match sqlx::query!( ··· 564 563 565 564 Ok(()) 566 565 } 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 - }
+1 -8
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::{BackupStorage, BlobStorage, S3BlobStorage}; 7 + use crate::storage::{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>>, 20 19 pub firehose_tx: broadcast::Sender<SequencedEvent>, 21 20 pub rate_limiters: Arc<RateLimiters>, 22 21 pub circuit_breakers: Arc<CircuitBreakers>, ··· 40 39 TotpVerify, 41 40 HandleUpdate, 42 41 HandleUpdateDaily, 43 - VerificationCheck, 44 42 } 45 43 46 44 impl RateLimitKind { ··· 60 58 Self::TotpVerify => "totp_verify", 61 59 Self::HandleUpdate => "handle_update", 62 60 Self::HandleUpdateDaily => "handle_update_daily", 63 - Self::VerificationCheck => "verification_check", 64 61 } 65 62 } 66 63 ··· 80 77 Self::TotpVerify => (5, 300_000), 81 78 Self::HandleUpdate => (10, 300_000), 82 79 Self::HandleUpdateDaily => (50, 86_400_000), 83 - Self::VerificationCheck => (60, 60_000), 84 80 } 85 81 } 86 82 } ··· 135 131 136 132 let block_store = PostgresBlockStore::new(db.clone()); 137 133 let blob_store = S3BlobStorage::new().await; 138 - let backup_storage = BackupStorage::new().await.map(Arc::new); 139 134 140 135 let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE") 141 136 .ok() ··· 152 147 db, 153 148 block_store, 154 149 blob_store: Arc::new(blob_store), 155 - backup_storage, 156 150 firehose_tx, 157 151 rate_limiters, 158 152 circuit_breakers, ··· 205 199 RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify, 206 200 RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update, 207 201 RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily, 208 - RateLimitKind::VerificationCheck => &self.rate_limiters.verification_check, 209 202 }; 210 203 211 204 let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+16 -119
src/storage/mod.rs
··· 32 32 33 33 impl S3BlobStorage { 34 34 pub async fn new() -> Self { 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); 35 + let region_provider = RegionProviderChain::default_provider().or_else("us-east-1"); 70 36 71 - if !backup_enabled { 72 - return None; 73 - } 37 + let config = aws_config::defaults(BehaviorVersion::latest()) 38 + .region(region_provider) 39 + .load() 40 + .await; 74 41 75 - let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?; 76 - let client = create_s3_client().await; 77 - Some(Self { client, bucket }) 78 - } 42 + let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set"); 79 43 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 - } 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 + }; 86 53 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 - })?; 112 - 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(); 139 - 140 - crate::metrics::record_s3_operation("backup_get", "success"); 141 - Ok(data) 142 - } 143 - 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 - })?; 155 - 156 - crate::metrics::record_s3_operation("backup_delete", "success"); 157 - Ok(()) 54 + Self { client, bucket } 158 55 } 159 56 } 160 57
+12 -23
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") 80 81 { 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 - } 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 + }]; 104 93 } 105 94 obj.values() 106 95 .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; 4 2 use rand::Rng; 5 - use serde_json::Value as JsonValue; 6 3 use sqlx::PgPool; 7 - use std::collections::BTreeMap; 8 - use std::str::FromStr; 9 4 use std::sync::OnceLock; 10 5 use uuid::Uuid; 11 6 ··· 155 150 format!("{}{}", pds_public_url(), path) 156 151 } 157 152 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 - 189 153 #[cfg(test)] 190 154 mod tests { 191 155 use super::*; ··· 259 223 for part in parts { 260 224 assert_eq!(part.len(), 4); 261 225 } 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"); 355 226 } 356 227 }
+40 -10
tests/account_notifications.rs
··· 27 27 } 28 28 29 29 let resp = client 30 - .get(format!("{}/xrpc/_account.getNotificationHistory", base)) 30 + .get(format!( 31 + "{}/xrpc/com.tranquil.account.getNotificationHistory", 32 + base 33 + )) 31 34 .header("Authorization", format!("Bearer {}", token)) 32 35 .send() 33 36 .await ··· 53 56 "discordId": "123456789" 54 57 }); 55 58 let resp = client 56 - .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 59 + .post(format!( 60 + "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 61 + base 62 + )) 57 63 .header("Authorization", format!("Bearer {}", token)) 58 64 .json(&prefs) 59 65 .send() ··· 95 101 "code": code 96 102 }); 97 103 let resp = client 98 - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 104 + .post(format!( 105 + "{}/xrpc/com.tranquil.account.confirmChannelVerification", 106 + base 107 + )) 99 108 .header("Authorization", format!("Bearer {}", token)) 100 109 .json(&input) 101 110 .send() ··· 104 113 assert_eq!(resp.status(), 200); 105 114 106 115 let resp = client 107 - .get(format!("{}/xrpc/_account.getNotificationPrefs", base)) 116 + .get(format!( 117 + "{}/xrpc/com.tranquil.account.getNotificationPrefs", 118 + base 119 + )) 108 120 .header("Authorization", format!("Bearer {}", token)) 109 121 .send() 110 122 .await ··· 124 136 "telegramUsername": "testuser" 125 137 }); 126 138 let resp = client 127 - .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 139 + .post(format!( 140 + "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 141 + base 142 + )) 128 143 .header("Authorization", format!("Bearer {}", token)) 129 144 .json(&prefs) 130 145 .send() ··· 138 153 "code": "XXXX-XXXX-XXXX-XXXX" 139 154 }); 140 155 let resp = client 141 - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 156 + .post(format!( 157 + "{}/xrpc/com.tranquil.account.confirmChannelVerification", 158 + base 159 + )) 142 160 .header("Authorization", format!("Bearer {}", token)) 143 161 .json(&input) 144 162 .send() ··· 163 181 "code": "XXXX-XXXX-XXXX-XXXX" 164 182 }); 165 183 let resp = client 166 - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 184 + .post(format!( 185 + "{}/xrpc/com.tranquil.account.confirmChannelVerification", 186 + base 187 + )) 167 188 .header("Authorization", format!("Bearer {}", token)) 168 189 .json(&input) 169 190 .send() ··· 188 209 "email": unique_email 189 210 }); 190 211 let resp = client 191 - .post(format!("{}/xrpc/_account.updateNotificationPrefs", base)) 212 + .post(format!( 213 + "{}/xrpc/com.tranquil.account.updateNotificationPrefs", 214 + base 215 + )) 192 216 .header("Authorization", format!("Bearer {}", token)) 193 217 .json(&prefs) 194 218 .send() ··· 239 263 "code": code 240 264 }); 241 265 let resp = client 242 - .post(format!("{}/xrpc/_account.confirmChannelVerification", base)) 266 + .post(format!( 267 + "{}/xrpc/com.tranquil.account.confirmChannelVerification", 268 + base 269 + )) 243 270 .header("Authorization", format!("Bearer {}", token)) 244 271 .json(&input) 245 272 .send() ··· 248 275 assert_eq!(resp.status(), 200); 249 276 250 277 let resp = client 251 - .get(format!("{}/xrpc/_account.getNotificationPrefs", base)) 278 + .get(format!( 279 + "{}/xrpc/com.tranquil.account.getNotificationPrefs", 280 + base 281 + )) 252 282 .header("Authorization", format!("Bearer {}", token)) 253 283 .send() 254 284 .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/_admin.getServerStats", base)) 14 + .get(format!("{}/xrpc/com.tranquil.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/_admin.getServerStats", base)) 36 + .get(format!("{}/xrpc/com.tranquil.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 - }
+24 -6
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!("{}/xrpc/_account.changePassword", base_url().await)) 35 + .post(format!( 36 + "{}/xrpc/com.tranquil.account.changePassword", 37 + base_url().await 38 + )) 36 39 .bearer_auth(&jwt) 37 40 .json(&json!({ 38 41 "currentPassword": old_password, ··· 83 86 let client = client(); 84 87 let (_, jwt) = setup_new_user("change-pw-wrong").await; 85 88 let res = client 86 - .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 89 + .post(format!( 90 + "{}/xrpc/com.tranquil.account.changePassword", 91 + base_url().await 92 + )) 87 93 .bearer_auth(&jwt) 88 94 .json(&json!({ 89 95 "currentPassword": "Wrongpass999!", ··· 123 129 let did = create_body["did"].as_str().unwrap(); 124 130 let jwt = verify_new_account(&client, did).await; 125 131 let res = client 126 - .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 132 + .post(format!( 133 + "{}/xrpc/com.tranquil.account.changePassword", 134 + base_url().await 135 + )) 127 136 .bearer_auth(&jwt) 128 137 .json(&json!({ 129 138 "currentPassword": password, ··· 142 151 let client = client(); 143 152 let (_, jwt) = setup_new_user("change-pw-empty").await; 144 153 let res = client 145 - .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 154 + .post(format!( 155 + "{}/xrpc/com.tranquil.account.changePassword", 156 + base_url().await 157 + )) 146 158 .bearer_auth(&jwt) 147 159 .json(&json!({ 148 160 "currentPassword": "", ··· 159 171 let client = client(); 160 172 let (_, jwt) = setup_new_user("change-pw-emptynew").await; 161 173 let res = client 162 - .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 174 + .post(format!( 175 + "{}/xrpc/com.tranquil.account.changePassword", 176 + base_url().await 177 + )) 163 178 .bearer_auth(&jwt) 164 179 .json(&json!({ 165 180 "currentPassword": "E2epass123!", ··· 175 190 async fn test_change_password_requires_auth() { 176 191 let client = client(); 177 192 let res = client 178 - .post(format!("{}/xrpc/_account.changePassword", base_url().await)) 193 + .post(format!( 194 + "{}/xrpc/com.tranquil.account.changePassword", 195 + base_url().await 196 + )) 179 197 .json(&json!({ 180 198 "currentPassword": "Oldpass123!", 181 199 "newPassword": "Newpass123!"
+247 -28
tests/did_web.rs
··· 547 547 } 548 548 549 549 #[tokio::test] 550 - async fn test_did_web_can_edit_did_document() { 550 + async fn test_deactivate_with_migrating_to() { 551 551 let client = client(); 552 552 let base = base_url().await; 553 - let handle = format!("doc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 553 + let handle = format!("mig{}", &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"; 570 571 let res = client 571 - .get(format!("{}/xrpc/_account.getDidDocument", base)) 572 + .post(format!( 573 + "{}/xrpc/com.atproto.server.deactivateAccount", 574 + base 575 + )) 572 576 .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) 573 615 .send() 574 616 .await 575 617 .expect("Failed to send request"); 576 618 assert_eq!(res.status(), StatusCode::OK); 577 619 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"); 578 664 assert!( 579 - body["didDocument"].is_object(), 580 - "Should return DID document" 665 + res.status().is_client_error(), 666 + "createRecord should fail for migrated account: {}", 667 + res.status() 668 + ); 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() 581 688 ); 582 - assert_eq!( 583 - body["didDocument"]["id"], did, 584 - "DID document should have correct id" 689 + let res = client 690 + .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base)) 691 + .bearer_auth(&jwt) 692 + .json(&json!({ 693 + "repo": did, 694 + "collection": "app.bsky.feed.post", 695 + "rkey": "test123" 696 + })) 697 + .send() 698 + .await 699 + .expect("Failed to send request"); 700 + assert!( 701 + res.status().is_client_error(), 702 + "deleteRecord should fail for migrated account: {}", 703 + res.status() 585 704 ); 586 705 let res = client 587 - .post(format!("{}/xrpc/_account.updateDidDocument", base)) 706 + .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base)) 588 707 .bearer_auth(&jwt) 589 708 .json(&json!({ 590 - "alsoKnownAs": ["at://custom.handle.test"] 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 + }] 591 719 })) 592 720 .send() 593 721 .await 594 722 .expect("Failed to send request"); 595 - assert_eq!( 596 - res.status(), 597 - StatusCode::OK, 598 - "Non-migrated did:web user should be able to update DID document" 723 + 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() 599 740 ); 741 + } 742 + 743 + #[tokio::test] 744 + async fn test_migrated_session_status() { 745 + let client = client(); 746 + let base = base_url().await; 747 + let handle = format!("ses{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 748 + let payload = json!({ 749 + "handle": handle, 750 + "email": format!("{}@example.com", handle), 751 + "password": "Testpass123!", 752 + "didType": "web" 753 + }); 754 + let res = client 755 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 756 + .json(&payload) 757 + .send() 758 + .await 759 + .expect("Failed to send request"); 760 + assert_eq!(res.status(), StatusCode::OK); 600 761 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"); 762 + let did = body["did"].as_str().expect("No DID").to_string(); 763 + let jwt = verify_new_account(&client, &did).await; 764 + 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); 605 773 assert!( 606 - also_known_as 607 - .iter() 608 - .any(|v| v.as_str() == Some("at://custom.handle.test")), 609 - "alsoKnownAs should contain custom entry" 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 + .post(format!( 780 + "{}/xrpc/com.atproto.server.deactivateAccount", 781 + base 782 + )) 783 + .bearer_auth(&jwt) 784 + .json(&json!({ "migratingTo": target_pds })) 785 + .send() 786 + .await 787 + .expect("Failed to send request"); 788 + assert_eq!(res.status(), StatusCode::OK); 789 + let res = client 790 + .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 791 + .bearer_auth(&jwt) 792 + .send() 793 + .await 794 + .expect("Failed to send request"); 795 + assert_eq!(res.status(), StatusCode::OK); 796 + 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" 610 808 ); 611 809 } 612 810 613 811 #[tokio::test] 614 - async fn test_deactivate_account_basic() { 812 + async fn test_migrating_to_ignored_for_did_plc() { 615 813 let client = client(); 616 814 let base = base_url().await; 617 - let handle = format!("dea{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 815 + let handle = format!("plc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 618 816 let payload = json!({ 619 817 "handle": handle, 620 818 "email": format!("{}@example.com", handle), 621 819 "password": "Testpass123!", 622 - "didType": "web" 820 + "didType": "plc" 623 821 }); 624 822 let res = client 625 823 .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) ··· 630 828 assert_eq!(res.status(), StatusCode::OK); 631 829 let body: Value = res.json().await.expect("Response was not JSON"); 632 830 let did = body["did"].as_str().expect("No DID").to_string(); 831 + assert!(did.starts_with("did:plc:"), "Should be did:plc account"); 633 832 let jwt = verify_new_account(&client, &did).await; 634 833 let res = client 635 834 .post(format!( ··· 637 836 base 638 837 )) 639 838 .bearer_auth(&jwt) 640 - .json(&json!({})) 839 + .json(&json!({ "migratingTo": "https://pds2.example.com" })) 641 840 .send() 642 841 .await 643 842 .expect("Failed to send request"); 644 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 + ); 645 860 let res = client 646 861 .get(format!("{}/xrpc/com.atproto.server.getSession", base)) 647 862 .bearer_auth(&jwt) ··· 650 865 .expect("Failed to send request"); 651 866 assert_eq!(res.status(), StatusCode::OK); 652 867 let body: Value = res.json().await.expect("Response was not JSON"); 653 - assert_eq!(body["active"], false, "Account should be deactivated"); 868 + assert_eq!(body["active"], false); 654 869 assert_eq!( 655 870 body["status"], "deactivated", 656 - "Status should be '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" 657 876 ); 658 877 }
+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; 4 5 use common::{base_url, client, get_test_db_pool}; 5 6 use helpers::verify_new_account; 6 7 use reqwest::{StatusCode, redirect};
+4 -1
tests/oauth_security.rs
··· 1116 1116 1117 1117 let delegated_handle = format!("dg{}", suffix); 1118 1118 let delegated_res = http_client 1119 - .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url)) 1119 + .post(format!( 1120 + "{}/xrpc/com.tranquil.delegation.createDelegatedAccount", 1121 + url 1122 + )) 1120 1123 .bearer_auth(controller_jwt) 1121 1124 .json(&json!({ 1122 1125 "handle": delegated_handle,
+36 -9
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!("{}/xrpc/_account.listSessions", base_url().await)) 13 + .get(format!( 14 + "{}/xrpc/com.tranquil.account.listSessions", 15 + base_url().await 16 + )) 14 17 .bearer_auth(&jwt) 15 18 .send() 16 19 .await ··· 80 83 let login_body: Value = login_res.json().await.unwrap(); 81 84 let jwt2 = login_body["accessJwt"].as_str().unwrap(); 82 85 let list_res = client 83 - .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 86 + .get(format!( 87 + "{}/xrpc/com.tranquil.account.listSessions", 88 + base_url().await 89 + )) 84 90 .bearer_auth(jwt2) 85 91 .send() 86 92 .await ··· 100 106 async fn test_list_sessions_requires_auth() { 101 107 let client = client(); 102 108 let res = client 103 - .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 109 + .get(format!( 110 + "{}/xrpc/com.tranquil.account.listSessions", 111 + base_url().await 112 + )) 104 113 .send() 105 114 .await 106 115 .expect("Failed to send request"); ··· 149 158 let login_body: Value = login_res.json().await.unwrap(); 150 159 let jwt2 = login_body["accessJwt"].as_str().unwrap(); 151 160 let list_res = client 152 - .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 161 + .get(format!( 162 + "{}/xrpc/com.tranquil.account.listSessions", 163 + base_url().await 164 + )) 153 165 .bearer_auth(jwt2) 154 166 .send() 155 167 .await ··· 165 177 ); 166 178 let session_id = other_session.unwrap()["id"].as_str().unwrap(); 167 179 let revoke_res = client 168 - .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 180 + .post(format!( 181 + "{}/xrpc/com.tranquil.account.revokeSession", 182 + base_url().await 183 + )) 169 184 .bearer_auth(jwt2) 170 185 .json(&json!({"sessionId": session_id})) 171 186 .send() ··· 173 188 .expect("Failed to revoke session"); 174 189 assert_eq!(revoke_res.status(), StatusCode::OK); 175 190 let list_after_res = client 176 - .get(format!("{}/xrpc/_account.listSessions", base_url().await)) 191 + .get(format!( 192 + "{}/xrpc/com.tranquil.account.listSessions", 193 + base_url().await 194 + )) 177 195 .bearer_auth(jwt2) 178 196 .send() 179 197 .await ··· 195 213 let client = client(); 196 214 let (_, jwt) = setup_new_user("revoke-invalid").await; 197 215 let res = client 198 - .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 216 + .post(format!( 217 + "{}/xrpc/com.tranquil.account.revokeSession", 218 + base_url().await 219 + )) 199 220 .bearer_auth(&jwt) 200 221 .json(&json!({"sessionId": "not-a-number"})) 201 222 .send() ··· 209 230 let client = client(); 210 231 let (_, jwt) = setup_new_user("revoke-notfound").await; 211 232 let res = client 212 - .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 233 + .post(format!( 234 + "{}/xrpc/com.tranquil.account.revokeSession", 235 + base_url().await 236 + )) 213 237 .bearer_auth(&jwt) 214 238 .json(&json!({"sessionId": "jwt:999999999"})) 215 239 .send() ··· 222 246 async fn test_revoke_session_requires_auth() { 223 247 let client = client(); 224 248 let res = client 225 - .post(format!("{}/xrpc/_account.revokeSession", base_url().await)) 249 + .post(format!( 250 + "{}/xrpc/com.tranquil.account.revokeSession", 251 + base_url().await 252 + )) 226 253 .json(&json!({"sessionId": "1"})) 227 254 .send() 228 255 .await

History

4 rounds 12 comments
sign up or login to add to the discussion
5 commits
expand
40568e20
try to fix
149ed8e3
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
ced673cf
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
19e88430
feat: Proxy getRecord requests to the user's PDS after DID resolution instead of a central AppView endpoint.
1694091e
Backups, adversarial migrations
expand 0 comments
closed without merging
6 commits
expand
4d1b4c33
fix hardcoded host and port
44cd7259
try to fix
5dc814ee
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
fc8641aa
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
2f966608
feat: Proxy getRecord requests to the user's PDS after DID resolution instead of a central AppView endpoint.
085621dc
Backups, adversarial migrations
expand 1 comment
5 commits
expand
4d1b4c33
fix hardcoded host and port
44cd7259
try to fix
5dc814ee
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
fc8641aa
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
2f966608
feat: Proxy getRecord requests to the user's PDS after DID resolution instead of a central AppView endpoint.
expand 5 comments

there, i fixed it

Would you mind rebasing or something? :p i can't see nothin in there (I know it's my fault for pushing massive changes while your PR is open but whatchagonnadoabouttit

i cant resubmit

it wont let me..

yeah it sucks

4 commits
expand
4d1b4c33
fix hardcoded host and port
44cd7259
try to fix
5dc814ee
feat: Implement Pushed Authorization Requests (PAR) for OAuth migration and proxy missing repo records to AppView.
fc8641aa
feat: Proxy record reads to AppView for local misses and improve frontend type safety.
expand 6 comments

tangled is so funny why did it include the commit already merged here

i dont know how to fix that merge conflict tangled is the best git platform oat

what is this mystical BSKY_APPVIEW_ENDPOINT about :p what if im running tranquil and I wanna support every bsky-esque appview equally?? zeppelin may return in spirit

it's so i can add a backdoor to the ccp

i would reset to the commit before the port/host hardoded thing fix i did and then merge this one as it includes it

like lewis mentioned i dont quite get why the dedicated proxying to a configured bsky appview is needed here? it makes tranquil less generic, less spec compliant and i dont see what functionality it offers over the spec compliant proxying thats already there? (and that modern social-app has luckily been updated to use as it should)