+28
.sqlx/query-017b04caf42b30f2c8f9468acf61a83244b7c2fa5cacfaee41a946a6af5ef68e.json
+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
-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
+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
-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
+22
.sqlx/query-6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT email_verified FROM users WHERE email = $1 OR handle = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "email_verified",
9
+
"type_info": "Bool"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
false
19
+
]
20
+
},
21
+
"hash": "6258398accee69e0c5f455a3c0ecc273b3da6ef5bb4d8660adafe63d8e3cd2d4"
22
+
}
+7
-7
.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json
.sqlx/query-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json
+7
-7
.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json
.sqlx/query-e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1",
3
+
"query": "SELECT id, handle, deactivated_at FROM users WHERE did = $1",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
10
10
},
11
11
{
12
12
"ordinal": 1,
13
-
"name": "migrated_to_pds",
13
+
"name": "handle",
14
14
"type_info": "Text"
15
15
},
16
16
{
17
17
"ordinal": 2,
18
-
"name": "handle",
19
-
"type_info": "Text"
18
+
"name": "deactivated_at",
19
+
"type_info": "Timestamptz"
20
20
}
21
21
],
22
22
"parameters": {
···
26
26
},
27
27
"nullable": [
28
28
false,
29
-
true,
30
-
false
29
+
false,
30
+
true
31
31
]
32
32
},
33
-
"hash": "63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073"
33
+
"hash": "e60550cc972a5b0dd7cbdbc20d6ae6439eae3811d488166dca1b41bcc11f81f7"
34
34
}
+2
-2
.sqlx/query-6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115.json
.sqlx/query-ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf.json
+2
-2
.sqlx/query-6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115.json
.sqlx/query-ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf.json
···
1
1
{
2
2
"db_name": "PostgreSQL",
3
-
"query": "\n SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = rb.repo_id\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3\n ",
3
+
"query": "\n SELECT rb.blob_cid, rb.record_uri\n FROM record_blobs rb\n LEFT JOIN blobs b ON rb.blob_cid = b.cid\n WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2\n ORDER BY rb.blob_cid\n LIMIT $3\n ",
4
4
"describe": {
5
5
"columns": [
6
6
{
···
26
26
false
27
27
]
28
28
},
29
-
"hash": "6f88c5e63c1beb47733daed5295492d59c649a35ef78414c62dcdf4d0b2a3115"
29
+
"hash": "ec51d224b9fcd73fd04eebaf2215423d7b1d528b5aba87a0d2f5fe4636af0adf"
30
30
}
+35
.sqlx/query-72a5e8d9f678caf2e6c03e43d78203941645529a4d0ccf18f1abf477cde6ed8d.json
+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
-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
+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
+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
+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
+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
+40
.sqlx/query-d6d533b728887666b2a9ad2d2f9e6b173131842bb9b5f9068175397fd30a50ab.json
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n SELECT u.id as user_id, u.did, r.repo_root_cid, r.repo_rev\n FROM users u\n JOIN repos r ON r.user_id = u.id\n WHERE u.backup_enabled = true\n AND u.deactivated_at IS NULL\n AND (\n NOT EXISTS (\n SELECT 1 FROM account_backups ab WHERE ab.user_id = u.id\n )\n OR (\n SELECT MAX(ab.created_at) FROM account_backups ab WHERE ab.user_id = u.id\n ) < NOW() - make_interval(secs => $1)\n )\n LIMIT 50\n ",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "user_id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "did",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "repo_root_cid",
19
+
"type_info": "Text"
20
+
},
21
+
{
22
+
"ordinal": 3,
23
+
"name": "repo_rev",
24
+
"type_info": "Text"
25
+
}
26
+
],
27
+
"parameters": {
28
+
"Left": [
29
+
"Float8"
30
+
]
31
+
},
32
+
"nullable": [
33
+
false,
34
+
false,
35
+
false,
36
+
true
37
+
]
38
+
},
39
+
"hash": "d6d533b728887666b2a9ad2d2f9e6b173131842bb9b5f9068175397fd30a50ab"
40
+
}
+34
.sqlx/query-f405fc944c383ab9f50b805da3e4bf302e40698beac5b06d3d19abd185de21c1.json
+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
+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
+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
+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
+64
Cargo.lock
···
111
111
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
112
112
113
113
[[package]]
114
+
name = "arbitrary"
115
+
version = "1.4.2"
116
+
source = "registry+https://github.com/rust-lang/crates.io-index"
117
+
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
118
+
dependencies = [
119
+
"derive_arbitrary",
120
+
]
121
+
122
+
[[package]]
114
123
name = "arc-swap"
115
124
version = "1.7.1"
116
125
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1621
1630
]
1622
1631
1623
1632
[[package]]
1633
+
name = "derive_arbitrary"
1634
+
version = "1.4.2"
1635
+
source = "registry+https://github.com/rust-lang/crates.io-index"
1636
+
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
1637
+
dependencies = [
1638
+
"proc-macro2",
1639
+
"quote",
1640
+
"syn 2.0.111",
1641
+
]
1642
+
1643
+
[[package]]
1624
1644
name = "derive_more"
1625
1645
version = "1.0.0"
1626
1646
source = "registry+https://github.com/rust-lang/crates.io-index"
···
1973
1993
checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
1974
1994
dependencies = [
1975
1995
"crc32fast",
1996
+
"libz-rs-sys",
1976
1997
"miniz_oxide",
1977
1998
]
1978
1999
···
3457
3478
dependencies = [
3458
3479
"pkg-config",
3459
3480
"vcpkg",
3481
+
]
3482
+
3483
+
[[package]]
3484
+
name = "libz-rs-sys"
3485
+
version = "0.5.5"
3486
+
source = "registry+https://github.com/rust-lang/crates.io-index"
3487
+
checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415"
3488
+
dependencies = [
3489
+
"zlib-rs",
3460
3490
]
3461
3491
3462
3492
[[package]]
···
6286
6316
"ed25519-dalek",
6287
6317
"futures",
6288
6318
"governor",
6319
+
"hex",
6289
6320
"hickory-resolver",
6290
6321
"hkdf",
6291
6322
"hmac",
···
6329
6360
"webauthn-rs",
6330
6361
"webauthn-rs-proto",
6331
6362
"wiremock",
6363
+
"zip",
6332
6364
]
6333
6365
6334
6366
[[package]]
···
7289
7321
"proc-macro2",
7290
7322
"quote",
7291
7323
"syn 2.0.111",
7324
+
]
7325
+
7326
+
[[package]]
7327
+
name = "zip"
7328
+
version = "7.0.0"
7329
+
source = "registry+https://github.com/rust-lang/crates.io-index"
7330
+
checksum = "bdd8a47718a4ee5fe78e07667cd36f3de80e7c2bfe727c7074245ffc7303c037"
7331
+
dependencies = [
7332
+
"arbitrary",
7333
+
"crc32fast",
7334
+
"flate2",
7335
+
"indexmap 2.12.1",
7336
+
"memchr",
7337
+
"zopfli",
7338
+
]
7339
+
7340
+
[[package]]
7341
+
name = "zlib-rs"
7342
+
version = "0.5.5"
7343
+
source = "registry+https://github.com/rust-lang/crates.io-index"
7344
+
checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3"
7345
+
7346
+
[[package]]
7347
+
name = "zopfli"
7348
+
version = "0.8.3"
7349
+
source = "registry+https://github.com/rust-lang/crates.io-index"
7350
+
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
7351
+
dependencies = [
7352
+
"bumpalo",
7353
+
"crc32fast",
7354
+
"log",
7355
+
"simd-adler32",
7292
7356
]
7293
7357
7294
7358
[[package]]
+2
Cargo.toml
+2
Cargo.toml
···
19
19
dotenvy = "0.15.7"
20
20
futures = "0.3.30"
21
21
governor = "0.10"
22
+
hex = "0.4"
22
23
hkdf = "0.12"
23
24
hmac = "0.12"
24
25
aes-gcm = "0.10"
···
62
63
totp-rs = { version = "5", features = ["qr"] }
63
64
webauthn-rs = { version = "0.5.4", features = ["danger-allow-state-serialisation", "danger-user-presence-only-security-keys"] }
64
65
webauthn-rs-proto = "0.5.4"
66
+
zip = { version = "7.0.0", default-features = false, features = ["deflate"] }
65
67
[features]
66
68
external-infra = []
67
69
[dev-dependencies]
+1
-1
README.md
+1
-1
README.md
···
14
14
15
15
This software isn't an afterthought by a company with limited resources.
16
16
17
-
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
17
+
It is a superset of the reference PDS, including: passkeys and 2FA (WebAuthn/FIDO2, TOTP, backup codes, trusted devices), did:web support (PDS-hosted subdomains or bring-your-own), multi-channel communication (email, discord, telegram, signal) for verification and alerts, granular OAuth scopes with a consent UI showing human-readable descriptions, app passwords with granular permissions (read-only, post-only, or custom scopes), account delegation (letting others manage an account with configurable permission levels), automatic backups to s3-compatible object storage (configurable retention and frequency, one-click restore), and a built-in web UI for account management, OAuth consent, repo browsing, and admin.
18
18
19
19
The PDS itself is a single small binary with no node/npm runtime. It does require postgres, valkey, and s3-compatible storage, which makes setup heavier than the reference PDS's sqlite. The tradeoff is that these are battle-tested pieces of infra that we already know how to scale, back up, and monitor.
20
20
+2
-15
TODO.md
+2
-15
TODO.md
···
2
2
3
3
## Active development
4
4
5
-
### Migration tool
6
-
Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states.
7
-
8
-
- [x] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow
9
-
- [x] Support `createAccount` with existing DID + service auth token
10
-
- [x] Progress tracking with resume capability
11
-
- [ ] Scheduled automatic backups (CAR export)
12
-
- [ ] One-click restore from backup
13
-
14
-
Outbound migration wizard exists but is disabled. Rethinking the approach: instead of a managed flow with `migratingTo` state, pds-hosted did:web users should just have direct control over their DID document. They can independently update serviceEndpoint, add/remove keys, export their repo, deactivate their account.
15
-
16
-
- [ ] Remove `migratingTo` field and related state machine
17
-
- [ ] Let did:web users edit their DID doc fields (serviceEndpoint, keys) whenever
18
-
- [ ] Repo export as standalone feature, not tied to migration wizard
19
-
20
5
### Plugin system
21
6
Extensible architecture allowing third-party plugins to add functionality. Going with wasm-based rather than scripting language.
22
7
···
69
54
App password scopes: Granular permissions for app passwords using the same scope system as OAuth. Preset buttons for common use cases (full access, read-only, post-only), scope stored in session and preserved across token refresh, explicit RPC/repo/blob scope enforcement for restricted passwords.
70
55
71
56
Account Delegation: Delegated accounts controlled by other accounts instead of passwords. OAuth delegation flow (authenticate as controller), scope-based permissions (owner/admin/editor/viewer presets), scope intersection (tokens limited to granted permissions), `act` claim for delegation tracking, creating delegated account flow, controller management UI, "act as" account switcher, comprehensive audit logging with actor/controller tracking, delegation-aware OAuth consent with permission limitation notices.
57
+
58
+
Migration: OAuth-based inbound migration wizard with PLC token flow, offline restore from CAR file + rotation key for disaster recovery, scheduled automatic backups, standalone repo/blob export, did:web DID document editor for self-service identity management.
+94
frontend/deno.lock
+94
frontend/deno.lock
···
1
1
{
2
2
"version": "5",
3
3
"specifiers": {
4
+
"npm:@atcute/cbor@^2.2.8": "2.2.8",
5
+
"npm:@atcute/crypto@^2.3.0": "2.3.0",
6
+
"npm:@atcute/did-plc@~0.3.1": "0.3.1",
7
+
"npm:@atcute/multibase@^1.1.6": "1.1.6",
4
8
"npm:@noble/secp256k1@^2.1.0": "2.3.0",
5
9
"npm:@sveltejs/vite-plugin-svelte@5": "5.1.1_svelte@5.45.10__acorn@8.15.0_vite@6.4.1__picomatch@4.0.3",
6
10
"npm:@testing-library/jest-dom@^6.6.3": "6.9.1",
···
30
34
"lru-cache"
31
35
]
32
36
},
37
+
"@atcute/cbor@2.2.8": {
38
+
"integrity": "sha512-UzOAN9BuN6JCXgn0ryV8qZuRJUDrNqrbLd6EFM8jc6RYssjRyGRxNy6RZ1NU/07Hd8Tq/0pz8+nQiMu5Zai5uw==",
39
+
"dependencies": [
40
+
"@atcute/cid",
41
+
"@atcute/multibase",
42
+
"@atcute/uint8array"
43
+
]
44
+
},
45
+
"@atcute/cid@2.3.0": {
46
+
"integrity": "sha512-1SRdkTuMs/l5arQ+7Ag0F7JAueZqtzYE0d2gmbkuzi8EPweNU1kYlQs0CE4dSd81YF8PMDTOQty0K2ATq9CW9g==",
47
+
"dependencies": [
48
+
"@atcute/multibase",
49
+
"@atcute/uint8array"
50
+
]
51
+
},
52
+
"@atcute/crypto@2.3.0": {
53
+
"integrity": "sha512-w5pkJKCjbNMQu+F4JRHbR3ROQyhi1wbn+GSC6WDQamcYHkZmEZk1/eoI354bIQOOfkEM6aFLv718iskrkon4GQ==",
54
+
"dependencies": [
55
+
"@atcute/multibase",
56
+
"@atcute/uint8array",
57
+
"@noble/secp256k1@3.0.0"
58
+
]
59
+
},
60
+
"@atcute/did-plc@0.3.1": {
61
+
"integrity": "sha512-KsuVdRtaaIPMmlcCDcxZzLg6OWm7rajczquhIHfA3s57+c34PFQbdY4Lsc2BvDwZ0fUjmbwzvQI3Zio2VcZa7w==",
62
+
"dependencies": [
63
+
"@atcute/cbor",
64
+
"@atcute/cid",
65
+
"@atcute/crypto",
66
+
"@atcute/identity",
67
+
"@atcute/lexicons",
68
+
"@atcute/multibase",
69
+
"@atcute/uint8array",
70
+
"@atcute/util-fetch",
71
+
"@badrap/valita"
72
+
]
73
+
},
74
+
"@atcute/identity@1.1.3": {
75
+
"integrity": "sha512-oIqPoI8TwWeQxvcLmFEZLdN2XdWcaLVtlm8pNk0E72As9HNzzD9pwKPrLr3rmTLRIoULPPFmq9iFNsTeCIU9ng==",
76
+
"dependencies": [
77
+
"@atcute/lexicons",
78
+
"@badrap/valita"
79
+
]
80
+
},
81
+
"@atcute/lexicons@1.2.6": {
82
+
"integrity": "sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==",
83
+
"dependencies": [
84
+
"@atcute/uint8array",
85
+
"@atcute/util-text",
86
+
"@standard-schema/spec",
87
+
"esm-env"
88
+
]
89
+
},
90
+
"@atcute/multibase@1.1.6": {
91
+
"integrity": "sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==",
92
+
"dependencies": [
93
+
"@atcute/uint8array"
94
+
]
95
+
},
96
+
"@atcute/uint8array@1.0.6": {
97
+
"integrity": "sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A=="
98
+
},
99
+
"@atcute/util-fetch@1.0.4": {
100
+
"integrity": "sha512-sIU9Qk0dE8PLEXSfhy+gIJV+HpiiknMytCI2SqLlqd0vgZUtEKI/EQfP+23LHWvP+CLCzVDOa6cpH045OlmNBg==",
101
+
"dependencies": [
102
+
"@badrap/valita"
103
+
]
104
+
},
105
+
"@atcute/util-text@0.0.1": {
106
+
"integrity": "sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==",
107
+
"dependencies": [
108
+
"unicode-segmenter"
109
+
]
110
+
},
33
111
"@babel/code-frame@7.27.1": {
34
112
"integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==",
35
113
"dependencies": [
···
43
121
},
44
122
"@babel/runtime@7.28.4": {
45
123
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="
124
+
},
125
+
"@badrap/valita@0.4.6": {
126
+
"integrity": "sha512-4kdqcjyxo/8RQ8ayjms47HCWZIF5981oE5nIenbfThKDxWXtEHKipAOWlflpPJzZx9y/JWYQkp18Awr7VuepFg=="
46
127
},
47
128
"@csstools/color-helpers@5.1.0": {
48
129
"integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="
···
498
579
"@noble/secp256k1@2.3.0": {
499
580
"integrity": "sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw=="
500
581
},
582
+
"@noble/secp256k1@3.0.0": {
583
+
"integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg=="
584
+
},
501
585
"@rollup/rollup-android-arm-eabi@4.53.3": {
502
586
"integrity": "sha512-mRSi+4cBjrRLoaal2PnqH82Wqyb+d3HsPUN/W+WslCXsZsyHa9ZeQQX/pQsZaVIWDkPcpV6jJ+3KLbTbgnwv8w==",
503
587
"os": ["android"],
···
607
691
"integrity": "sha512-UhTd8u31dXadv0MopwGgNOBpUVROFKWVQgAg5N1ESyCz8AuBcMqm4AuTjrwgQKGDfoFuz02EuMRHQIw/frmYKQ==",
608
692
"os": ["win32"],
609
693
"cpu": ["x64"]
694
+
},
695
+
"@standard-schema/spec@1.1.0": {
696
+
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
610
697
},
611
698
"@sveltejs/acorn-typescript@1.0.8_acorn@8.15.0": {
612
699
"integrity": "sha512-esgN+54+q0NjB0Y/4BomT9samII7jGwNy/2a3wNZbT2A2RpmXsXwUt24LvLhx6jUq2gVk4cWEvcRO6MFQbOfNA==",
···
1545
1632
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1546
1633
"bin": true
1547
1634
},
1635
+
"unicode-segmenter@0.14.4": {
1636
+
"integrity": "sha512-pR5VCiCrLrKOL6FRW61jnk9+wyMtKKowq+jyFY9oc6uHbWKhDL4yVRiI4YZPksGMK72Pahh8m0cn/0JvbDDyJg=="
1637
+
},
1548
1638
"vite-node@2.1.9": {
1549
1639
"integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==",
1550
1640
"dependencies": [
···
1671
1761
"workspace": {
1672
1762
"packageJson": {
1673
1763
"dependencies": [
1764
+
"npm:@atcute/cbor@^2.2.8",
1765
+
"npm:@atcute/crypto@^2.3.0",
1766
+
"npm:@atcute/did-plc@~0.3.1",
1767
+
"npm:@atcute/multibase@^1.1.6",
1674
1768
"npm:@noble/secp256k1@^2.1.0",
1675
1769
"npm:@sveltejs/vite-plugin-svelte@5",
1676
1770
"npm:@testing-library/jest-dom@^6.6.3",
+4
frontend/package.json
+4
frontend/package.json
···
12
12
"test:coverage": "vitest run --coverage"
13
13
},
14
14
"dependencies": {
15
+
"@atcute/cbor": "^2.2.8",
16
+
"@atcute/crypto": "^2.3.0",
17
+
"@atcute/did-plc": "^0.3.1",
18
+
"@atcute/multibase": "^1.1.6",
15
19
"@noble/secp256k1": "^2.1.0",
16
20
"multiformats": "^13.3.1",
17
21
"svelte-i18n": "^4.0.1"
+2
-2
frontend/src/components/ReauthModal.svelte
+2
-2
frontend/src/components/ReauthModal.svelte
···
228
228
/>
229
229
</div>
230
230
<button type="submit" class="btn-primary" disabled={loading || !password}>
231
-
{loading ? $_('reauth.verifying') : $_('reauth.verify')}
231
+
{loading ? $_('common.verifying') : $_('common.verify')}
232
232
</button>
233
233
</form>
234
234
{:else if activeMethod === 'totp'}
···
247
247
/>
248
248
</div>
249
249
<button type="submit" class="btn-primary" disabled={loading || !totpCode}>
250
-
{loading ? $_('reauth.verifying') : $_('reauth.verify')}
250
+
{loading ? $_('common.verifying') : $_('common.verify')}
251
251
</button>
252
252
</form>
253
253
{:else if activeMethod === 'passkey'}
+86
frontend/src/components/migration/AppPasswordStep.svelte
+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
+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
+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
+23
frontend/src/components/migration/ErrorStep.svelte
···
1
+
<script lang="ts">
2
+
import { _ } from '../../lib/i18n'
3
+
4
+
interface Props {
5
+
error: string | null
6
+
onStartOver: () => void
7
+
}
8
+
9
+
let { error, onStartOver }: Props = $props()
10
+
</script>
11
+
12
+
<div class="step-content">
13
+
<h2>{$_('migration.inbound.error.title')}</h2>
14
+
<p>{$_('migration.inbound.error.desc')}</p>
15
+
16
+
<div class="message error">
17
+
{error || $_('migration.inbound.error.unknown')}
18
+
</div>
19
+
20
+
<div class="button-row">
21
+
<button class="ghost" onclick={onStartOver}>{$_('migration.inbound.error.startOver')}</button>
22
+
</div>
23
+
</div>
+64
-306
frontend/src/components/migration/InboundWizard.svelte
+64
-306
frontend/src/components/migration/InboundWizard.svelte
···
5
5
import { base64UrlEncode, prepareWebAuthnCreationOptions } from '../../lib/migration/atproto-client'
6
6
import { _ } from '../../lib/i18n'
7
7
import '../../styles/migration.css'
8
+
import ErrorStep from './ErrorStep.svelte'
9
+
import SuccessStep from './SuccessStep.svelte'
10
+
import ChooseHandleStep from './ChooseHandleStep.svelte'
11
+
import EmailVerifyStep from './EmailVerifyStep.svelte'
12
+
import PasskeySetupStep from './PasskeySetupStep.svelte'
13
+
import AppPasswordStep from './AppPasswordStep.svelte'
8
14
9
15
interface ResumeInfo {
10
-
direction: 'inbound' | 'outbound'
16
+
direction: 'inbound'
11
17
sourceHandle: string
12
18
targetHandle: string
13
19
sourcePdsUrl: string
···
37
43
let checkingHandle = $state(false)
38
44
let selectedAuthMethod = $state<AuthMethod>('password')
39
45
let passkeyName = $state('')
40
-
let appPasswordCopied = $state(false)
41
-
let appPasswordAcknowledged = $state(false)
42
46
43
47
const isResuming = $derived(flow.state.needsReauth === true)
44
48
const isDidWeb = $derived(flow.state.sourceDid.startsWith("did:web:"))
···
234
238
}
235
239
}
236
240
237
-
function copyAppPassword() {
238
-
if (flow.state.generatedAppPassword) {
239
-
navigator.clipboard.writeText(flow.state.generatedAppPassword)
240
-
appPasswordCopied = true
241
-
}
242
-
}
243
-
244
241
async function handleProceedFromAppPassword() {
245
242
loading = true
246
243
try {
···
352
349
</label>
353
350
354
351
<div class="button-row">
355
-
<button class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button>
356
-
<button disabled={!understood} onclick={() => flow.setStep('source-handle')}>
352
+
<button type="button" class="ghost" onclick={onBack}>{$_('migration.inbound.common.cancel')}</button>
353
+
<button type="button" disabled={!understood} onclick={() => flow.setStep('source-handle')}>
357
354
{$_('migration.inbound.common.continue')}
358
355
</button>
359
356
</div>
···
409
406
</div>
410
407
411
408
{:else if flow.state.step === 'choose-handle'}
412
-
<div class="step-content">
413
-
<h2>{$_('migration.inbound.chooseHandle.title')}</h2>
414
-
<p>{$_('migration.inbound.chooseHandle.desc')}</p>
415
-
416
-
<div class="current-info">
417
-
<span class="label">{$_('migration.inbound.chooseHandle.migratingFrom')}:</span>
418
-
<span class="value">{flow.state.sourceHandle}</span>
419
-
</div>
420
-
421
-
<div class="field">
422
-
<label for="new-handle">{$_('migration.inbound.chooseHandle.newHandle')}</label>
423
-
<div class="handle-input-group">
424
-
<input
425
-
id="new-handle"
426
-
type="text"
427
-
placeholder="username"
428
-
bind:value={handleInput}
429
-
onblur={checkHandle}
430
-
/>
431
-
{#if serverInfo && serverInfo.availableUserDomains.length > 0 && !handleInput.includes('.')}
432
-
<select bind:value={selectedDomain}>
433
-
{#each serverInfo.availableUserDomains as domain}
434
-
<option value={domain}>.{domain}</option>
435
-
{/each}
436
-
</select>
437
-
{/if}
438
-
</div>
439
-
440
-
{#if checkingHandle}
441
-
<p class="hint">{$_('migration.inbound.chooseHandle.checkingAvailability')}</p>
442
-
{:else if handleAvailable === true}
443
-
<p class="hint" style="color: var(--success-text)">{$_('migration.inbound.chooseHandle.handleAvailable')}</p>
444
-
{:else if handleAvailable === false}
445
-
<p class="hint error">{$_('migration.inbound.chooseHandle.handleTaken')}</p>
446
-
{:else}
447
-
<p class="hint">{$_('migration.inbound.chooseHandle.handleHint')}</p>
448
-
{/if}
449
-
</div>
450
-
451
-
<div class="field">
452
-
<label for="email">{$_('migration.inbound.chooseHandle.email')}</label>
453
-
<input
454
-
id="email"
455
-
type="email"
456
-
placeholder="you@example.com"
457
-
bind:value={flow.state.targetEmail}
458
-
oninput={(e) => flow.updateField('targetEmail', (e.target as HTMLInputElement).value)}
459
-
required
460
-
/>
461
-
</div>
462
-
463
-
<div class="field">
464
-
<label>{$_('migration.inbound.chooseHandle.authMethod')}</label>
465
-
<div class="auth-method-options">
466
-
<label class="auth-option" class:selected={selectedAuthMethod === 'password'}>
467
-
<input
468
-
type="radio"
469
-
name="auth-method"
470
-
value="password"
471
-
bind:group={selectedAuthMethod}
472
-
/>
473
-
<div class="auth-option-content">
474
-
<strong>{$_('migration.inbound.chooseHandle.authPassword')}</strong>
475
-
<span>{$_('migration.inbound.chooseHandle.authPasswordDesc')}</span>
476
-
</div>
477
-
</label>
478
-
<label class="auth-option" class:selected={selectedAuthMethod === 'passkey'}>
479
-
<input
480
-
type="radio"
481
-
name="auth-method"
482
-
value="passkey"
483
-
bind:group={selectedAuthMethod}
484
-
/>
485
-
<div class="auth-option-content">
486
-
<strong>{$_('migration.inbound.chooseHandle.authPasskey')}</strong>
487
-
<span>{$_('migration.inbound.chooseHandle.authPasskeyDesc')}</span>
488
-
</div>
489
-
</label>
490
-
</div>
491
-
</div>
492
-
493
-
{#if selectedAuthMethod === 'password'}
494
-
<div class="field">
495
-
<label for="new-password">{$_('migration.inbound.chooseHandle.password')}</label>
496
-
<input
497
-
id="new-password"
498
-
type="password"
499
-
placeholder="Password for your new account"
500
-
bind:value={flow.state.targetPassword}
501
-
oninput={(e) => flow.updateField('targetPassword', (e.target as HTMLInputElement).value)}
502
-
required
503
-
minlength="8"
504
-
/>
505
-
<p class="hint">{$_('migration.inbound.chooseHandle.passwordHint')}</p>
506
-
</div>
507
-
{:else}
508
-
<div class="info-box">
509
-
<p>{$_('migration.inbound.chooseHandle.passkeyInfo')}</p>
510
-
</div>
511
-
{/if}
512
-
513
-
{#if serverInfo?.inviteCodeRequired}
514
-
<div class="field">
515
-
<label for="invite">{$_('migration.inbound.chooseHandle.inviteCode')}</label>
516
-
<input
517
-
id="invite"
518
-
type="text"
519
-
placeholder="Enter invite code"
520
-
bind:value={flow.state.inviteCode}
521
-
oninput={(e) => flow.updateField('inviteCode', (e.target as HTMLInputElement).value)}
522
-
required
523
-
/>
524
-
</div>
525
-
{/if}
526
-
527
-
<div class="button-row">
528
-
<button class="ghost" onclick={() => flow.setStep('source-handle')}>{$_('migration.inbound.common.back')}</button>
529
-
<button
530
-
disabled={!handleInput.trim() || !flow.state.targetEmail || (selectedAuthMethod === 'password' && !flow.state.targetPassword) || handleAvailable === false}
531
-
onclick={proceedToReviewWithAuth}
532
-
>
533
-
{$_('migration.inbound.common.continue')}
534
-
</button>
535
-
</div>
536
-
</div>
409
+
<ChooseHandleStep
410
+
{handleInput}
411
+
{selectedDomain}
412
+
{handleAvailable}
413
+
{checkingHandle}
414
+
email={flow.state.targetEmail}
415
+
password={flow.state.targetPassword}
416
+
authMethod={selectedAuthMethod}
417
+
inviteCode={flow.state.inviteCode}
418
+
{serverInfo}
419
+
migratingFromLabel={$_('migration.inbound.chooseHandle.migratingFrom')}
420
+
migratingFromValue={flow.state.sourceHandle}
421
+
{loading}
422
+
onHandleChange={(h) => handleInput = h}
423
+
onDomainChange={(d) => selectedDomain = d}
424
+
onCheckHandle={checkHandle}
425
+
onEmailChange={(e) => flow.updateField('targetEmail', e)}
426
+
onPasswordChange={(p) => flow.updateField('targetPassword', p)}
427
+
onAuthMethodChange={(m) => selectedAuthMethod = m}
428
+
onInviteCodeChange={(c) => flow.updateField('inviteCode', c)}
429
+
onBack={() => flow.setStep('source-handle')}
430
+
onContinue={proceedToReviewWithAuth}
431
+
/>
537
432
538
433
{:else if flow.state.step === 'review'}
539
434
<div class="step-content">
···
620
515
</div>
621
516
622
517
{:else if flow.state.step === 'passkey-setup'}
623
-
<div class="step-content">
624
-
<h2>{$_('migration.inbound.passkeySetup.title')}</h2>
625
-
<p>{$_('migration.inbound.passkeySetup.desc')}</p>
626
-
627
-
{#if flow.state.error}
628
-
<div class="message error">
629
-
{flow.state.error}
630
-
</div>
631
-
{/if}
632
-
633
-
<div class="field">
634
-
<label for="passkey-name">{$_('migration.inbound.passkeySetup.nameLabel')}</label>
635
-
<input
636
-
id="passkey-name"
637
-
type="text"
638
-
placeholder={$_('migration.inbound.passkeySetup.namePlaceholder')}
639
-
bind:value={passkeyName}
640
-
disabled={loading}
641
-
/>
642
-
<p class="hint">{$_('migration.inbound.passkeySetup.nameHint')}</p>
643
-
</div>
644
-
645
-
<div class="passkey-section">
646
-
<p>{$_('migration.inbound.passkeySetup.instructions')}</p>
647
-
<button class="primary" onclick={registerPasskey} disabled={loading}>
648
-
{loading ? $_('migration.inbound.passkeySetup.registering') : $_('migration.inbound.passkeySetup.register')}
649
-
</button>
650
-
</div>
651
-
</div>
518
+
<PasskeySetupStep
519
+
{passkeyName}
520
+
{loading}
521
+
error={flow.state.error}
522
+
onPasskeyNameChange={(n) => passkeyName = n}
523
+
onRegister={registerPasskey}
524
+
/>
652
525
653
526
{:else if flow.state.step === 'app-password'}
654
-
<div class="step-content">
655
-
<h2>{$_('migration.inbound.appPassword.title')}</h2>
656
-
<p>{$_('migration.inbound.appPassword.desc')}</p>
657
-
658
-
<div class="warning-box">
659
-
<strong>{$_('migration.inbound.appPassword.warning')}</strong>
660
-
</div>
661
-
662
-
<div class="app-password-display">
663
-
<div class="app-password-label">
664
-
{$_('migration.inbound.appPassword.label')}: <strong>{flow.state.generatedAppPasswordName}</strong>
665
-
</div>
666
-
<code class="app-password-code">{flow.state.generatedAppPassword}</code>
667
-
<button type="button" class="copy-btn" onclick={copyAppPassword}>
668
-
{appPasswordCopied ? $_('common.copied') : $_('common.copyToClipboard')}
669
-
</button>
670
-
</div>
671
-
672
-
<label class="checkbox-label">
673
-
<input type="checkbox" bind:checked={appPasswordAcknowledged} />
674
-
<span>{$_('migration.inbound.appPassword.saved')}</span>
675
-
</label>
676
-
677
-
<div class="button-row">
678
-
<button onclick={handleProceedFromAppPassword} disabled={!appPasswordAcknowledged || loading}>
679
-
{loading ? $_('migration.inbound.common.continue') : $_('migration.inbound.appPassword.continue')}
680
-
</button>
681
-
</div>
682
-
</div>
527
+
<AppPasswordStep
528
+
appPassword={flow.state.generatedAppPassword || ''}
529
+
appPasswordName={flow.state.generatedAppPasswordName || ''}
530
+
{loading}
531
+
onContinue={handleProceedFromAppPassword}
532
+
/>
683
533
684
534
{:else if flow.state.step === 'email-verify'}
685
-
<div class="step-content">
686
-
<h2>{$_('migration.inbound.emailVerify.title')}</h2>
687
-
<p>{@html $_('migration.inbound.emailVerify.desc', { values: { email: `<strong>${flow.state.targetEmail}</strong>` } })}</p>
688
-
689
-
<div class="info-box">
690
-
<p>
691
-
{$_('migration.inbound.emailVerify.hint')}
692
-
</p>
693
-
</div>
694
-
695
-
{#if flow.state.error}
696
-
<div class="message error">
697
-
{flow.state.error}
698
-
</div>
699
-
{/if}
700
-
701
-
<form onsubmit={submitEmailVerify}>
702
-
<div class="field">
703
-
<label for="email-verify-token">{$_('migration.inbound.emailVerify.tokenLabel')}</label>
704
-
<input
705
-
id="email-verify-token"
706
-
type="text"
707
-
placeholder={$_('migration.inbound.emailVerify.tokenPlaceholder')}
708
-
bind:value={flow.state.emailVerifyToken}
709
-
oninput={(e) => flow.updateField('emailVerifyToken', (e.target as HTMLInputElement).value)}
710
-
disabled={loading}
711
-
required
712
-
/>
713
-
</div>
714
-
715
-
<div class="button-row">
716
-
<button type="button" class="ghost" onclick={resendEmailVerify} disabled={loading}>
717
-
{$_('migration.inbound.emailVerify.resend')}
718
-
</button>
719
-
<button type="submit" disabled={loading || !flow.state.emailVerifyToken}>
720
-
{loading ? $_('migration.inbound.emailVerify.verifying') : $_('migration.inbound.emailVerify.verify')}
721
-
</button>
722
-
</div>
723
-
</form>
724
-
</div>
535
+
<EmailVerifyStep
536
+
email={flow.state.targetEmail}
537
+
token={flow.state.emailVerifyToken}
538
+
{loading}
539
+
error={flow.state.error}
540
+
onTokenChange={(t) => flow.updateField('emailVerifyToken', t)}
541
+
onSubmit={submitEmailVerify}
542
+
onResend={resendEmailVerify}
543
+
/>
725
544
726
545
{:else if flow.state.step === 'plc-token'}
727
546
<div class="step-content">
···
837
656
</div>
838
657
839
658
{:else if flow.state.step === 'success'}
840
-
<div class="step-content success-content">
841
-
<div class="success-icon">✓</div>
842
-
<h2>{$_('migration.inbound.success.title')}</h2>
843
-
<p>{$_('migration.inbound.success.desc')}</p>
844
-
845
-
<div class="success-details">
846
-
<div class="detail-row">
847
-
<span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span>
848
-
<span class="value">{flow.state.targetHandle}</span>
849
-
</div>
850
-
<div class="detail-row">
851
-
<span class="label">{$_('migration.inbound.success.did')}:</span>
852
-
<span class="value mono">{flow.state.sourceDid}</span>
853
-
</div>
854
-
</div>
855
-
856
-
{#if flow.state.progress.blobsFailed.length > 0}
857
-
<div class="message warning">
858
-
{$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })}
859
-
</div>
860
-
{/if}
861
-
862
-
<p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p>
863
-
</div>
659
+
<SuccessStep handle={flow.state.targetHandle} did={flow.state.sourceDid}>
660
+
{#snippet extraContent()}
661
+
{#if flow.state.progress.blobsFailed.length > 0}
662
+
<div class="message warning">
663
+
{$_('migration.inbound.success.blobsWarning', { values: { count: flow.state.progress.blobsFailed.length } })}
664
+
</div>
665
+
{/if}
666
+
{/snippet}
667
+
</SuccessStep>
864
668
865
669
{:else if flow.state.step === 'error'}
866
-
<div class="step-content">
867
-
<h2>{$_('migration.inbound.error.title')}</h2>
868
-
<p>{$_('migration.inbound.error.desc')}</p>
869
-
870
-
<div class="message error">
871
-
{flow.state.error || 'An unknown error occurred. Please check the browser console for details.'}
872
-
</div>
873
-
874
-
<div class="button-row">
875
-
<button class="ghost" onclick={onBack}>{$_('migration.inbound.error.startOver')}</button>
876
-
</div>
877
-
</div>
670
+
<ErrorStep error={flow.state.error} onStartOver={onBack} />
878
671
{/if}
879
672
</div>
880
673
881
674
<style>
882
-
.passkey-section {
883
-
margin-top: 16px;
884
-
}
885
-
.passkey-section button {
886
-
width: 100%;
887
-
margin-top: 12px;
888
-
}
889
-
.app-password-display {
890
-
background: var(--bg-card);
891
-
border: 2px solid var(--accent);
892
-
border-radius: var(--radius-xl);
893
-
padding: var(--space-6);
894
-
text-align: center;
895
-
margin: var(--space-4) 0;
896
-
}
897
-
.app-password-label {
898
-
font-size: var(--text-sm);
899
-
color: var(--text-secondary);
900
-
margin-bottom: var(--space-4);
901
-
}
902
-
.app-password-code {
903
-
display: block;
904
-
font-size: var(--text-xl);
905
-
font-family: ui-monospace, monospace;
906
-
letter-spacing: 0.1em;
907
-
padding: var(--space-5);
908
-
background: var(--bg-input);
909
-
border-radius: var(--radius-md);
910
-
margin-bottom: var(--space-4);
911
-
user-select: all;
912
-
}
913
-
.copy-btn {
914
-
padding: var(--space-3) var(--space-5);
915
-
font-size: var(--text-sm);
916
-
}
917
675
.resume-info {
918
676
margin-bottom: var(--space-5);
919
677
}
+591
frontend/src/components/migration/OfflineInboundWizard.svelte
+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
-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
+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
+36
frontend/src/components/migration/SuccessStep.svelte
···
1
+
<script lang="ts">
2
+
import type { Snippet } from 'svelte'
3
+
import { _ } from '../../lib/i18n'
4
+
5
+
interface Props {
6
+
handle: string
7
+
did: string
8
+
description?: string
9
+
extraContent?: Snippet
10
+
}
11
+
12
+
let { handle, did, description, extraContent }: Props = $props()
13
+
</script>
14
+
15
+
<div class="step-content success-content">
16
+
<div class="success-icon">✓</div>
17
+
<h2>{$_('migration.inbound.success.title')}</h2>
18
+
<p>{description || $_('migration.inbound.success.desc')}</p>
19
+
20
+
<div class="success-details">
21
+
<div class="detail-row">
22
+
<span class="label">{$_('migration.inbound.success.yourNewHandle')}:</span>
23
+
<span class="value">{handle}</span>
24
+
</div>
25
+
<div class="detail-row">
26
+
<span class="label">{$_('migration.inbound.success.did')}:</span>
27
+
<span class="value mono">{did}</span>
28
+
</div>
29
+
</div>
30
+
31
+
{#if extraContent}
32
+
{@render extraContent()}
33
+
{/if}
34
+
35
+
<p class="redirect-text">{$_('migration.inbound.success.redirecting')}</p>
36
+
</div>
+155
-47
frontend/src/lib/api.ts
+155
-47
frontend/src/lib/api.ts
···
205
205
return data;
206
206
},
207
207
208
+
async createAccountWithServiceAuth(
209
+
serviceAuthToken: string,
210
+
params: {
211
+
did: string;
212
+
handle: string;
213
+
email: string;
214
+
password: string;
215
+
inviteCode?: string;
216
+
},
217
+
): Promise<Session> {
218
+
const url = `${API_BASE}/com.atproto.server.createAccount`;
219
+
const response = await fetch(url, {
220
+
method: "POST",
221
+
headers: {
222
+
"Content-Type": "application/json",
223
+
"Authorization": `Bearer ${serviceAuthToken}`,
224
+
},
225
+
body: JSON.stringify({
226
+
did: params.did,
227
+
handle: params.handle,
228
+
email: params.email,
229
+
password: params.password,
230
+
inviteCode: params.inviteCode,
231
+
}),
232
+
});
233
+
const data = await response.json();
234
+
if (!response.ok) {
235
+
throw new ApiError(response.status, data.error, data.message);
236
+
}
237
+
return data;
238
+
},
239
+
208
240
async confirmSignup(
209
241
did: string,
210
242
verificationCode: string,
···
226
258
return xrpc("com.atproto.server.createSession", {
227
259
method: "POST",
228
260
body: { identifier, password },
261
+
});
262
+
},
263
+
264
+
async checkEmailVerified(identifier: string): Promise<{ verified: boolean }> {
265
+
return xrpc("_checkEmailVerified", {
266
+
method: "POST",
267
+
body: { identifier },
229
268
});
230
269
},
231
270
···
379
418
signalNumber: string | null;
380
419
signalVerified: boolean;
381
420
}> {
382
-
return xrpc("com.tranquil.account.getNotificationPrefs", { token });
421
+
return xrpc("_account.getNotificationPrefs", { token });
383
422
},
384
423
385
424
async updateNotificationPrefs(token: string, prefs: {
···
388
427
telegramUsername?: string;
389
428
signalNumber?: string;
390
429
}): Promise<{ success: boolean }> {
391
-
return xrpc("com.tranquil.account.updateNotificationPrefs", {
430
+
return xrpc("_account.updateNotificationPrefs", {
392
431
method: "POST",
393
432
token,
394
433
body: prefs,
···
401
440
identifier: string,
402
441
code: string,
403
442
): Promise<{ success: boolean }> {
404
-
return xrpc("com.tranquil.account.confirmChannelVerification", {
443
+
return xrpc("_account.confirmChannelVerification", {
405
444
method: "POST",
406
445
token,
407
446
body: { channel, identifier, code },
···
418
457
body: string;
419
458
}>;
420
459
}> {
421
-
return xrpc("com.tranquil.account.getNotificationHistory", { token });
460
+
return xrpc("_account.getNotificationHistory", { token });
422
461
},
423
462
424
463
async getServerStats(token: string): Promise<{
···
427
466
recordCount: number;
428
467
blobStorageBytes: number;
429
468
}> {
430
-
return xrpc("com.tranquil.admin.getServerStats", { token });
469
+
return xrpc("_admin.getServerStats", { token });
431
470
},
432
471
433
472
async getServerConfig(): Promise<{
···
438
477
secondaryColorDark: string | null;
439
478
logoCid: string | null;
440
479
}> {
441
-
return xrpc("com.tranquil.server.getConfig");
480
+
return xrpc("_server.getConfig");
442
481
},
443
482
444
483
async updateServerConfig(
···
452
491
logoCid?: string;
453
492
},
454
493
): Promise<{ success: boolean }> {
455
-
return xrpc("com.tranquil.admin.updateServerConfig", {
494
+
return xrpc("_admin.updateServerConfig", {
456
495
method: "POST",
457
496
token,
458
497
body: config,
···
495
534
currentPassword: string,
496
535
newPassword: string,
497
536
): Promise<void> {
498
-
await xrpc("com.tranquil.account.changePassword", {
537
+
await xrpc("_account.changePassword", {
499
538
method: "POST",
500
539
token,
501
540
body: { currentPassword, newPassword },
···
503
542
},
504
543
505
544
async removePassword(token: string): Promise<{ success: boolean }> {
506
-
return xrpc("com.tranquil.account.removePassword", {
545
+
return xrpc("_account.removePassword", {
507
546
method: "POST",
508
547
token,
509
548
});
510
549
},
511
550
512
551
async getPasswordStatus(token: string): Promise<{ hasPassword: boolean }> {
513
-
return xrpc("com.tranquil.account.getPasswordStatus", { token });
552
+
return xrpc("_account.getPasswordStatus", { token });
514
553
},
515
554
516
555
async getLegacyLoginPreference(
517
556
token: string,
518
557
): Promise<{ allowLegacyLogin: boolean; hasMfa: boolean }> {
519
-
return xrpc("com.tranquil.account.getLegacyLoginPreference", { token });
558
+
return xrpc("_account.getLegacyLoginPreference", { token });
520
559
},
521
560
522
561
async updateLegacyLoginPreference(
523
562
token: string,
524
563
allowLegacyLogin: boolean,
525
564
): Promise<{ allowLegacyLogin: boolean }> {
526
-
return xrpc("com.tranquil.account.updateLegacyLoginPreference", {
565
+
return xrpc("_account.updateLegacyLoginPreference", {
527
566
method: "POST",
528
567
token,
529
568
body: { allowLegacyLogin },
···
534
573
token: string,
535
574
preferredLocale: string,
536
575
): Promise<{ preferredLocale: string }> {
537
-
return xrpc("com.tranquil.account.updateLocale", {
576
+
return xrpc("_account.updateLocale", {
538
577
method: "POST",
539
578
token,
540
579
body: { preferredLocale },
···
551
590
isCurrent: boolean;
552
591
}>;
553
592
}> {
554
-
return xrpc("com.tranquil.account.listSessions", { token });
593
+
return xrpc("_account.listSessions", { token });
555
594
},
556
595
557
596
async revokeSession(token: string, sessionId: string): Promise<void> {
558
-
await xrpc("com.tranquil.account.revokeSession", {
597
+
await xrpc("_account.revokeSession", {
559
598
method: "POST",
560
599
token,
561
600
body: { sessionId },
···
563
602
},
564
603
565
604
async revokeAllSessions(token: string): Promise<{ revokedCount: number }> {
566
-
return xrpc("com.tranquil.account.revokeAllSessions", {
605
+
return xrpc("_account.revokeAllSessions", {
567
606
method: "POST",
568
607
token,
569
608
});
···
868
907
lastSeenAt: string;
869
908
}>;
870
909
}> {
871
-
return xrpc("com.tranquil.account.listTrustedDevices", { token });
910
+
return xrpc("_account.listTrustedDevices", { token });
872
911
},
873
912
874
913
async revokeTrustedDevice(
875
914
token: string,
876
915
deviceId: string,
877
916
): Promise<{ success: boolean }> {
878
-
return xrpc("com.tranquil.account.revokeTrustedDevice", {
917
+
return xrpc("_account.revokeTrustedDevice", {
879
918
method: "POST",
880
919
token,
881
920
body: { deviceId },
···
887
926
deviceId: string,
888
927
friendlyName: string,
889
928
): Promise<{ success: boolean }> {
890
-
return xrpc("com.tranquil.account.updateTrustedDevice", {
929
+
return xrpc("_account.updateTrustedDevice", {
891
930
method: "POST",
892
931
token,
893
932
body: { deviceId, friendlyName },
···
899
938
lastReauthAt: string | null;
900
939
availableMethods: string[];
901
940
}> {
902
-
return xrpc("com.tranquil.account.getReauthStatus", { token });
941
+
return xrpc("_account.getReauthStatus", { token });
903
942
},
904
943
905
944
async reauthPassword(
906
945
token: string,
907
946
password: string,
908
947
): Promise<{ success: boolean; reauthAt: string }> {
909
-
return xrpc("com.tranquil.account.reauthPassword", {
948
+
return xrpc("_account.reauthPassword", {
910
949
method: "POST",
911
950
token,
912
951
body: { password },
···
917
956
token: string,
918
957
code: string,
919
958
): Promise<{ success: boolean; reauthAt: string }> {
920
-
return xrpc("com.tranquil.account.reauthTotp", {
959
+
return xrpc("_account.reauthTotp", {
921
960
method: "POST",
922
961
token,
923
962
body: { code },
···
925
964
},
926
965
927
966
async reauthPasskeyStart(token: string): Promise<{ options: unknown }> {
928
-
return xrpc("com.tranquil.account.reauthPasskeyStart", {
967
+
return xrpc("_account.reauthPasskeyStart", {
929
968
method: "POST",
930
969
token,
931
970
});
···
935
974
token: string,
936
975
credential: unknown,
937
976
): Promise<{ success: boolean; reauthAt: string }> {
938
-
return xrpc("com.tranquil.account.reauthPasskeyFinish", {
977
+
return xrpc("_account.reauthPasskeyFinish", {
939
978
method: "POST",
940
979
token,
941
980
body: { credential },
···
982
1021
setupToken: string;
983
1022
setupExpiresAt: string;
984
1023
}> {
985
-
const url = `${API_BASE}/com.tranquil.account.createPasskeyAccount`;
1024
+
const url = `${API_BASE}/_account.createPasskeyAccount`;
986
1025
const headers: Record<string, string> = {
987
1026
"Content-Type": "application/json",
988
1027
};
···
1009
1048
setupToken: string,
1010
1049
friendlyName?: string,
1011
1050
): Promise<{ options: unknown }> {
1012
-
return xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", {
1051
+
return xrpc("_account.startPasskeyRegistrationForSetup", {
1013
1052
method: "POST",
1014
1053
body: { did, setupToken, friendlyName },
1015
1054
});
···
1026
1065
appPassword: string;
1027
1066
appPasswordName: string;
1028
1067
}> {
1029
-
return xrpc("com.tranquil.account.completePasskeySetup", {
1068
+
return xrpc("_account.completePasskeySetup", {
1030
1069
method: "POST",
1031
1070
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
1032
1071
});
1033
1072
},
1034
1073
1035
1074
async requestPasskeyRecovery(email: string): Promise<{ success: boolean }> {
1036
-
return xrpc("com.tranquil.account.requestPasskeyRecovery", {
1075
+
return xrpc("_account.requestPasskeyRecovery", {
1037
1076
method: "POST",
1038
1077
body: { email },
1039
1078
});
···
1044
1083
recoveryToken: string,
1045
1084
newPassword: string,
1046
1085
): Promise<{ success: boolean }> {
1047
-
return xrpc("com.tranquil.account.recoverPasskeyAccount", {
1086
+
return xrpc("_account.recoverPasskeyAccount", {
1048
1087
method: "POST",
1049
1088
body: { did, recoveryToken, newPassword },
1050
1089
});
···
1077
1116
purpose: string;
1078
1117
channel: string;
1079
1118
}> {
1080
-
return xrpc("com.tranquil.account.verifyToken", {
1119
+
return xrpc("_account.verifyToken", {
1081
1120
method: "POST",
1082
1121
body: { token, identifier },
1083
1122
token: accessToken,
···
1085
1124
},
1086
1125
1087
1126
async getDidDocument(token: string): Promise<DidDocument> {
1088
-
return xrpc("com.tranquil.account.getDidDocument", { token });
1127
+
return xrpc("_account.getDidDocument", { token });
1089
1128
},
1090
1129
1091
1130
async updateDidDocument(
···
1096
1135
serviceEndpoint?: string;
1097
1136
},
1098
1137
): Promise<{ success: boolean }> {
1099
-
return xrpc("com.tranquil.account.updateDidDocument", {
1138
+
return xrpc("_account.updateDidDocument", {
1100
1139
method: "POST",
1101
1140
token,
1102
1141
body: params,
···
1106
1145
async deactivateAccount(
1107
1146
token: string,
1108
1147
deleteAfter?: string,
1109
-
migratingTo?: string,
1110
1148
): Promise<void> {
1111
1149
await xrpc("com.atproto.server.deactivateAccount", {
1112
1150
method: "POST",
1113
1151
token,
1114
-
body: { deleteAfter, migratingTo },
1152
+
body: { deleteAfter },
1153
+
});
1154
+
},
1155
+
1156
+
async getRepo(token: string, did: string): Promise<ArrayBuffer> {
1157
+
const url = `${API_BASE}/com.atproto.sync.getRepo?did=${
1158
+
encodeURIComponent(did)
1159
+
}`;
1160
+
const res = await fetch(url, {
1161
+
headers: { Authorization: `Bearer ${token}` },
1162
+
});
1163
+
if (!res.ok) {
1164
+
const err = await res.json().catch(() => ({
1165
+
error: "Unknown",
1166
+
message: res.statusText,
1167
+
}));
1168
+
throw new ApiError(res.status, err.error, err.message);
1169
+
}
1170
+
return res.arrayBuffer();
1171
+
},
1172
+
1173
+
async listBackups(token: string): Promise<{
1174
+
backups: Array<{
1175
+
id: string;
1176
+
repoRev: string;
1177
+
repoRootCid: string;
1178
+
blockCount: number;
1179
+
sizeBytes: number;
1180
+
createdAt: string;
1181
+
}>;
1182
+
backupEnabled: boolean;
1183
+
}> {
1184
+
return xrpc("_backup.listBackups", { token });
1185
+
},
1186
+
1187
+
async getBackup(token: string, id: string): Promise<Blob> {
1188
+
const url = `${API_BASE}/_backup.getBackup?id=${encodeURIComponent(id)}`;
1189
+
const res = await fetch(url, {
1190
+
headers: { Authorization: `Bearer ${token}` },
1115
1191
});
1192
+
if (!res.ok) {
1193
+
const err = await res.json().catch(() => ({
1194
+
error: "Unknown",
1195
+
message: res.statusText,
1196
+
}));
1197
+
throw new ApiError(res.status, err.error, err.message);
1198
+
}
1199
+
return res.blob();
1116
1200
},
1117
1201
1118
-
async getMigrationStatus(token: string): Promise<{
1119
-
migratedToPds?: string;
1120
-
migratedAt?: string;
1121
-
forwardingEnabled: boolean;
1202
+
async createBackup(token: string): Promise<{
1203
+
id: string;
1204
+
repoRev: string;
1205
+
sizeBytes: number;
1206
+
blockCount: number;
1122
1207
}> {
1123
-
return xrpc("com.tranquil.account.getMigrationStatus", { token });
1208
+
return xrpc("_backup.createBackup", {
1209
+
method: "POST",
1210
+
token,
1211
+
});
1124
1212
},
1125
1213
1126
-
async updateMigrationForwarding(
1127
-
token: string,
1128
-
forwardingPds?: string,
1129
-
): Promise<{ success: boolean }> {
1130
-
return xrpc("com.tranquil.account.updateMigrationForwarding", {
1214
+
async deleteBackup(token: string, id: string): Promise<void> {
1215
+
await xrpc("_backup.deleteBackup", {
1131
1216
method: "POST",
1132
1217
token,
1133
-
body: { forwardingPds },
1218
+
params: { id },
1134
1219
});
1135
1220
},
1136
1221
1137
-
async clearMigrationForwarding(token: string): Promise<{ success: boolean }> {
1138
-
return xrpc("com.tranquil.account.clearMigrationForwarding", {
1222
+
async setBackupEnabled(
1223
+
token: string,
1224
+
enabled: boolean,
1225
+
): Promise<{ enabled: boolean }> {
1226
+
return xrpc("_backup.setEnabled", {
1139
1227
method: "POST",
1140
1228
token,
1229
+
body: { enabled },
1141
1230
});
1231
+
},
1232
+
1233
+
async importRepo(token: string, car: Uint8Array): Promise<void> {
1234
+
const url = `${API_BASE}/com.atproto.repo.importRepo`;
1235
+
const res = await fetch(url, {
1236
+
method: "POST",
1237
+
headers: {
1238
+
Authorization: `Bearer ${token}`,
1239
+
"Content-Type": "application/vnd.ipld.car",
1240
+
},
1241
+
body: car,
1242
+
});
1243
+
if (!res.ok) {
1244
+
const err = await res.json().catch(() => ({
1245
+
error: "Unknown",
1246
+
message: res.statusText,
1247
+
}));
1248
+
throw new ApiError(res.status, err.error, err.message);
1249
+
}
1142
1250
},
1143
1251
};
+16
-42
frontend/src/lib/migration/atproto-client.ts
+16
-42
frontend/src/lib/migration/atproto-client.ts
···
372
372
);
373
373
}
374
374
375
-
async deactivateAccount(migratingTo?: string): Promise<void> {
375
+
async deactivateAccount(): Promise<void> {
376
376
apiLog(
377
377
"POST",
378
378
`${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`,
379
-
{
380
-
migratingTo,
381
-
},
382
379
);
383
380
const start = Date.now();
384
381
try {
385
-
const body: { migratingTo?: string } = {};
386
-
if (migratingTo) {
387
-
body.migratingTo = migratingTo;
388
-
}
389
382
await this.xrpc("com.atproto.server.deactivateAccount", {
390
383
httpMethod: "POST",
391
-
body,
392
384
});
393
385
apiLog(
394
386
"POST",
···
396
388
{
397
389
durationMs: Date.now() - start,
398
390
success: true,
399
-
migratingTo,
400
391
},
401
392
);
402
393
} catch (e) {
···
409
400
error: err.message,
410
401
errorCode: err.error,
411
402
status: err.status,
412
-
migratingTo,
413
403
},
414
404
);
415
405
throw e;
···
420
410
return this.xrpc("com.atproto.server.checkAccountStatus");
421
411
}
422
412
423
-
async getMigrationStatus(): Promise<{
424
-
did: string;
425
-
didType: string;
426
-
migrated: boolean;
427
-
migratedToPds?: string;
428
-
migratedAt?: string;
429
-
}> {
430
-
return this.xrpc("com.tranquil.account.getMigrationStatus");
431
-
}
432
-
433
-
async updateMigrationForwarding(pdsUrl: string): Promise<{
434
-
success: boolean;
435
-
migratedToPds: string;
436
-
migratedAt: string;
437
-
}> {
438
-
return this.xrpc("com.tranquil.account.updateMigrationForwarding", {
439
-
httpMethod: "POST",
440
-
body: { pdsUrl },
441
-
});
442
-
}
443
-
444
-
async clearMigrationForwarding(): Promise<{ success: boolean }> {
445
-
return this.xrpc("com.tranquil.account.clearMigrationForwarding", {
446
-
httpMethod: "POST",
447
-
});
448
-
}
449
-
450
413
async resolveHandle(handle: string): Promise<{ did: string }> {
451
414
return this.xrpc("com.atproto.identity.resolveHandle", {
452
415
params: { handle },
···
468
431
return session;
469
432
}
470
433
434
+
async checkEmailVerified(identifier: string): Promise<boolean> {
435
+
const result = await this.xrpc<{ verified: boolean }>(
436
+
"_checkEmailVerified",
437
+
{
438
+
httpMethod: "POST",
439
+
body: { identifier },
440
+
},
441
+
);
442
+
return result.verified;
443
+
}
444
+
471
445
async verifyToken(
472
446
token: string,
473
447
identifier: string,
474
448
): Promise<
475
449
{ success: boolean; did: string; purpose: string; channel: string }
476
450
> {
477
-
return this.xrpc("com.tranquil.account.verifyToken", {
451
+
return this.xrpc("_account.verifyToken", {
478
452
httpMethod: "POST",
479
453
body: { token, identifier },
480
454
});
···
498
472
}
499
473
500
474
const res = await fetch(
501
-
`${this.baseUrl}/xrpc/com.tranquil.account.createPasskeyAccount`,
475
+
`${this.baseUrl}/xrpc/_account.createPasskeyAccount`,
502
476
{
503
477
method: "POST",
504
478
headers,
···
530
504
setupToken: string,
531
505
friendlyName?: string,
532
506
): Promise<StartPasskeyRegistrationResponse> {
533
-
return this.xrpc("com.tranquil.account.startPasskeyRegistrationForSetup", {
507
+
return this.xrpc("_account.startPasskeyRegistrationForSetup", {
534
508
httpMethod: "POST",
535
509
body: { did, setupToken, friendlyName },
536
510
});
···
542
516
passkeyCredential: unknown,
543
517
passkeyFriendlyName?: string,
544
518
): Promise<CompletePasskeySetupResponse> {
545
-
return this.xrpc("com.tranquil.account.completePasskeySetup", {
519
+
return this.xrpc("_account.completePasskeySetup", {
546
520
httpMethod: "POST",
547
521
body: { did, setupToken, passkeyCredential, passkeyFriendlyName },
548
522
});
+156
frontend/src/lib/migration/blob-migration.ts
+156
frontend/src/lib/migration/blob-migration.ts
···
1
+
import type { AtprotoClient } from "./atproto-client";
2
+
import type { MigrationProgress } from "./types";
3
+
4
+
export interface BlobMigrationResult {
5
+
migrated: number;
6
+
failed: string[];
7
+
total: number;
8
+
sourceUnreachable: boolean;
9
+
}
10
+
11
+
export async function migrateBlobs(
12
+
localClient: AtprotoClient,
13
+
sourceClient: AtprotoClient | null,
14
+
userDid: string,
15
+
onProgress: (update: Partial<MigrationProgress>) => void,
16
+
): Promise<BlobMigrationResult> {
17
+
const missingBlobs: string[] = [];
18
+
let cursor: string | undefined;
19
+
20
+
console.log("[blob-migration] Starting blob migration for", userDid);
21
+
console.log(
22
+
"[blob-migration] Source client:",
23
+
sourceClient ? "available" : "NOT AVAILABLE",
24
+
);
25
+
26
+
onProgress({ currentOperation: "Checking for missing blobs..." });
27
+
28
+
do {
29
+
const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
30
+
cursor,
31
+
100,
32
+
);
33
+
console.log(
34
+
"[blob-migration] listMissingBlobs returned",
35
+
blobs.length,
36
+
"blobs, cursor:",
37
+
nextCursor,
38
+
);
39
+
for (const blob of blobs) {
40
+
missingBlobs.push(blob.cid);
41
+
}
42
+
cursor = nextCursor;
43
+
} while (cursor);
44
+
45
+
console.log("[blob-migration] Total missing blobs:", missingBlobs.length);
46
+
onProgress({ blobsTotal: missingBlobs.length });
47
+
48
+
if (missingBlobs.length === 0) {
49
+
console.log("[blob-migration] No blobs to migrate");
50
+
onProgress({ currentOperation: "No blobs to migrate" });
51
+
return { migrated: 0, failed: [], total: 0, sourceUnreachable: false };
52
+
}
53
+
54
+
if (!sourceClient) {
55
+
console.warn(
56
+
"[blob-migration] No source client available, cannot fetch blobs",
57
+
);
58
+
onProgress({
59
+
currentOperation:
60
+
`${missingBlobs.length} media files missing. No source PDS URL available - your old server may have shut down. Posts will work, but some images/media may be unavailable.`,
61
+
});
62
+
return {
63
+
migrated: 0,
64
+
failed: missingBlobs,
65
+
total: missingBlobs.length,
66
+
sourceUnreachable: true,
67
+
};
68
+
}
69
+
70
+
onProgress({ currentOperation: `Migrating ${missingBlobs.length} blobs...` });
71
+
72
+
let migrated = 0;
73
+
const failed: string[] = [];
74
+
let sourceUnreachable = false;
75
+
76
+
for (const cid of missingBlobs) {
77
+
if (sourceUnreachable) {
78
+
failed.push(cid);
79
+
continue;
80
+
}
81
+
82
+
try {
83
+
onProgress({
84
+
currentOperation: `Migrating blob ${
85
+
migrated + 1
86
+
}/${missingBlobs.length}...`,
87
+
});
88
+
89
+
console.log("[blob-migration] Fetching blob", cid, "from source");
90
+
const blobData = await sourceClient.getBlob(userDid, cid);
91
+
console.log(
92
+
"[blob-migration] Got blob",
93
+
cid,
94
+
"size:",
95
+
blobData.byteLength,
96
+
);
97
+
await localClient.uploadBlob(blobData, "application/octet-stream");
98
+
console.log("[blob-migration] Uploaded blob", cid);
99
+
migrated++;
100
+
onProgress({ blobsMigrated: migrated });
101
+
} catch (e) {
102
+
const errorMessage = (e as Error).message || String(e);
103
+
console.error(
104
+
"[blob-migration] Failed to migrate blob",
105
+
cid,
106
+
":",
107
+
errorMessage,
108
+
);
109
+
110
+
const isNetworkError =
111
+
errorMessage.includes("fetch") ||
112
+
errorMessage.includes("network") ||
113
+
errorMessage.includes("CORS") ||
114
+
errorMessage.includes("Failed to fetch") ||
115
+
errorMessage.includes("NetworkError") ||
116
+
errorMessage.includes("blocked by CORS");
117
+
118
+
if (isNetworkError) {
119
+
sourceUnreachable = true;
120
+
console.warn(
121
+
"[blob-migration] Source appears unreachable (likely CORS or network issue), skipping remaining blobs",
122
+
);
123
+
const remaining = missingBlobs.length - migrated - 1;
124
+
if (migrated > 0) {
125
+
onProgress({
126
+
currentOperation:
127
+
`Source PDS unreachable (browser security restriction). ${migrated} media files migrated successfully. ${remaining + 1} could not be fetched - these may need to be re-uploaded.`,
128
+
});
129
+
} else {
130
+
onProgress({
131
+
currentOperation:
132
+
`Cannot reach source PDS (browser security restriction). This commonly happens when the old server has shut down or doesn't allow cross-origin requests. Your posts will work, but ${missingBlobs.length} media files couldn't be recovered.`,
133
+
});
134
+
}
135
+
}
136
+
failed.push(cid);
137
+
}
138
+
}
139
+
140
+
if (migrated === missingBlobs.length) {
141
+
onProgress({
142
+
currentOperation: `All ${migrated} blobs migrated successfully`,
143
+
});
144
+
} else if (migrated > 0) {
145
+
onProgress({
146
+
currentOperation:
147
+
`${migrated}/${missingBlobs.length} blobs migrated. ${failed.length} failed.`,
148
+
});
149
+
} else {
150
+
onProgress({
151
+
currentOperation: `Could not migrate blobs (${failed.length} missing)`,
152
+
});
153
+
}
154
+
155
+
return { migrated, failed, total: missingBlobs.length, sourceUnreachable };
156
+
}
+17
-318
frontend/src/lib/migration/flow.svelte.ts
+17
-318
frontend/src/lib/migration/flow.svelte.ts
···
2
2
InboundMigrationState,
3
3
InboundStep,
4
4
MigrationProgress,
5
-
OutboundMigrationState,
6
-
OutboundStep,
7
5
PasskeyAccountSetup,
8
6
ServerDescription,
9
7
StoredMigrationState,
···
30
28
updateProgress,
31
29
updateStep,
32
30
} from "./storage";
31
+
import { migrateBlobs as migrateBlobsUtil } from "./blob-migration";
33
32
34
33
function migrationLog(stage: string, data?: Record<string, unknown>) {
35
34
const timestamp = new Date().toISOString();
···
85
84
let sourceClient: AtprotoClient | null = null;
86
85
let localClient: AtprotoClient | null = null;
87
86
let localServerInfo: ServerDescription | null = null;
87
+
let sourceOAuthMetadata: Awaited<ReturnType<typeof getOAuthServerMetadata>> =
88
+
null;
88
89
89
90
function setStep(step: InboundStep) {
90
91
state.step = step;
91
92
state.error = null;
92
-
saveMigrationState(state);
93
-
updateStep(step);
93
+
if (step !== "success") {
94
+
saveMigrationState(state);
95
+
updateStep(step);
96
+
}
94
97
}
95
98
96
99
function setError(error: string) {
···
458
461
async function migrateBlobs(): Promise<void> {
459
462
if (!sourceClient || !localClient) return;
460
463
461
-
let cursor: string | undefined;
462
-
let migrated = 0;
463
-
464
-
do {
465
-
const { blobs, cursor: nextCursor } = await localClient.listMissingBlobs(
466
-
cursor,
467
-
100,
468
-
);
469
-
470
-
for (const blob of blobs) {
471
-
try {
472
-
setProgress({
473
-
currentOperation: `Migrating blob ${
474
-
migrated + 1
475
-
}/${state.progress.blobsTotal}...`,
476
-
});
477
-
478
-
const blobData = await sourceClient.getBlob(
479
-
state.sourceDid,
480
-
blob.cid,
481
-
);
482
-
await localClient.uploadBlob(blobData, "application/octet-stream");
483
-
migrated++;
484
-
setProgress({ blobsMigrated: migrated });
485
-
} catch {
486
-
state.progress.blobsFailed.push(blob.cid);
487
-
}
488
-
}
464
+
const result = await migrateBlobsUtil(
465
+
localClient,
466
+
sourceClient,
467
+
state.sourceDid,
468
+
setProgress,
469
+
);
489
470
490
-
cursor = nextCursor;
491
-
} while (cursor);
471
+
state.progress.blobsFailed = result.failed;
492
472
}
493
473
494
474
async function migratePreferences(): Promise<void> {
···
578
558
579
559
checkingEmailVerification = true;
580
560
try {
561
+
const verified = await localClient.checkEmailVerified(state.targetEmail);
562
+
if (!verified) return false;
563
+
581
564
await localClient.loginDeactivated(
582
565
state.targetEmail,
583
566
state.targetPassword,
···
978
961
};
979
962
}
980
963
981
-
export function createOutboundMigrationFlow() {
982
-
let state = $state<OutboundMigrationState>({
983
-
direction: "outbound",
984
-
step: "welcome",
985
-
localDid: "",
986
-
localHandle: "",
987
-
targetPdsUrl: "",
988
-
targetPdsDid: "",
989
-
targetHandle: "",
990
-
targetEmail: "",
991
-
targetPassword: "",
992
-
inviteCode: "",
993
-
targetAccessToken: null,
994
-
targetRefreshToken: null,
995
-
serviceAuthToken: null,
996
-
plcToken: "",
997
-
progress: createInitialProgress(),
998
-
error: null,
999
-
targetServerInfo: null,
1000
-
});
1001
-
1002
-
let localClient: AtprotoClient | null = null;
1003
-
let targetClient: AtprotoClient | null = null;
1004
-
1005
-
function setStep(step: OutboundStep) {
1006
-
state.step = step;
1007
-
state.error = null;
1008
-
saveMigrationState(state);
1009
-
updateStep(step);
1010
-
}
1011
-
1012
-
function setError(error: string) {
1013
-
state.error = error;
1014
-
saveMigrationState(state);
1015
-
}
1016
-
1017
-
function setProgress(updates: Partial<MigrationProgress>) {
1018
-
state.progress = { ...state.progress, ...updates };
1019
-
updateProgress(updates);
1020
-
}
1021
-
1022
-
async function validateTargetPds(url: string): Promise<ServerDescription> {
1023
-
const normalizedUrl = url.replace(/\/$/, "");
1024
-
targetClient = new AtprotoClient(normalizedUrl);
1025
-
1026
-
try {
1027
-
const serverInfo = await targetClient.describeServer();
1028
-
state.targetPdsUrl = normalizedUrl;
1029
-
state.targetPdsDid = serverInfo.did;
1030
-
state.targetServerInfo = serverInfo;
1031
-
return serverInfo;
1032
-
} catch (e) {
1033
-
throw new Error(`Could not connect to PDS: ${(e as Error).message}`);
1034
-
}
1035
-
}
1036
-
1037
-
function initLocalClient(
1038
-
accessToken: string,
1039
-
did?: string,
1040
-
handle?: string,
1041
-
): void {
1042
-
localClient = createLocalClient();
1043
-
localClient.setAccessToken(accessToken);
1044
-
if (did) {
1045
-
state.localDid = did;
1046
-
}
1047
-
if (handle) {
1048
-
state.localHandle = handle;
1049
-
}
1050
-
}
1051
-
1052
-
async function startMigration(currentDid: string): Promise<void> {
1053
-
if (!localClient || !targetClient) {
1054
-
throw new Error("Not connected to PDSes");
1055
-
}
1056
-
1057
-
setStep("migrating");
1058
-
setProgress({ currentOperation: "Getting service auth token..." });
1059
-
1060
-
try {
1061
-
const { token } = await localClient.getServiceAuth(
1062
-
state.targetPdsDid,
1063
-
"com.atproto.server.createAccount",
1064
-
);
1065
-
state.serviceAuthToken = token;
1066
-
1067
-
setProgress({ currentOperation: "Creating account on new PDS..." });
1068
-
1069
-
const accountParams = {
1070
-
did: currentDid,
1071
-
handle: state.targetHandle,
1072
-
email: state.targetEmail,
1073
-
password: state.targetPassword,
1074
-
inviteCode: state.inviteCode || undefined,
1075
-
};
1076
-
1077
-
const session = await targetClient.createAccount(accountParams, token);
1078
-
state.targetAccessToken = session.accessJwt;
1079
-
state.targetRefreshToken = session.refreshJwt;
1080
-
targetClient.setAccessToken(session.accessJwt);
1081
-
1082
-
setProgress({ currentOperation: "Exporting repository..." });
1083
-
1084
-
const car = await localClient.getRepo(currentDid);
1085
-
setProgress({
1086
-
repoExported: true,
1087
-
currentOperation: "Importing repository...",
1088
-
});
1089
-
1090
-
await targetClient.importRepo(car);
1091
-
setProgress({
1092
-
repoImported: true,
1093
-
currentOperation: "Counting blobs...",
1094
-
});
1095
-
1096
-
const accountStatus = await targetClient.checkAccountStatus();
1097
-
setProgress({
1098
-
blobsTotal: accountStatus.expectedBlobs,
1099
-
currentOperation: "Migrating blobs...",
1100
-
});
1101
-
1102
-
await migrateBlobs(currentDid);
1103
-
1104
-
setProgress({ currentOperation: "Migrating preferences..." });
1105
-
await migratePreferences();
1106
-
1107
-
setProgress({ currentOperation: "Requesting PLC operation token..." });
1108
-
await localClient.requestPlcOperationSignature();
1109
-
1110
-
setStep("plc-token");
1111
-
} catch (e) {
1112
-
const err = e as Error & { error?: string; status?: number };
1113
-
const message = err.message || err.error ||
1114
-
`Unknown error (status ${err.status || "unknown"})`;
1115
-
setError(message);
1116
-
setStep("error");
1117
-
}
1118
-
}
1119
-
1120
-
async function migrateBlobs(did: string): Promise<void> {
1121
-
if (!localClient || !targetClient) return;
1122
-
1123
-
let cursor: string | undefined;
1124
-
let migrated = 0;
1125
-
1126
-
do {
1127
-
const { blobs, cursor: nextCursor } = await targetClient.listMissingBlobs(
1128
-
cursor,
1129
-
100,
1130
-
);
1131
-
1132
-
for (const blob of blobs) {
1133
-
try {
1134
-
setProgress({
1135
-
currentOperation: `Migrating blob ${
1136
-
migrated + 1
1137
-
}/${state.progress.blobsTotal}...`,
1138
-
});
1139
-
1140
-
const blobData = await localClient.getBlob(did, blob.cid);
1141
-
await targetClient.uploadBlob(blobData, "application/octet-stream");
1142
-
migrated++;
1143
-
setProgress({ blobsMigrated: migrated });
1144
-
} catch {
1145
-
state.progress.blobsFailed.push(blob.cid);
1146
-
}
1147
-
}
1148
-
1149
-
cursor = nextCursor;
1150
-
} while (cursor);
1151
-
}
1152
-
1153
-
async function migratePreferences(): Promise<void> {
1154
-
if (!localClient || !targetClient) return;
1155
-
1156
-
try {
1157
-
const prefs = await localClient.getPreferences();
1158
-
await targetClient.putPreferences(prefs);
1159
-
setProgress({ prefsMigrated: true });
1160
-
} catch { /* optional, best-effort */ }
1161
-
}
1162
-
1163
-
async function submitPlcToken(token: string): Promise<void> {
1164
-
if (!localClient || !targetClient) {
1165
-
throw new Error("Not connected to PDSes");
1166
-
}
1167
-
1168
-
state.plcToken = token;
1169
-
setStep("finalizing");
1170
-
setProgress({ currentOperation: "Signing PLC operation..." });
1171
-
1172
-
try {
1173
-
const credentials = await targetClient.getRecommendedDidCredentials();
1174
-
1175
-
const { operation } = await localClient.signPlcOperation({
1176
-
token,
1177
-
...credentials,
1178
-
});
1179
-
1180
-
setProgress({
1181
-
plcSigned: true,
1182
-
currentOperation: "Submitting PLC operation...",
1183
-
});
1184
-
1185
-
await targetClient.submitPlcOperation(operation);
1186
-
1187
-
setProgress({ currentOperation: "Activating account on new PDS..." });
1188
-
await targetClient.activateAccount();
1189
-
setProgress({ activated: true });
1190
-
1191
-
setProgress({ currentOperation: "Deactivating old account..." });
1192
-
try {
1193
-
await localClient.deactivateAccount(state.targetPdsUrl);
1194
-
setProgress({ deactivated: true });
1195
-
} catch { /* optional, best-effort */ }
1196
-
1197
-
setStep("success");
1198
-
clearMigrationState();
1199
-
} catch (e) {
1200
-
const err = e as Error & { error?: string; status?: number };
1201
-
const message = err.message || err.error ||
1202
-
`Unknown error (status ${err.status || "unknown"})`;
1203
-
setError(message);
1204
-
setStep("plc-token");
1205
-
}
1206
-
}
1207
-
1208
-
async function resendPlcToken(): Promise<void> {
1209
-
if (!localClient) {
1210
-
throw new Error("Not connected to local PDS");
1211
-
}
1212
-
await localClient.requestPlcOperationSignature();
1213
-
}
1214
-
1215
-
function reset(): void {
1216
-
state = {
1217
-
direction: "outbound",
1218
-
step: "welcome",
1219
-
localDid: "",
1220
-
localHandle: "",
1221
-
targetPdsUrl: "",
1222
-
targetPdsDid: "",
1223
-
targetHandle: "",
1224
-
targetEmail: "",
1225
-
targetPassword: "",
1226
-
inviteCode: "",
1227
-
targetAccessToken: null,
1228
-
targetRefreshToken: null,
1229
-
serviceAuthToken: null,
1230
-
plcToken: "",
1231
-
progress: createInitialProgress(),
1232
-
error: null,
1233
-
targetServerInfo: null,
1234
-
};
1235
-
localClient = null;
1236
-
targetClient = null;
1237
-
clearMigrationState();
1238
-
}
1239
-
1240
-
return {
1241
-
get state() {
1242
-
return state;
1243
-
},
1244
-
setStep,
1245
-
setError,
1246
-
validateTargetPds,
1247
-
initLocalClient,
1248
-
startMigration,
1249
-
submitPlcToken,
1250
-
resendPlcToken,
1251
-
reset,
1252
-
1253
-
updateField<K extends keyof OutboundMigrationState>(
1254
-
field: K,
1255
-
value: OutboundMigrationState[K],
1256
-
) {
1257
-
state[field] = value;
1258
-
},
1259
-
};
1260
-
}
1261
-
1262
964
export type InboundMigrationFlow = ReturnType<
1263
965
typeof createInboundMigrationFlow
1264
966
>;
1265
-
export type OutboundMigrationFlow = ReturnType<
1266
-
typeof createOutboundMigrationFlow
1267
-
>;
+8
-2
frontend/src/lib/migration/index.ts
+8
-2
frontend/src/lib/migration/index.ts
···
1
1
export * from "./types";
2
2
export * from "./atproto-client";
3
3
export * from "./storage";
4
+
export * from "./blob-migration";
4
5
export {
5
6
createInboundMigrationFlow,
6
-
createOutboundMigrationFlow,
7
7
type InboundMigrationFlow,
8
-
type OutboundMigrationFlow,
9
8
} from "./flow.svelte";
9
+
export {
10
+
clearOfflineState,
11
+
createOfflineInboundMigrationFlow,
12
+
getOfflineResumeInfo,
13
+
hasPendingOfflineMigration,
14
+
} from "./offline-flow.svelte";
15
+
export type { OfflineInboundMigrationFlow } from "./offline-flow.svelte";
+765
frontend/src/lib/migration/offline-flow.svelte.ts
+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
+281
frontend/src/lib/migration/plc-ops.ts
···
1
+
import {
2
+
defs,
3
+
type IndexedEntry,
4
+
normalizeOp,
5
+
type Operation,
6
+
} from "@atcute/did-plc";
7
+
import {
8
+
P256PrivateKey,
9
+
parsePrivateMultikey,
10
+
Secp256k1PrivateKey,
11
+
Secp256k1PrivateKeyExportable,
12
+
} from "@atcute/crypto";
13
+
import * as CBOR from "@atcute/cbor";
14
+
import { fromBase16, toBase64Url } from "@atcute/multibase";
15
+
16
+
export type PrivateKey = P256PrivateKey | Secp256k1PrivateKey;
17
+
18
+
export interface KeypairInfo {
19
+
type: "private_key";
20
+
didPublicKey: `did:key:${string}`;
21
+
keypair: PrivateKey;
22
+
}
23
+
24
+
export interface PlcService {
25
+
type: string;
26
+
endpoint: string;
27
+
}
28
+
29
+
export interface PlcOperationData {
30
+
type: "plc_operation";
31
+
prev: string;
32
+
alsoKnownAs: string[];
33
+
rotationKeys: string[];
34
+
services: Record<string, PlcService>;
35
+
verificationMethods: Record<string, string>;
36
+
sig?: string;
37
+
}
38
+
39
+
const jsonToB64Url = (obj: unknown): string => {
40
+
const enc = new TextEncoder();
41
+
const json = JSON.stringify(obj);
42
+
return toBase64Url(enc.encode(json));
43
+
};
44
+
45
+
export class PlcOps {
46
+
private plcDirectoryUrl: string;
47
+
48
+
constructor(plcDirectoryUrl = "https://plc.directory") {
49
+
this.plcDirectoryUrl = plcDirectoryUrl;
50
+
}
51
+
52
+
async getPlcAuditLogs(did: string): Promise<IndexedEntry[]> {
53
+
const response = await fetch(`${this.plcDirectoryUrl}/${did}/log/audit`);
54
+
if (!response.ok) {
55
+
throw new Error(`Failed to fetch PLC audit logs: ${response.status}`);
56
+
}
57
+
const json = await response.json();
58
+
return defs.indexedEntryLog.parse(json);
59
+
}
60
+
61
+
async getLastPlcOpFromPlc(
62
+
did: string,
63
+
): Promise<{ lastOperation: Operation; base: IndexedEntry }> {
64
+
const logs = await this.getPlcAuditLogs(did);
65
+
const lastOp = logs.at(-1);
66
+
if (!lastOp) {
67
+
throw new Error("No PLC operations found for this DID");
68
+
}
69
+
return { lastOperation: normalizeOp(lastOp.operation), base: lastOp };
70
+
}
71
+
72
+
async getCurrentRotationKeysForUser(did: string): Promise<string[]> {
73
+
const { lastOperation } = await this.getLastPlcOpFromPlc(did);
74
+
return lastOperation.rotationKeys || [];
75
+
}
76
+
77
+
async createNewSecp256k1Keypair(): Promise<
78
+
{ privateKey: string; publicKey: `did:key:${string}` }
79
+
> {
80
+
const keypair = await Secp256k1PrivateKeyExportable.createKeypair();
81
+
const publicKey = await keypair.exportPublicKey("did");
82
+
const privateKey = await keypair.exportPrivateKey("multikey");
83
+
return { privateKey, publicKey };
84
+
}
85
+
86
+
async getKeyPair(
87
+
privateKeyString: string,
88
+
type: "secp256k1" | "p256" = "secp256k1",
89
+
): Promise<KeypairInfo> {
90
+
const HEX_REGEX = /^[0-9a-f]+$/i;
91
+
const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/;
92
+
let keypair: PrivateKey | undefined;
93
+
94
+
const trimmed = privateKeyString.trim();
95
+
96
+
if (HEX_REGEX.test(trimmed) && trimmed.length === 64) {
97
+
const privateKeyBytes = fromBase16(trimmed);
98
+
if (type === "p256") {
99
+
keypair = await P256PrivateKey.importRaw(privateKeyBytes);
100
+
} else {
101
+
keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
102
+
}
103
+
} else if (MULTIKEY_REGEX.test(trimmed)) {
104
+
const match = parsePrivateMultikey(trimmed);
105
+
const privateKeyBytes = match.privateKeyBytes;
106
+
if (match.type === "p256") {
107
+
keypair = await P256PrivateKey.importRaw(privateKeyBytes);
108
+
} else if (match.type === "secp256k1") {
109
+
keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
110
+
} else {
111
+
throw new Error(`Unsupported key type: ${match.type}`);
112
+
}
113
+
} else {
114
+
throw new Error(
115
+
"Invalid key format. Expected 64-char hex or multikey format.",
116
+
);
117
+
}
118
+
119
+
if (!keypair) {
120
+
throw new Error("Failed to parse private key");
121
+
}
122
+
123
+
return {
124
+
type: "private_key",
125
+
didPublicKey: await keypair.exportPublicKey("did"),
126
+
keypair,
127
+
};
128
+
}
129
+
130
+
async signAndPublishNewOp(
131
+
did: string,
132
+
signingRotationKey: PrivateKey,
133
+
alsoKnownAs: string[],
134
+
rotationKeys: string[],
135
+
pds: string,
136
+
verificationKey: string,
137
+
prev: string,
138
+
): Promise<void> {
139
+
const rotationKeysToUse = [...new Set(rotationKeys)];
140
+
if (rotationKeysToUse.length === 0) {
141
+
throw new Error("No rotation keys provided");
142
+
}
143
+
if (rotationKeysToUse.length > 5) {
144
+
throw new Error("Maximum 5 rotation keys allowed");
145
+
}
146
+
147
+
const operation: PlcOperationData = {
148
+
type: "plc_operation",
149
+
prev,
150
+
alsoKnownAs,
151
+
rotationKeys: rotationKeysToUse,
152
+
services: {
153
+
atproto_pds: {
154
+
type: "AtprotoPersonalDataServer",
155
+
endpoint: pds,
156
+
},
157
+
},
158
+
verificationMethods: {
159
+
atproto: verificationKey,
160
+
},
161
+
};
162
+
163
+
const opBytes = CBOR.encode(operation);
164
+
const sigBytes = await signingRotationKey.sign(opBytes);
165
+
const signature = toBase64Url(sigBytes);
166
+
167
+
const signedOperation = {
168
+
...operation,
169
+
sig: signature,
170
+
};
171
+
172
+
await this.pushPlcOperation(did, signedOperation);
173
+
}
174
+
175
+
async pushPlcOperation(
176
+
did: string,
177
+
operation: PlcOperationData,
178
+
): Promise<void> {
179
+
const response = await fetch(`${this.plcDirectoryUrl}/${did}`, {
180
+
method: "POST",
181
+
headers: {
182
+
"Content-Type": "application/json",
183
+
},
184
+
body: JSON.stringify(operation),
185
+
});
186
+
187
+
if (!response.ok) {
188
+
const contentType = response.headers.get("content-type");
189
+
if (contentType?.includes("application/json")) {
190
+
const json = await response.json();
191
+
if (
192
+
typeof json === "object" && json !== null &&
193
+
typeof json.message === "string"
194
+
) {
195
+
throw new Error(json.message);
196
+
}
197
+
}
198
+
throw new Error(`PLC directory returned HTTP ${response.status}`);
199
+
}
200
+
}
201
+
202
+
async createServiceAuthToken(
203
+
iss: string,
204
+
aud: string,
205
+
keypair: PrivateKey,
206
+
lxm: string,
207
+
): Promise<string> {
208
+
const iat = Math.floor(Date.now() / 1000);
209
+
const exp = iat + 60;
210
+
211
+
const jti = (() => {
212
+
const bytes = new Uint8Array(16);
213
+
crypto.getRandomValues(bytes);
214
+
return Array.from(bytes)
215
+
.map((b) => b.toString(16).padStart(2, "0"))
216
+
.join("");
217
+
})();
218
+
219
+
const header = { typ: "JWT", alg: "ES256K" };
220
+
const payload = { iat, iss, aud, exp, lxm, jti };
221
+
222
+
const headerB64 = jsonToB64Url(header);
223
+
const payloadB64 = jsonToB64Url(payload);
224
+
const toSignStr = `${headerB64}.${payloadB64}`;
225
+
226
+
const toSignBytes = new TextEncoder().encode(toSignStr);
227
+
const sigBytes = await keypair.sign(toSignBytes);
228
+
const sigB64 = toBase64Url(sigBytes);
229
+
230
+
return `${toSignStr}.${sigB64}`;
231
+
}
232
+
233
+
async signPlcOperationWithCredentials(
234
+
did: string,
235
+
signingKey: PrivateKey,
236
+
credentials: {
237
+
rotationKeys?: string[];
238
+
alsoKnownAs?: string[];
239
+
verificationMethods?: Record<string, string>;
240
+
services?: Record<string, PlcService>;
241
+
},
242
+
additionalRotationKeys: string[],
243
+
prevCid: string,
244
+
): Promise<void> {
245
+
const rotationKeys = [
246
+
...new Set([
247
+
...(additionalRotationKeys || []),
248
+
...(credentials.rotationKeys || []),
249
+
]),
250
+
];
251
+
252
+
if (rotationKeys.length === 0) {
253
+
throw new Error("No rotation keys provided");
254
+
}
255
+
if (rotationKeys.length > 5) {
256
+
throw new Error("Maximum 5 rotation keys allowed");
257
+
}
258
+
259
+
const operation: PlcOperationData = {
260
+
type: "plc_operation",
261
+
prev: prevCid,
262
+
alsoKnownAs: credentials.alsoKnownAs || [],
263
+
rotationKeys,
264
+
services: credentials.services || {},
265
+
verificationMethods: credentials.verificationMethods || {},
266
+
};
267
+
268
+
const opBytes = CBOR.encode(operation);
269
+
const sigBytes = await signingKey.sign(opBytes);
270
+
const signature = toBase64Url(sigBytes);
271
+
272
+
const signedOperation = {
273
+
...operation,
274
+
sig: signature,
275
+
};
276
+
277
+
await this.pushPlcOperation(did, signedOperation);
278
+
}
279
+
}
280
+
281
+
export const plcOps = new PlcOps();
+35
-21
frontend/src/lib/migration/types.ts
+35
-21
frontend/src/lib/migration/types.ts
···
13
13
| "success"
14
14
| "error";
15
15
16
-
export type AuthMethod = "password" | "passkey";
17
-
18
-
export type OutboundStep =
16
+
export type OfflineInboundStep =
19
17
| "welcome"
20
-
| "target-pds"
21
-
| "new-account"
18
+
| "provide-did"
19
+
| "upload-car"
20
+
| "provide-rotation-key"
21
+
| "choose-handle"
22
22
| "review"
23
-
| "migrating"
24
-
| "plc-token"
23
+
| "creating"
24
+
| "importing"
25
+
| "migrating-blobs"
26
+
| "plc-signing"
27
+
| "email-verify"
28
+
| "passkey-setup"
29
+
| "app-password"
25
30
| "finalizing"
26
31
| "success"
27
32
| "error";
28
33
29
-
export type MigrationDirection = "inbound" | "outbound";
34
+
export type AuthMethod = "password" | "passkey";
35
+
36
+
export type MigrationDirection = "inbound";
30
37
31
38
export interface MigrationProgress {
32
39
repoExported: boolean;
···
68
75
resumeToStep?: InboundStep;
69
76
}
70
77
71
-
export interface OutboundMigrationState {
72
-
direction: "outbound";
73
-
step: OutboundStep;
74
-
localDid: string;
75
-
localHandle: string;
76
-
targetPdsUrl: string;
77
-
targetPdsDid: string;
78
+
export interface OfflineInboundMigrationState {
79
+
direction: "offline-inbound";
80
+
step: OfflineInboundStep;
81
+
userDid: string;
82
+
carFile: Uint8Array | null;
83
+
carFileName: string;
84
+
carSizeBytes: number;
85
+
carNeedsReupload: boolean;
86
+
rotationKey: string;
87
+
rotationKeyDidKey: string;
88
+
oldPdsUrl: string | null;
78
89
targetHandle: string;
79
90
targetEmail: string;
80
91
targetPassword: string;
81
92
inviteCode: string;
82
-
targetAccessToken: string | null;
83
-
targetRefreshToken: string | null;
84
-
serviceAuthToken: string | null;
85
-
plcToken: string;
93
+
authMethod: AuthMethod;
94
+
localAccessToken: string | null;
95
+
localRefreshToken: string | null;
96
+
passkeySetupToken: string | null;
97
+
generatedAppPassword: string | null;
98
+
generatedAppPasswordName: string | null;
99
+
emailVerifyToken: string;
86
100
progress: MigrationProgress;
87
101
error: string | null;
88
-
targetServerInfo: ServerDescription | null;
102
+
plcUpdatedTemporarily: boolean;
89
103
}
90
104
91
-
export type MigrationState = InboundMigrationState | OutboundMigrationState;
105
+
export type MigrationState = InboundMigrationState;
92
106
93
107
export interface StoredMigrationState {
94
108
version: 1;
+152
-98
frontend/src/locales/en.json
+152
-98
frontend/src/locales/en.json
···
17
17
"dashboard": "Dashboard",
18
18
"backToDashboard": "← Dashboard",
19
19
"copied": "Copied!",
20
-
"copyToClipboard": "Copy to Clipboard"
20
+
"copyToClipboard": "Copy to Clipboard",
21
+
22
+
"verifying": "Verifying...",
23
+
"saving": "Saving...",
24
+
"creating": "Creating...",
25
+
"updating": "Updating...",
26
+
"sending": "Sending...",
27
+
"authenticating": "Authenticating...",
28
+
"checking": "Checking...",
29
+
"redirecting": "Redirecting...",
30
+
31
+
"signIn": "Sign In",
32
+
"verify": "Verify",
33
+
"remove": "Remove",
34
+
"revoke": "Revoke",
35
+
"resendCode": "Resend Code",
36
+
"startOver": "Start Over",
37
+
"tryAgain": "Try Again",
38
+
39
+
"password": "Password",
40
+
"email": "Email",
41
+
"emailAddress": "Email Address",
42
+
"handle": "Handle",
43
+
"did": "DID",
44
+
"verificationCode": "Verification Code",
45
+
"inviteCode": "Invite Code",
46
+
"newPassword": "New Password",
47
+
"confirmPassword": "Confirm Password",
48
+
49
+
"enterSixDigitCode": "Enter 6-digit code",
50
+
"passwordHint": "At least 8 characters",
51
+
"enterPassword": "Enter your password",
52
+
"emailPlaceholder": "you@example.com",
53
+
54
+
"verified": "Verified",
55
+
"disabled": "Disabled",
56
+
"available": "Available",
57
+
"deactivated": "Deactivated",
58
+
"unverified": "Unverified",
59
+
60
+
"backToLogin": "Back to Login",
61
+
"backToSettings": "Back to Settings",
62
+
"alreadyHaveAccount": "Already have an account?",
63
+
"createAccount": "Create account",
64
+
65
+
"passwordsMismatch": "Passwords do not match",
66
+
"passwordTooShort": "Password must be at least 8 characters"
21
67
},
22
68
"login": {
23
69
"title": "Sign In",
···
49
95
"codeLabel": "Verification Code",
50
96
"codePlaceholder": "Enter 6-digit code",
51
97
"verifyButton": "Verify Account",
52
-
"verifying": "Verifying...",
53
-
"resendButton": "Resend Code",
54
-
"resending": "Resending...",
55
-
"resent": "Verification code resent!",
56
-
"backToLogin": "Back to Login"
98
+
"resent": "Verification code resent!"
57
99
},
58
100
"register": {
59
101
"title": "Create Account",
···
124
166
"inviteCodePlaceholder": "Enter your invite code",
125
167
"inviteCodeRequired": "required",
126
168
"createButton": "Create Account",
127
-
"creating": "Creating account...",
128
169
"alreadyHaveAccount": "Already have an account?",
129
170
"signIn": "Sign in",
130
171
"wantPasswordless": "Want passwordless security?",
···
179
220
"navAdminDesc": "Server stats and admin operations",
180
221
"navDidDocument": "DID Document",
181
222
"navDidDocumentDesc": "Manage your DID document for external migrations",
223
+
"navDidDocumentDescActive": "Edit your DID document settings",
224
+
"navBackup": "Download Backup",
225
+
"navBackupDesc": "Download your repository as a CAR file",
226
+
"downloadingBackup": "Downloading...",
227
+
"backupFailed": "Failed to download backup",
182
228
"migrated": "Migrated",
183
229
"migratedTitle": "Account Migrated",
184
230
"migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.",
···
208
254
"serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.",
209
255
"currentPds": "Current PDS URL",
210
256
"save": "Save Changes",
211
-
"saving": "Saving...",
212
257
"success": "DID document updated successfully",
213
258
"saveFailed": "Failed to save DID document",
214
259
"loadFailed": "Failed to load DID document",
···
246
291
"yourDomain": "Your Domain",
247
292
"yourDomainPlaceholder": "example.com",
248
293
"verifyAndUpdate": "Verify & Update Handle",
249
-
"verifying": "Verifying...",
250
294
"newHandle": "New Handle",
251
295
"newHandlePlaceholder": "yourhandle",
252
296
"changeHandleButton": "Change Handle",
···
262
306
"exportData": "Export Data",
263
307
"exportDataDescription": "Download your entire repository as a CAR (Content Addressable Archive) file. This includes all your posts, likes, follows, and other data.",
264
308
"downloadRepo": "Download Repository",
309
+
"downloadBlobs": "Download Media",
265
310
"exporting": "Exporting...",
311
+
"backups": {
312
+
"title": "Backups",
313
+
"description": "Your repository is automatically backed up daily. You can also create manual backups or restore from a previous backup.",
314
+
"enableAutomatic": "Enable automatic backups",
315
+
"enabled": "Automatic backups enabled",
316
+
"disabled": "Automatic backups disabled",
317
+
"toggleFailed": "Failed to update backup setting",
318
+
"noBackups": "No backups available yet.",
319
+
"blocks": "blocks",
320
+
"download": "Download",
321
+
"delete": "Delete",
322
+
"createNow": "Create Backup Now",
323
+
"created": "Backup created successfully",
324
+
"createFailed": "Failed to create backup",
325
+
"downloadFailed": "Failed to download backup",
326
+
"deleted": "Backup deleted",
327
+
"deleteFailed": "Failed to delete backup",
328
+
"restoreTitle": "Restore from Backup",
329
+
"restoreDescription": "Upload a CAR file to restore your repository. This will overwrite your current data.",
330
+
"selectFile": "Select CAR file",
331
+
"selectedFile": "Selected file",
332
+
"restore": "Restore",
333
+
"restoring": "Restoring...",
334
+
"restored": "Repository restored successfully",
335
+
"restoreFailed": "Failed to restore repository"
336
+
},
266
337
"deleteAccount": "Delete Account",
267
338
"deleteWarning": "This action is irreversible. All your data will be permanently deleted.",
268
339
"requestDeletion": "Request Account Deletion",
···
291
362
"deleteConfirmation": "Are you absolutely sure you want to delete your account? This cannot be undone.",
292
363
"deletionFailed": "Failed to delete account",
293
364
"repoExported": "Repository exported successfully",
294
-
"exportFailed": "Failed to export repository",
365
+
"blobsExported": "Media files exported successfully",
366
+
"noBlobsToExport": "No media files to export",
367
+
"exportFailed": "Failed to export",
295
368
"confirmDelete": "Are you absolutely sure you want to delete your account? This cannot be undone."
296
369
}
297
370
},
···
306
379
"noPasswords": "No app passwords yet",
307
380
"revoke": "Revoke",
308
381
"revoking": "Revoking...",
309
-
"creating": "Creating...",
310
382
"revokeConfirm": "Revoke app password \"{name}\"? Apps using this password will no longer be able to access your account.",
311
383
"saveWarningTitle": "Important: Save this app password!",
312
384
"saveWarningMessage": "This password is required to sign into apps that don't support passkeys or OAuth. You will only see it once.",
···
354
426
"used": "Used by @{handle}",
355
427
"disabled": "Disabled",
356
428
"usedBy": "Used by",
357
-
"creating": "Creating...",
358
429
"disableConfirm": "Disable this invite code? It can no longer be used.",
359
430
"created": "Invite Code Created",
360
431
"copy": "Copy",
···
482
553
"verifyButton": "Verify",
483
554
"verifyCodePlaceholder": "Enter verification code",
484
555
"submit": "Submit",
485
-
"saving": "Saving...",
486
556
"savePreferences": "Save Preferences",
487
557
"preferencesSaved": "Communication preferences saved",
488
558
"verifiedSuccess": "{channel} verified successfully",
···
521
591
"noCollectionsYet": "No collections yet. Create your first record to get started.",
522
592
"loadMore": "Load More",
523
593
"recordJson": "Record JSON",
524
-
"saving": "Saving...",
525
594
"updateRecord": "Update Record",
526
595
"collectionNsid": "Collection (NSID)",
527
596
"recordKeyOptional": "Record Key (optional)",
528
597
"autoGenerated": "Auto-generated if empty (TID)",
529
598
"autoGeneratedHint": "Leave empty to auto-generate a TID-based key",
530
-
"creating": "Creating...",
531
599
"demoPostText": "Hello from my PDS! This is my first post.",
532
600
"demoDisplayName": "Your Display Name",
533
601
"demoBio": "A short bio about yourself."
···
551
619
"secondaryLight": "Secondary (Light Mode)",
552
620
"secondaryDark": "Secondary (Dark Mode)",
553
621
"configSaved": "Server configuration saved",
554
-
"saving": "Saving...",
555
622
"saveConfig": "Save Configuration",
556
623
"serverStats": "Server Statistics",
557
624
"users": "Users",
···
639
706
"title": "Two-Factor Authentication",
640
707
"subtitle": "Additional verification is required",
641
708
"usePasskey": "Use Passkey",
642
-
"useTotp": "Use Authenticator App",
643
-
"verifying": "Verifying..."
709
+
"useTotp": "Use Authenticator App"
644
710
},
645
711
"twoFactorCode": {
646
712
"title": "Two-Factor Authentication",
647
713
"subtitle": "A verification code has been sent to your {channel}. Enter the code below to continue.",
648
714
"codeLabel": "Verification Code",
649
715
"codePlaceholder": "Enter 6-digit code",
650
-
"verify": "Verify",
651
-
"verifying": "Verifying...",
652
716
"errors": {
653
717
"missingRequestUri": "Missing request_uri parameter",
654
718
"verificationFailed": "Verification failed",
···
660
724
"title": "Enter Authenticator Code",
661
725
"subtitle": "Enter the 6-digit code from your authenticator app",
662
726
"codePlaceholder": "Enter 6-digit code",
663
-
"verify": "Verify",
664
-
"verifying": "Verifying...",
665
727
"useBackupCode": "Use backup code instead",
666
728
"backupCodePlaceholder": "Enter backup code",
667
729
"trustDevice": "Trust this device for 30 days",
···
691
753
"codeLabel": "Verification Code",
692
754
"codeHelp": "Copy the entire code from your message, including dashes",
693
755
"verifyButton": "Verify Account",
694
-
"verify": "Verify",
695
-
"verifying": "Verifying...",
696
756
"pleaseWait": "Please wait...",
697
-
"resendCode": "Resend Code",
698
-
"resending": "Resending...",
699
-
"sending": "Sending...",
700
757
"codeResent": "Verification code resent!",
701
758
"codeResentDetail": "Verification code sent! Check your inbox.",
702
-
"backToLogin": "Back to Login",
703
-
"backToSettings": "Back to Settings",
704
759
"verifyingAccount": "Verifying account: @{handle}",
705
760
"startOver": "Start over with a different account",
706
761
"noPending": "No pending verification found.",
···
746
801
"resetButton": "Reset Password",
747
802
"resetting": "Resetting...",
748
803
"success": "Password reset successfully!",
749
-
"backToLogin": "Back to Sign In",
750
804
"requestNewCode": "Request New Code",
751
805
"passwordsMismatch": "Passwords do not match",
752
806
"passwordLength": "Password must be at least 8 characters"
···
790
844
"howItWorks": "How it works",
791
845
"howItWorksDetail": "We'll send a secure link to your registered notification channel. Click the link to set a temporary password. Then you can sign in and add a new passkey.",
792
846
"sendRecoveryLink": "Send Recovery Link",
793
-
"sending": "Sending...",
794
-
"backToLogin": "Back to Sign In"
847
+
"sending": "Sending..."
795
848
},
796
849
"registerPasskey": {
797
850
"title": "Create Passkey Account",
···
814
867
"inviteCode": "Invite Code",
815
868
"inviteCodePlaceholder": "Enter your invite code",
816
869
"createButton": "Create Account",
817
-
"creating": "Creating...",
818
870
"continue": "Continue",
819
871
"back": "Back",
820
872
"alreadyHaveAccount": "Already have an account?",
···
911
963
"useTotp": "Use Authenticator",
912
964
"passwordPlaceholder": "Enter your password",
913
965
"totpPlaceholder": "Enter 6-digit code",
914
-
"verify": "Verify",
915
-
"verifying": "Verifying...",
916
966
"authenticating": "Authenticating...",
917
967
"passkeyPrompt": "Click the button below to authenticate with your passkey.",
918
968
"cancel": "Cancel"
···
947
997
"handle": "Handle",
948
998
"emailOptional": "Email (optional)",
949
999
"yourAccessLevel": "Your Access Level",
950
-
"creating": "Creating...",
951
1000
"createAccount": "Create Account",
952
1001
"createDelegatedAccountButton": "+ Create Delegated Account",
953
1002
"accountCreated": "Created delegated account: {handle}",
···
1059
1108
"navDesc": "Move your account to or from another PDS",
1060
1109
"migrateHere": "Migrate Here",
1061
1110
"migrateHereDesc": "Move your existing AT Protocol account to this PDS from another server.",
1062
-
"migrateAway": "Migrate Away",
1063
-
"migrateAwayDesc": "Move your account from this PDS to another server.",
1064
-
"loginRequired": "Login required",
1065
1111
"bringDid": "Bring your DID and identity",
1066
1112
"transferData": "Transfer all your data",
1067
1113
"keepFollowers": "Keep your followers",
1068
-
"exportRepo": "Export your repository",
1069
-
"transferToPds": "Transfer to new PDS",
1070
-
"updateIdentity": "Update your identity",
1071
1114
"whatIsMigration": "What is account migration?",
1072
1115
"whatIsMigrationDesc": "Account migration allows you to move your AT Protocol identity between Personal Data Servers (PDSes). Your DID (decentralized identifier) stays the same, so your followers and social connections are preserved.",
1073
1116
"beforeMigrate": "Before you migrate",
···
1077
1120
"beforeMigrate4": "Your old PDS will be notified to deactivate your account",
1078
1121
"importantWarning": "Account migration is a significant action. Make sure you trust the destination PDS and understand that your data will be moved. If something goes wrong, recovery may require manual intervention.",
1079
1122
"learnMore": "Learn more about migration risks",
1080
-
"comingSoon": "Coming soon",
1123
+
"offlineRestore": "Offline Restore",
1124
+
"offlineRestoreDesc": "Restore from backup when your old PDS is unavailable.",
1125
+
"offlineFeature1": "Use a CAR file backup",
1126
+
"offlineFeature2": "Prove ownership with rotation key",
1127
+
"offlineFeature3": "Recovery for shutdown servers",
1081
1128
"oauthCompleting": "Completing authentication...",
1082
1129
"oauthFailed": "Authentication Failed",
1083
1130
"tryAgain": "Try Again",
···
1086
1133
"incomplete": "You have an incomplete migration in progress:",
1087
1134
"direction": "Direction",
1088
1135
"migratingHere": "Migrating here",
1089
-
"migratingAway": "Migrating away",
1090
1136
"from": "From",
1091
1137
"to": "To",
1092
1138
"progress": "Progress",
···
1229
1275
"error": {
1230
1276
"title": "Migration Error",
1231
1277
"desc": "An error occurred during migration.",
1232
-
"startOver": "Start Over"
1278
+
"startOver": "Start Over",
1279
+
"unknown": "An unknown error occurred."
1233
1280
},
1234
1281
"common": {
1235
1282
"back": "Back",
···
1247
1294
"warning3": "Your old account will be deactivated after migration"
1248
1295
}
1249
1296
},
1250
-
"outbound": {
1297
+
"offline": {
1251
1298
"welcome": {
1252
-
"title": "Migrate Away from This PDS",
1253
-
"desc": "Move your account to another Personal Data Server.",
1254
-
"warning": "After migration, your account here will be deactivated.",
1255
-
"didWebNotice": "did:web Migration Notice",
1256
-
"didWebNoticeDesc": "Your account uses a did:web identifier ({did}). After migrating, this PDS will continue to serve your DID document pointing to the new PDS. Your identity will remain functional as long as this server is online.",
1257
-
"understand": "I understand the risks and want to proceed"
1299
+
"title": "Offline Restore",
1300
+
"desc": "Restore your account when your old PDS is unavailable. This is for disaster recovery when you cannot contact your previous server.",
1301
+
"warningTitle": "Advanced Recovery Method",
1302
+
"warningDesc": "This method requires your rotation key private key. Only use this if your previous PDS has shut down or you cannot access it.",
1303
+
"requirementsTitle": "You will need:",
1304
+
"requirement1": "Your DID (did:plc:...)",
1305
+
"requirement2": "A CAR file backup of your repository",
1306
+
"requirement3": "Your rotation key (private key in hex, base58, or JWK format)",
1307
+
"understand": "I understand this is for offline recovery only"
1258
1308
},
1259
-
"targetPds": {
1260
-
"title": "Choose Target PDS",
1261
-
"desc": "Enter the URL of the PDS you want to migrate to.",
1262
-
"url": "PDS URL",
1263
-
"urlPlaceholder": "https://pds.example.com",
1264
-
"validate": "Validate & Continue",
1309
+
"provideDid": {
1310
+
"title": "Enter Your DID",
1311
+
"desc": "Enter the DID of the account you want to restore.",
1312
+
"label": "Your DID",
1313
+
"hint": "Your decentralized identifier (e.g., did:plc:abc123...)"
1314
+
},
1315
+
"uploadCar": {
1316
+
"title": "Upload Repository Backup",
1317
+
"desc": "Upload the CAR file containing your repository data.",
1318
+
"label": "CAR File",
1319
+
"hint": "This should be a .car file from a previous backup of your repository",
1320
+
"reuploadWarningTitle": "CAR File Required",
1321
+
"reuploadWarning": "Your session was restored, but you need to re-upload your CAR file. For security reasons, file contents are not stored between sessions."
1322
+
},
1323
+
"rotationKey": {
1324
+
"title": "Provide Rotation Key",
1325
+
"desc": "Enter your rotation key to prove ownership of this DID.",
1326
+
"securityWarningTitle": "Security Warning",
1327
+
"securityWarning1": "Your rotation key is extremely sensitive - anyone with it can take over your identity",
1328
+
"securityWarning2": "Only enter it on trusted devices and connections",
1329
+
"securityWarning3": "The key will not be stored after migration",
1330
+
"label": "Rotation Key",
1331
+
"placeholder": "Paste your rotation key (hex, base58, or JWK)...",
1332
+
"hint": "Supports 64-character hex, base58, or JWK format",
1333
+
"valid": "Rotation key verified! You have control of this DID.",
1334
+
"invalid": "This key is not a valid rotation key for this DID.",
1265
1335
"validating": "Validating...",
1266
-
"connected": "Connected to {name}",
1267
-
"inviteRequired": "Invite code required",
1268
-
"privacyPolicy": "Privacy Policy",
1269
-
"termsOfService": "Terms of Service"
1336
+
"validate": "Validate Key"
1270
1337
},
1271
-
"newAccount": {
1272
-
"title": "New Account Details",
1273
-
"desc": "Set up your account on the new PDS.",
1274
-
"handle": "Handle",
1275
-
"availableDomains": "Available domains",
1276
-
"email": "Email",
1277
-
"password": "Password",
1278
-
"confirmPassword": "Confirm Password",
1279
-
"inviteCode": "Invite Code"
1338
+
"chooseHandle": {
1339
+
"migratingDid": "Restoring DID"
1280
1340
},
1281
1341
"review": {
1282
-
"title": "Review Migration",
1283
-
"desc": "Please review and confirm your migration details.",
1284
-
"currentHandle": "Current Handle",
1285
-
"newHandle": "New Handle",
1286
-
"sourcePds": "This PDS",
1287
-
"targetPds": "Target PDS",
1288
-
"confirm": "I confirm I want to migrate my account",
1289
-
"startMigration": "Start Migration"
1342
+
"desc": "Please confirm the details of your offline restoration.",
1343
+
"carFile": "CAR File",
1344
+
"rotationKey": "Rotation Key",
1345
+
"warning": "After you click \"Start Migration\", your repository will be imported and your DID will be updated to point to this PDS.",
1346
+
"plcWarningTitle": "Point of No Return",
1347
+
"plcWarning": "Once you start, your DID document will be updated to point to this PDS. If something goes wrong, you can use your rotation key to recover, but you should complete the migration to avoid a broken identity state."
1290
1348
},
1291
1349
"migrating": {
1292
-
"title": "Migrating Your Account",
1293
-
"desc": "Please wait while we transfer your data..."
1294
-
},
1295
-
"plcToken": {
1296
-
"title": "Verify Your Identity",
1297
-
"desc": "A verification code has been sent to your email."
1350
+
"title": "Restoring Account",
1351
+
"desc": "Please wait while your account is being restored...",
1352
+
"creating": "Creating account",
1353
+
"importing": "Importing repository",
1354
+
"plcSigning": "Signing identity update",
1355
+
"activating": "Activating account"
1298
1356
},
1299
-
"finalizing": {
1300
-
"title": "Finalizing Migration",
1301
-
"desc": "Please wait while we complete the migration...",
1302
-
"updatingForwarding": "Updating DID document forwarding..."
1357
+
"blobs": {
1358
+
"title": "Migrating Blobs",
1359
+
"desc": "Attempting to recover images and media from your old PDS...",
1360
+
"migrating": "Migrating blobs",
1361
+
"failedTitle": "Some blobs could not be migrated",
1362
+
"failedDesc": "{count} blobs could not be fetched from your old PDS. This may happen if the server is unreachable or the files were deleted.",
1363
+
"sourceUnreachableTitle": "Source PDS Unreachable",
1364
+
"sourceUnreachable": "Could not connect to your old PDS to fetch media files. This is common when migrating from a shut-down server. Your posts will work, but some images may be missing."
1303
1365
},
1304
1366
"success": {
1305
-
"title": "Migration Complete!",
1306
-
"desc": "Your account has been successfully migrated to your new PDS.",
1307
-
"newHandle": "New Handle",
1308
-
"newPds": "New PDS",
1309
-
"nextSteps": "Next Steps",
1310
-
"nextSteps1": "Sign in to your new PDS",
1311
-
"nextSteps2": "Update any apps with your new credentials",
1312
-
"nextSteps3": "Your followers will automatically see your new location",
1313
-
"loggingOut": "Logging you out in {seconds} seconds..."
1367
+
"desc": "Your account has been successfully restored to this PDS."
1314
1368
}
1315
1369
},
1316
1370
"progress": {
+154
-100
frontend/src/locales/fi.json
+154
-100
frontend/src/locales/fi.json
···
17
17
"dashboard": "Hallintapaneeli",
18
18
"backToDashboard": "← Hallintapaneeli",
19
19
"copied": "Kopioitu!",
20
-
"copyToClipboard": "Kopioi"
20
+
"copyToClipboard": "Kopioi",
21
+
22
+
"verifying": "Vahvistetaan...",
23
+
"saving": "Tallennetaan...",
24
+
"creating": "Luodaan...",
25
+
"updating": "Päivitetään...",
26
+
"sending": "Lähetetään...",
27
+
"authenticating": "Todennetaan...",
28
+
"checking": "Tarkistetaan...",
29
+
"redirecting": "Ohjataan...",
30
+
31
+
"signIn": "Kirjaudu sisään",
32
+
"verify": "Vahvista",
33
+
"remove": "Poista",
34
+
"revoke": "Peruuta",
35
+
"resendCode": "Lähetä koodi uudelleen",
36
+
"startOver": "Aloita alusta",
37
+
"tryAgain": "Yritä uudelleen",
38
+
39
+
"password": "Salasana",
40
+
"email": "Sähköposti",
41
+
"emailAddress": "Sähköpostiosoite",
42
+
"handle": "Käsittely",
43
+
"did": "DID",
44
+
"verificationCode": "Vahvistuskoodi",
45
+
"inviteCode": "Kutsukoodi",
46
+
"newPassword": "Uusi salasana",
47
+
"confirmPassword": "Vahvista salasana",
48
+
49
+
"enterSixDigitCode": "Syötä 6-numeroinen koodi",
50
+
"passwordHint": "Vähintään 8 merkkiä",
51
+
"enterPassword": "Syötä salasanasi",
52
+
"emailPlaceholder": "sinä@esimerkki.com",
53
+
54
+
"verified": "Vahvistettu",
55
+
"disabled": "Poistettu käytöstä",
56
+
"available": "Saatavilla",
57
+
"deactivated": "Deaktivoitu",
58
+
"unverified": "Vahvistamaton",
59
+
60
+
"backToLogin": "Takaisin kirjautumiseen",
61
+
"backToSettings": "Takaisin asetuksiin",
62
+
"alreadyHaveAccount": "Onko sinulla jo tili?",
63
+
"createAccount": "Luo tili",
64
+
65
+
"passwordsMismatch": "Salasanat eivät täsmää",
66
+
"passwordTooShort": "Salasanan on oltava vähintään 8 merkkiä"
21
67
},
22
68
"login": {
23
69
"title": "Kirjaudu sisään",
···
49
95
"codeLabel": "Vahvistuskoodi",
50
96
"codePlaceholder": "Syötä 6-numeroinen koodi",
51
97
"verifyButton": "Vahvista tili",
52
-
"verifying": "Vahvistetaan...",
53
-
"resendButton": "Lähetä koodi uudelleen",
54
-
"resending": "Lähetetään uudelleen...",
55
-
"resent": "Vahvistuskoodi lähetetty uudelleen!",
56
-
"backToLogin": "Takaisin kirjautumiseen"
98
+
"resent": "Vahvistuskoodi lähetetty uudelleen!"
57
99
},
58
100
"register": {
59
101
"title": "Luo tili",
···
124
166
"inviteCodePlaceholder": "Syötä kutsukoodisi",
125
167
"inviteCodeRequired": "vaaditaan",
126
168
"createButton": "Luo tili",
127
-
"creating": "Luodaan tiliä...",
128
169
"alreadyHaveAccount": "Onko sinulla jo tili?",
129
170
"signIn": "Kirjaudu sisään",
130
171
"wantPasswordless": "Haluatko salasanattoman turvallisuuden?",
···
179
220
"navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot",
180
221
"navDidDocument": "DID-dokumentti",
181
222
"navDidDocumentDesc": "Hallitse DID-dokumenttiasi ulkoisia siirtoja varten",
223
+
"navDidDocumentDescActive": "Muokkaa DID-dokumentin asetuksia",
224
+
"navBackup": "Lataa varmuuskopio",
225
+
"navBackupDesc": "Lataa tietovarastosi CAR-tiedostona",
226
+
"downloadingBackup": "Ladataan...",
227
+
"backupFailed": "Varmuuskopion lataus epäonnistui",
182
228
"migrated": "Siirretty",
183
229
"migratedTitle": "Tili siirretty",
184
230
"migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isännöidään edelleen täällä, ja voit päivittää sen tulevia siirtoja varten.",
···
208
254
"serviceEndpointDesc": "PDS, joka tällä hetkellä isännöi tilitietojasi. Päivitä tämä siirron yhteydessä.",
209
255
"currentPds": "Nykyinen PDS-URL",
210
256
"save": "Tallenna muutokset",
211
-
"saving": "Tallennetaan...",
212
257
"success": "DID-dokumentti päivitetty onnistuneesti",
213
258
"saveFailed": "DID-dokumentin tallennus epäonnistui",
214
259
"loadFailed": "DID-dokumentin lataus epäonnistui",
···
246
291
"yourDomain": "Verkkotunnuksesi",
247
292
"yourDomainPlaceholder": "esimerkki.fi",
248
293
"verifyAndUpdate": "Vahvista ja päivitä käyttäjänimi",
249
-
"verifying": "Vahvistetaan...",
250
294
"newHandle": "Uusi käyttäjänimi",
251
295
"newHandlePlaceholder": "käyttäjänimesi",
252
296
"changeHandleButton": "Vaihda käyttäjänimi",
···
262
306
"exportData": "Vie tiedot",
263
307
"exportDataDescription": "Lataa koko tietovarastosi CAR-tiedostona (Content Addressable Archive). Tämä sisältää kaikki julkaisusi, tykkäyksesi, seuraamisesi ja muut tiedot.",
264
308
"downloadRepo": "Lataa tietovarasto",
309
+
"downloadBlobs": "Lataa media",
265
310
"exporting": "Viedään...",
311
+
"backups": {
312
+
"title": "Varmuuskopiot",
313
+
"description": "Tietovarastosi varmuuskopioidaan automaattisesti päivittäin. Voit myös luoda manuaalisia varmuuskopioita tai palauttaa aiemmasta varmuuskopiosta.",
314
+
"enableAutomatic": "Ota automaattiset varmuuskopiot käyttöön",
315
+
"enabled": "Automaattiset varmuuskopiot käytössä",
316
+
"disabled": "Automaattiset varmuuskopiot pois käytöstä",
317
+
"toggleFailed": "Varmuuskopioasetuksen päivitys epäonnistui",
318
+
"noBackups": "Varmuuskopioita ei ole vielä saatavilla.",
319
+
"blocks": "lohkoa",
320
+
"download": "Lataa",
321
+
"delete": "Poista",
322
+
"createNow": "Luo varmuuskopio nyt",
323
+
"created": "Varmuuskopio luotu onnistuneesti",
324
+
"createFailed": "Varmuuskopion luonti epäonnistui",
325
+
"downloadFailed": "Varmuuskopion lataus epäonnistui",
326
+
"deleted": "Varmuuskopio poistettu",
327
+
"deleteFailed": "Varmuuskopion poisto epäonnistui",
328
+
"restoreTitle": "Palauta varmuuskopiosta",
329
+
"restoreDescription": "Lataa CAR-tiedosto palauttaaksesi tietovarastosi. Tämä korvaa nykyiset tietosi.",
330
+
"selectFile": "Valitse CAR-tiedosto",
331
+
"selectedFile": "Valittu tiedosto",
332
+
"restore": "Palauta",
333
+
"restoring": "Palautetaan...",
334
+
"restored": "Tietovarasto palautettu onnistuneesti",
335
+
"restoreFailed": "Tietovaraston palautus epäonnistui"
336
+
},
266
337
"deleteAccount": "Poista tili",
267
338
"deleteWarning": "Tämä toiminto on peruuttamaton. Kaikki tietosi poistetaan pysyvästi.",
268
339
"requestDeletion": "Pyydä tilin poistoa",
···
291
362
"deleteConfirmation": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua.",
292
363
"deletionFailed": "Tilin poisto epäonnistui",
293
364
"repoExported": "Tietovarasto viety",
294
-
"exportFailed": "Tietovaraston vienti epäonnistui",
365
+
"blobsExported": "Mediatiedostot viety",
366
+
"noBlobsToExport": "Ei vietäviä mediatiedostoja",
367
+
"exportFailed": "Vienti epäonnistui",
295
368
"confirmDelete": "Oletko täysin varma, että haluat poistaa tilisi? Tätä ei voi perua."
296
369
}
297
370
},
···
306
379
"noPasswords": "Ei vielä sovellusten salasanoja",
307
380
"revoke": "Peruuta",
308
381
"revoking": "Peruutetaan...",
309
-
"creating": "Luodaan...",
310
382
"revokeConfirm": "Peruuta sovelluksen salasana \"{name}\"? Sovellukset, jotka käyttävät tätä salasanaa, eivät enää pääse tilillesi.",
311
383
"saveWarningTitle": "Tärkeää: Tallenna tämä sovelluksen salasana!",
312
384
"saveWarningMessage": "Tämä salasana tarvitaan kirjautumiseen sovelluksiin, jotka eivät tue pääsyavaimia tai OAuthia. Näet sen vain kerran.",
···
354
426
"used": "Käyttänyt @{handle}",
355
427
"disabled": "Poistettu käytöstä",
356
428
"usedBy": "Käyttänyt",
357
-
"creating": "Luodaan...",
358
429
"disableConfirm": "Poista tämä kutsukoodi käytöstä? Sitä ei voi enää käyttää.",
359
430
"created": "Kutsukoodi luotu",
360
431
"copy": "Kopioi",
···
482
553
"verifyButton": "Vahvista",
483
554
"verifyCodePlaceholder": "Syötä vahvistuskoodi",
484
555
"submit": "Lähetä",
485
-
"saving": "Tallennetaan...",
486
556
"savePreferences": "Tallenna asetukset",
487
557
"preferencesSaved": "Viestintäasetukset tallennettu",
488
558
"verifiedSuccess": "{channel} vahvistettu",
···
521
591
"noCollectionsYet": "Ei vielä kokoelmia. Luo ensimmäinen tietueesi aloittaaksesi.",
522
592
"loadMore": "Lataa lisää",
523
593
"recordJson": "Tietueen JSON",
524
-
"saving": "Tallennetaan...",
525
594
"updateRecord": "Päivitä tietue",
526
595
"collectionNsid": "Kokoelma (NSID)",
527
596
"recordKeyOptional": "Tietueavain (valinnainen)",
528
597
"autoGenerated": "Luodaan automaattisesti jos tyhjä (TID)",
529
598
"autoGeneratedHint": "Jätä tyhjäksi luodaksesi TID-pohjaisen avaimen automaattisesti",
530
-
"creating": "Luodaan...",
531
599
"demoPostText": "Hei PDS:ltäni! Tämä on ensimmäinen julkaisuni.",
532
600
"demoDisplayName": "Näyttönimesi",
533
601
"demoBio": "Lyhyt kuvaus itsestäsi."
···
548
616
"primaryLight": "Ensisijainen (vaalea tila)",
549
617
"primaryDark": "Ensisijainen (tumma tila)",
550
618
"configSaved": "Palvelinasetukset tallennettu",
551
-
"saving": "Tallennetaan...",
552
619
"saveConfig": "Tallenna asetukset",
553
620
"serverStats": "Palvelintilastot",
554
621
"users": "Käyttäjät",
···
639
706
"title": "Kaksivaiheinen tunnistautuminen",
640
707
"subtitle": "Lisävahvistus vaaditaan",
641
708
"usePasskey": "Käytä pääsyavainta",
642
-
"useTotp": "Käytä todentajasovellusta",
643
-
"verifying": "Vahvistetaan..."
709
+
"useTotp": "Käytä todentajasovellusta"
644
710
},
645
711
"twoFactorCode": {
646
712
"title": "Kaksivaiheinen tunnistautuminen",
647
713
"subtitle": "Vahvistuskoodi on lähetetty {channel}. Syötä koodi alla jatkaaksesi.",
648
714
"codeLabel": "Vahvistuskoodi",
649
715
"codePlaceholder": "Syötä 6-numeroinen koodi",
650
-
"verify": "Vahvista",
651
-
"verifying": "Vahvistetaan...",
652
716
"errors": {
653
717
"missingRequestUri": "Puuttuva request_uri-parametri",
654
718
"verificationFailed": "Vahvistus epäonnistui",
···
660
724
"title": "Syötä todentajakoodi",
661
725
"subtitle": "Syötä 6-numeroinen koodi todentajasovelluksestasi",
662
726
"codePlaceholder": "Syötä 6-numeroinen koodi",
663
-
"verify": "Vahvista",
664
-
"verifying": "Vahvistetaan...",
665
727
"useBackupCode": "Käytä varakoodia sen sijaan",
666
728
"backupCodePlaceholder": "Syötä varakoodi",
667
729
"trustDevice": "Luota tähän laitteeseen 30 päivää",
···
691
753
"codeLabel": "Vahvistuskoodi",
692
754
"codeHelp": "Kopioi koko koodi viestistäsi, mukaan lukien väliviivat",
693
755
"verifyButton": "Vahvista tili",
694
-
"verify": "Vahvista",
695
-
"verifying": "Vahvistetaan...",
696
756
"pleaseWait": "Odota...",
697
-
"sending": "Lähetetään...",
698
-
"resendCode": "Lähetä koodi uudelleen",
699
-
"resending": "Lähetetään uudelleen...",
700
757
"codeResent": "Vahvistuskoodi lähetetty uudelleen!",
701
758
"codeResentDetail": "Vahvistuskoodi lähetetty! Tarkista saapuneet-kansiosi.",
702
759
"verified": "Vahvistettu!",
···
706
763
"identifierLabel": "Sähköposti tai tunniste",
707
764
"identifierPlaceholder": "sinä@esimerkki.fi",
708
765
"identifierHelp": "Sähköpostiosoite tai tunniste, johon koodi lähetettiin",
709
-
"backToLogin": "Takaisin kirjautumiseen",
710
766
"verifyingAccount": "Vahvistetaan tiliä: @{handle}",
711
767
"startOver": "Aloita alusta toisella tilillä",
712
768
"noPending": "Odottavaa vahvistusta ei löytynyt.",
713
769
"noPendingInfo": "Jos loit tilin äskettäin ja sinun on vahvistettava se, sinun on ehkä luotava uusi tili. Jos olet jo vahvistanut tilisi, voit kirjautua sisään.",
714
770
"createAccount": "Luo tili",
715
771
"signIn": "Kirjaudu sisään",
716
-
"backToSettings": "Takaisin asetuksiin",
717
772
"emailUpdateCodeHelp": "Koodi lähetettiin nykyiseen sähköpostiosoitteeseesi",
718
773
"emailUpdateFailed": "Sähköpostiosoitteen päivitys epäonnistui",
719
774
"emailUpdateRequiresAuth": "Sinun on kirjauduttava sisään päivittääksesi sähköpostiosoitteesi.",
···
746
801
"resetButton": "Palauta salasana",
747
802
"resetting": "Palautetaan...",
748
803
"success": "Salasana palautettu!",
749
-
"backToLogin": "Takaisin kirjautumiseen",
750
804
"requestNewCode": "Pyydä uusi koodi",
751
805
"passwordsMismatch": "Salasanat eivät täsmää",
752
806
"passwordLength": "Salasanan on oltava vähintään 8 merkkiä"
···
790
844
"howItWorks": "Miten se toimii",
791
845
"howItWorksDetail": "Lähetämme suojatun linkin rekisteröityyn ilmoituskanavaasi. Klikkaa linkkiä asettaaksesi väliaikaisen salasanan. Sitten voit kirjautua sisään ja lisätä uuden pääsyavaimen.",
792
846
"sendRecoveryLink": "Lähetä palautuslinkki",
793
-
"sending": "Lähetetään...",
794
-
"backToLogin": "Takaisin kirjautumiseen"
847
+
"sending": "Lähetetään..."
795
848
},
796
849
"registerPasskey": {
797
850
"title": "Luo pääsyavaintili",
···
812
865
"externalDid": "Sinun did:web",
813
866
"externalDidPlaceholder": "did:web:verkkotunnuksesi.fi",
814
867
"createButton": "Luo tili",
815
-
"creating": "Luodaan...",
816
868
"alreadyHaveAccount": "Onko sinulla jo tili?",
817
869
"signIn": "Kirjaudu sisään",
818
870
"wantPassword": "Haluatko käyttää salasanaa?",
···
911
963
"useTotp": "Käytä todentajaa",
912
964
"passwordPlaceholder": "Syötä salasanasi",
913
965
"totpPlaceholder": "Syötä 6-numeroinen koodi",
914
-
"verify": "Vahvista",
915
-
"verifying": "Vahvistetaan...",
916
966
"authenticating": "Todennetaan...",
917
967
"passkeyPrompt": "Klikkaa alla olevaa painiketta todentaaksesi pääsyavaimellasi.",
918
968
"cancel": "Peruuta"
···
967
1017
"handle": "Käyttäjänimi",
968
1018
"emailOptional": "Sähköposti (valinnainen)",
969
1019
"yourAccessLevel": "Käyttöoikeustasosi",
970
-
"creating": "Luodaan...",
971
1020
"createAccount": "Luo tili",
972
1021
"createDelegatedAccountButton": "+ Luo delegoitu tili",
973
1022
"accountCreated": "Delegoitu tili luotu: {handle}",
···
1059
1108
"navDesc": "Siirrä tilisi toiseen tai toisesta PDS:stä",
1060
1109
"migrateHere": "Siirrä tänne",
1061
1110
"migrateHereDesc": "Siirrä olemassa oleva AT Protocol -tilisi tähän PDS:ään toiselta palvelimelta.",
1062
-
"migrateAway": "Siirrä pois",
1063
-
"migrateAwayDesc": "Siirrä tilisi tästä PDS:stä toiselle palvelimelle.",
1064
-
"loginRequired": "Kirjautuminen vaaditaan",
1065
1111
"bringDid": "Tuo DID ja identiteettisi",
1066
1112
"transferData": "Siirrä kaikki tietosi",
1067
1113
"keepFollowers": "Säilytä seuraajasi",
1068
-
"exportRepo": "Vie tietovarastosi",
1069
-
"transferToPds": "Siirrä uuteen PDS:ään",
1070
-
"updateIdentity": "Päivitä identiteettisi",
1071
1114
"whatIsMigration": "Mikä on tilin siirto?",
1072
1115
"whatIsMigrationDesc": "Tilin siirto mahdollistaa AT Protocol -identiteettisi siirtämisen henkilökohtaisten datapalvelimien (PDS) välillä. DID (hajautettu tunniste) pysyy samana, joten seuraajasi ja sosiaaliset yhteytesi säilyvät.",
1073
1116
"beforeMigrate": "Ennen siirtoa",
···
1077
1120
"beforeMigrate4": "Vanhalle PDS:llesi ilmoitetaan tilisi deaktivoinnista",
1078
1121
"importantWarning": "Tilin siirto on merkittävä toimenpide. Varmista, että luotat kohde-PDS:ään ja ymmärrät, että tietosi siirretään. Jos jokin menee pieleen, palautus voi vaatia manuaalista toimenpidettä.",
1079
1122
"learnMore": "Lue lisää siirron riskeistä",
1080
-
"comingSoon": "Tulossa pian",
1123
+
"offlineRestore": "Offline-palautus",
1124
+
"offlineRestoreDesc": "Palauta varmuuskopiosta, kun vanha PDS ei ole käytettävissä.",
1125
+
"offlineFeature1": "Käytä CAR-tiedoston varmuuskopiota",
1126
+
"offlineFeature2": "Todista omistajuus rotaatioavaimella",
1127
+
"offlineFeature3": "Palautus suljetuille palvelimille",
1081
1128
"oauthCompleting": "Viimeistellään todennusta...",
1082
1129
"oauthFailed": "Todennus epäonnistui",
1083
1130
"tryAgain": "Yritä uudelleen",
···
1086
1133
"incomplete": "Sinulla on keskeneräinen siirto:",
1087
1134
"direction": "Suunta",
1088
1135
"migratingHere": "Siirretään tänne",
1089
-
"migratingAway": "Siirretään pois",
1090
1136
"from": "Mistä",
1091
1137
"to": "Minne",
1092
1138
"progress": "Edistyminen",
···
1229
1275
"error": {
1230
1276
"title": "Siirtovirhe",
1231
1277
"desc": "Siirron aikana tapahtui virhe.",
1232
-
"startOver": "Aloita alusta"
1278
+
"startOver": "Aloita alusta",
1279
+
"unknown": "Tuntematon virhe tapahtui."
1233
1280
},
1234
1281
"common": {
1235
1282
"back": "Takaisin",
···
1247
1294
"warning3": "Vanha tilisi deaktivoidaan siirron jälkeen"
1248
1295
}
1249
1296
},
1250
-
"outbound": {
1297
+
"offline": {
1251
1298
"welcome": {
1252
-
"title": "Siirrä pois tästä PDS:stä",
1253
-
"desc": "Siirrä tilisi toiseen henkilökohtaiseen datapalvelimeen.",
1254
-
"warning": "Siirron jälkeen tilisi täällä deaktivoidaan.",
1255
-
"didWebNotice": "did:web-siirtoilmoitus",
1256
-
"didWebNoticeDesc": "Tilisi käyttää did:web-tunnistetta ({did}). Siirron jälkeen tämä PDS jatkaa DID-dokumenttisi tarjoamista osoittaen uuteen PDS:ään. Identiteettisi toimii niin kauan kuin tämä palvelin on päällä.",
1257
-
"understand": "Ymmärrän riskit ja haluan jatkaa"
1299
+
"title": "Palauta varmuuskopiosta",
1300
+
"desc": "Palauta tilisi CAR-tiedoston varmuuskopiolla ja rotaatioavaimella. Käytä tätä, kun edellinen PDS ei ole käytettävissä.",
1301
+
"warningTitle": "Milloin käyttää tätä menetelmää",
1302
+
"warningDesc": "Tämä offline-palautus on katastrofipalautukseen, kun vanha PDS on suljettu, tavoittamattomissa tai sinut on lukittu ulos. Jos vanha PDS on edelleen käytettävissä, käytä normaalia siirtoa.",
1303
+
"requirementsTitle": "Tarvitset",
1304
+
"requirement1": "CAR-tiedoston varmuuskopion tietovarastostasi",
1305
+
"requirement2": "Rotaatioavaimesi (DID:n yksityinen avain)",
1306
+
"requirement3": "DID:si (did:plc:xxx)",
1307
+
"understand": "Ymmärrän ja haluan jatkaa"
1258
1308
},
1259
-
"targetPds": {
1260
-
"title": "Valitse kohde-PDS",
1261
-
"desc": "Syötä sen PDS:n URL, johon haluat siirtyä.",
1262
-
"url": "PDS URL",
1263
-
"urlPlaceholder": "https://pds.example.com",
1264
-
"validate": "Vahvista ja jatka",
1265
-
"validating": "Vahvistetaan...",
1266
-
"connected": "Yhdistetty: {name}",
1267
-
"inviteRequired": "Kutsukoodi vaaditaan",
1268
-
"privacyPolicy": "Tietosuojakäytäntö",
1269
-
"termsOfService": "Käyttöehdot"
1309
+
"provideDid": {
1310
+
"title": "Syötä DID:si",
1311
+
"desc": "Syötä palautettavan tilin DID.",
1312
+
"label": "DID:si",
1313
+
"hint": "Hajautettu tunnistesi (esim. did:plc:abc123)"
1270
1314
},
1271
-
"newAccount": {
1272
-
"title": "Uuden tilin tiedot",
1273
-
"desc": "Määritä tilisi uudessa PDS:ssä.",
1274
-
"handle": "Käyttäjätunnus",
1275
-
"availableDomains": "Käytettävissä olevat verkkotunnukset",
1276
-
"email": "Sähköposti",
1277
-
"password": "Salasana",
1278
-
"confirmPassword": "Vahvista salasana",
1279
-
"inviteCode": "Kutsukoodi"
1315
+
"uploadCar": {
1316
+
"title": "Lataa CAR-tiedosto",
1317
+
"desc": "Lataa tietovaraston varmuuskopiotiedostosi.",
1318
+
"label": "CAR-tiedosto",
1319
+
"hint": "Valitse .car-tiedosto varmuuskopiostasi",
1320
+
"reuploadWarningTitle": "CAR-tiedosto vaaditaan",
1321
+
"reuploadWarning": "Istuntosi palautettiin, mutta sinun täytyy ladata CAR-tiedostosi uudelleen. Turvallisuussyistä tiedostosisältöä ei tallenneta istuntojen välillä."
1280
1322
},
1281
-
"review": {
1282
-
"title": "Tarkista siirto",
1283
-
"desc": "Tarkista ja vahvista siirtotietosi.",
1284
-
"currentHandle": "Nykyinen käyttäjätunnus",
1285
-
"newHandle": "Uusi käyttäjätunnus",
1286
-
"sourcePds": "Tämä PDS",
1287
-
"targetPds": "Kohde-PDS",
1288
-
"confirm": "Vahvistan haluavani siirtää tilini",
1289
-
"startMigration": "Aloita siirto"
1323
+
"rotationKey": {
1324
+
"title": "Anna rotaatioavain",
1325
+
"desc": "Anna rotaatioavaimesi todistaaksesi tämän DID:n omistajuuden.",
1326
+
"securityWarningTitle": "Turvallisuusvaroitus",
1327
+
"securityWarning1": "Rotaatioavaimesi on erittäin arkaluonteinen - kohtele sitä kuten pääsalasanaa",
1328
+
"securityWarning2": "Syötä se vain luotetuilla laitteilla ja verkoilla",
1329
+
"securityWarning3": "Tätä avainta ei tallenneta siirron jälkeen",
1330
+
"label": "Rotaatioavain",
1331
+
"placeholder": "Syötä yksityinen avain (hex, base58 tai JWK)",
1332
+
"hint": "Yksityinen avain, joka vastaa yhtä DID-dokumentin rotaatioavaimista",
1333
+
"valid": "Avain on kelvollinen ja vastaa DID:si rotaatioavainta",
1334
+
"invalid": "Avain ei vastaa mitään DID-dokumentin rotaatioavainta",
1335
+
"validating": "Vahvistetaan avainta...",
1336
+
"validate": "Vahvista avain"
1290
1337
},
1291
-
"migrating": {
1292
-
"title": "Siirretään tiliäsi",
1293
-
"desc": "Odota, kun siirrämme tietojasi..."
1338
+
"chooseHandle": {
1339
+
"migratingDid": "Palautetaan DID"
1294
1340
},
1295
-
"plcToken": {
1296
-
"title": "Vahvista henkilöllisyytesi",
1297
-
"desc": "Vahvistuskoodi on lähetetty sähköpostiisi."
1341
+
"review": {
1342
+
"desc": "Tarkista offline-palautuksen tiedot.",
1343
+
"carFile": "CAR-tiedosto",
1344
+
"rotationKey": "Rotaatioavain",
1345
+
"warning": "Kun aloitat palautuksen, identiteettisi päivitetään osoittamaan tähän PDS:ään. Tätä ei voi helposti perua.",
1346
+
"plcWarningTitle": "Ei paluuta",
1347
+
"plcWarning": "Kun aloitat, DID-dokumenttisi päivitetään osoittamaan tähän PDS:ään. Jos jokin menee pieleen, voit käyttää rotaatioavaintasi palautumiseen, mutta sinun tulisi suorittaa siirto loppuun välttääksesi rikkinäisen identiteettitilan."
1298
1348
},
1299
-
"finalizing": {
1300
-
"title": "Viimeistellään siirtoa",
1301
-
"desc": "Odota, kun viimeistelemme siirtoa...",
1302
-
"updatingForwarding": "Päivitetään DID-dokumentin uudelleenohjausta..."
1349
+
"migrating": {
1350
+
"title": "Palautetaan tiliä",
1351
+
"desc": "Odota, tiliäsi palautetaan...",
1352
+
"creating": "Luodaan tili",
1353
+
"importing": "Tuodaan tietovarastoa",
1354
+
"plcSigning": "Päivitetään identiteettiä",
1355
+
"activating": "Aktivoidaan tili"
1303
1356
},
1304
1357
"success": {
1305
-
"title": "Siirto valmis!",
1306
-
"desc": "Tilisi on siirretty onnistuneesti uuteen PDS:ääsi.",
1307
-
"newHandle": "Uusi käyttäjätunnus",
1308
-
"newPds": "Uusi PDS",
1309
-
"nextSteps": "Seuraavat vaiheet",
1310
-
"nextSteps1": "Kirjaudu uuteen PDS:ääsi",
1311
-
"nextSteps2": "Päivitä sovellukset uusilla tunnuksillasi",
1312
-
"nextSteps3": "Seuraajasi näkevät automaattisesti uuden sijaintisi",
1313
-
"loggingOut": "Kirjaudutaan ulos {seconds} sekunnin kuluttua..."
1358
+
"desc": "Tilisi on palautettu onnistuneesti tähän PDS:ään."
1359
+
},
1360
+
"blobs": {
1361
+
"title": "Siirretään blob-tiedostoja",
1362
+
"desc": "Yritetään palauttaa kuvia ja mediaa vanhasta PDS:stäsi...",
1363
+
"migrating": "Siirretään blob-tiedostoja",
1364
+
"failedTitle": "Joitain blob-tiedostoja ei voitu siirtää",
1365
+
"failedDesc": "{count} blob-tiedostoa ei voitu hakea vanhasta PDS:stäsi. Tämä voi tapahtua, jos palvelin ei ole tavoitettavissa tai tiedostot on poistettu.",
1366
+
"sourceUnreachableTitle": "Lähde-PDS ei tavoitettavissa",
1367
+
"sourceUnreachable": "Ei voitu yhdistää vanhaan PDS:ääsi mediatiedostojen hakemiseksi. Tämä on yleistä siirrettäessä suljetulta palvelimelta. Julkaisusi toimivat, mutta joitain kuvia saattaa puuttua."
1314
1368
}
1315
1369
},
1316
1370
"progress": {
+147
-100
frontend/src/locales/ja.json
+147
-100
frontend/src/locales/ja.json
···
17
17
"dashboard": "ダッシュボード",
18
18
"backToDashboard": "← ダッシュボード",
19
19
"copied": "コピーしました!",
20
-
"copyToClipboard": "クリップボードにコピー"
20
+
"copyToClipboard": "クリップボードにコピー",
21
+
"verifying": "確認中...",
22
+
"saving": "保存中...",
23
+
"creating": "作成中...",
24
+
"updating": "更新中...",
25
+
"sending": "送信中...",
26
+
"authenticating": "認証中...",
27
+
"checking": "確認中...",
28
+
"redirecting": "リダイレクト中...",
29
+
"signIn": "サインイン",
30
+
"verify": "確認",
31
+
"remove": "削除",
32
+
"revoke": "取り消し",
33
+
"resendCode": "コードを再送信",
34
+
"startOver": "最初からやり直す",
35
+
"tryAgain": "再試行",
36
+
"password": "パスワード",
37
+
"email": "メール",
38
+
"emailAddress": "メールアドレス",
39
+
"handle": "ハンドル",
40
+
"did": "DID",
41
+
"verificationCode": "確認コード",
42
+
"inviteCode": "招待コード",
43
+
"newPassword": "新しいパスワード",
44
+
"confirmPassword": "パスワードを確認",
45
+
"enterSixDigitCode": "6桁のコードを入力",
46
+
"passwordHint": "8文字以上",
47
+
"enterPassword": "パスワードを入力",
48
+
"emailPlaceholder": "you@example.com",
49
+
"verified": "確認済み",
50
+
"disabled": "無効",
51
+
"available": "利用可能",
52
+
"deactivated": "非アクティブ",
53
+
"unverified": "未確認",
54
+
"backToLogin": "ログインに戻る",
55
+
"backToSettings": "設定に戻る",
56
+
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
57
+
"createAccount": "アカウントを作成",
58
+
"passwordsMismatch": "パスワードが一致しません",
59
+
"passwordTooShort": "パスワードは8文字以上必要です"
21
60
},
22
61
"login": {
23
62
"title": "サインイン",
···
49
88
"codeLabel": "確認コード",
50
89
"codePlaceholder": "6桁のコードを入力",
51
90
"verifyButton": "確認する",
52
-
"verifying": "確認中...",
53
-
"resendButton": "コードを再送信",
54
-
"resending": "送信中...",
55
-
"resent": "確認コードを再送信しました!",
56
-
"backToLogin": "ログインに戻る"
91
+
"resent": "確認コードを再送信しました!"
57
92
},
58
93
"register": {
59
94
"title": "アカウント作成",
···
124
159
"inviteCodePlaceholder": "招待コードを入力",
125
160
"inviteCodeRequired": "必須",
126
161
"createButton": "アカウントを作成",
127
-
"creating": "作成中...",
128
162
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
129
163
"signIn": "サインイン",
130
164
"wantPasswordless": "パスワードレスをご希望ですか?",
···
179
213
"navAdminDesc": "サーバー統計と管理操作",
180
214
"navDidDocument": "DID ドキュメント",
181
215
"navDidDocumentDesc": "DID ドキュメントとキーを管理",
216
+
"navDidDocumentDescActive": "DID ドキュメント設定を編集",
217
+
"navBackup": "バックアップをダウンロード",
218
+
"navBackupDesc": "リポジトリを CAR ファイルとしてダウンロード",
219
+
"downloadingBackup": "ダウンロード中...",
220
+
"backupFailed": "バックアップのダウンロードに失敗しました",
182
221
"migrated": "移行済み",
183
222
"migratedTitle": "アカウント移行済み",
184
223
"migratedMessage": "アカウントは {pds} に移行されました。DID ドキュメントは引き続きここでホストされています。",
···
208
247
"serviceEndpointDesc": "アカウントデータを現在ホストしているPDS。移行時に更新してください。",
209
248
"currentPds": "現在のPDS URL",
210
249
"save": "変更を保存",
211
-
"saving": "保存中...",
212
250
"success": "DID ドキュメントを更新しました",
213
251
"saveFailed": "DIDドキュメントの保存に失敗しました",
214
252
"loadFailed": "DIDドキュメントの読み込みに失敗しました",
···
246
284
"yourDomain": "ドメイン",
247
285
"yourDomainPlaceholder": "example.com",
248
286
"verifyAndUpdate": "確認してハンドルを更新",
249
-
"verifying": "確認中...",
250
287
"newHandle": "新しいハンドル",
251
288
"newHandlePlaceholder": "yourhandle",
252
289
"changeHandleButton": "ハンドルを変更",
···
262
299
"exportData": "データエクスポート",
263
300
"exportDataDescription": "リポジトリ全体を CAR(Content Addressable Archive)ファイルとしてダウンロードします。投稿、いいね、フォローなどすべてのデータが含まれます。",
264
301
"downloadRepo": "リポジトリをダウンロード",
302
+
"downloadBlobs": "メディアをダウンロード",
265
303
"exporting": "エクスポート中...",
304
+
"backups": {
305
+
"title": "バックアップ",
306
+
"description": "リポジトリは毎日自動的にバックアップされます。手動でバックアップを作成したり、以前のバックアップから復元することもできます。",
307
+
"enableAutomatic": "自動バックアップを有効にする",
308
+
"enabled": "自動バックアップが有効です",
309
+
"disabled": "自動バックアップが無効です",
310
+
"toggleFailed": "バックアップ設定の更新に失敗しました",
311
+
"noBackups": "バックアップはまだありません。",
312
+
"blocks": "ブロック",
313
+
"download": "ダウンロード",
314
+
"delete": "削除",
315
+
"createNow": "今すぐバックアップを作成",
316
+
"created": "バックアップが正常に作成されました",
317
+
"createFailed": "バックアップの作成に失敗しました",
318
+
"downloadFailed": "バックアップのダウンロードに失敗しました",
319
+
"deleted": "バックアップが削除されました",
320
+
"deleteFailed": "バックアップの削除に失敗しました",
321
+
"restoreTitle": "バックアップから復元",
322
+
"restoreDescription": "CARファイルをアップロードしてリポジトリを復元します。現在のデータは上書きされます。",
323
+
"selectFile": "CARファイルを選択",
324
+
"selectedFile": "選択されたファイル",
325
+
"restore": "復元",
326
+
"restoring": "復元中...",
327
+
"restored": "リポジトリが正常に復元されました",
328
+
"restoreFailed": "リポジトリの復元に失敗しました"
329
+
},
266
330
"deleteAccount": "アカウント削除",
267
331
"deleteWarning": "この操作は取り消せません。すべてのデータが完全に削除されます。",
268
332
"requestDeletion": "アカウント削除をリクエスト",
···
291
355
"deleteConfirmation": "本当にアカウントを削除しますか?この操作は取り消せません。",
292
356
"deletionFailed": "アカウントの削除に失敗しました",
293
357
"repoExported": "リポジトリをエクスポートしました",
294
-
"exportFailed": "リポジトリのエクスポートに失敗しました",
358
+
"blobsExported": "メディアファイルをエクスポートしました",
359
+
"noBlobsToExport": "エクスポートするメディアファイルがありません",
360
+
"exportFailed": "エクスポートに失敗しました",
295
361
"confirmDelete": "本当にアカウントを削除しますか?この操作は取り消せません。"
296
362
}
297
363
},
···
306
372
"noPasswords": "アプリパスワードはまだありません",
307
373
"revoke": "取り消す",
308
374
"revoking": "取り消し中...",
309
-
"creating": "作成中...",
310
375
"revokeConfirm": "アプリパスワード「{name}」を取り消しますか?このパスワードを使用しているアプリはアカウントにアクセスできなくなります。",
311
376
"saveWarningTitle": "重要: このアプリパスワードを保存してください!",
312
377
"saveWarningMessage": "このパスワードはパスキーや OAuth をサポートしていないアプリにサインインするために必要です。一度しか表示されません。",
···
354
419
"used": "@{handle} が使用済み",
355
420
"disabled": "無効",
356
421
"usedBy": "使用者",
357
-
"creating": "作成中...",
358
422
"disableConfirm": "この招待コードを無効にしますか?使用できなくなります。",
359
423
"created": "招待コードを作成しました",
360
424
"copy": "コピー",
···
482
546
"verifyButton": "確認",
483
547
"verifyCodePlaceholder": "確認コードを入力",
484
548
"submit": "送信",
485
-
"saving": "保存中...",
486
549
"savePreferences": "設定を保存",
487
550
"preferencesSaved": "連絡設定を保存しました",
488
551
"verifiedSuccess": "{channel} を確認しました",
···
521
584
"noCollectionsYet": "コレクションがまだありません。最初のレコードを作成して開始しましょう。",
522
585
"loadMore": "さらに読み込む",
523
586
"recordJson": "レコード JSON",
524
-
"saving": "保存中...",
525
587
"updateRecord": "レコードを更新",
526
588
"collectionNsid": "コレクション (NSID)",
527
589
"recordKeyOptional": "レコードキー(任意)",
528
590
"autoGenerated": "空白で自動生成 (TID)",
529
591
"autoGeneratedHint": "空白にすると TID ベースのキーが自動生成されます",
530
-
"creating": "作成中...",
531
592
"demoPostText": "こんにちは、私の PDS からの初投稿です!",
532
593
"demoDisplayName": "表示名",
533
594
"demoBio": "自己紹介を書いてください。"
···
548
609
"primaryLight": "プライマリ(ライトモード)",
549
610
"primaryDark": "プライマリ(ダークモード)",
550
611
"configSaved": "サーバー設定を保存しました",
551
-
"saving": "保存中...",
552
612
"saveConfig": "設定を保存",
553
613
"serverStats": "サーバー統計",
554
614
"users": "ユーザー",
···
639
699
"title": "二要素認証",
640
700
"subtitle": "追加の確認が必要です",
641
701
"usePasskey": "パスキーを使用",
642
-
"useTotp": "認証アプリを使用",
643
-
"verifying": "確認中..."
702
+
"useTotp": "認証アプリを使用"
644
703
},
645
704
"twoFactorCode": {
646
705
"title": "二要素認証",
647
706
"subtitle": "{channel} に確認コードを送信しました。以下にコードを入力して続行してください。",
648
707
"codeLabel": "確認コード",
649
708
"codePlaceholder": "6桁のコードを入力",
650
-
"verify": "確認",
651
-
"verifying": "確認中...",
652
709
"errors": {
653
710
"missingRequestUri": "request_uri パラメータがありません",
654
711
"verificationFailed": "確認に失敗しました",
···
660
717
"title": "認証コードを入力",
661
718
"subtitle": "認証アプリの6桁のコードを入力",
662
719
"codePlaceholder": "6桁のコードを入力",
663
-
"verify": "確認",
664
-
"verifying": "確認中...",
665
720
"useBackupCode": "バックアップコードを使用",
666
721
"backupCodePlaceholder": "バックアップコードを入力",
667
722
"trustDevice": "このデバイスを30日間信頼する",
···
691
746
"codeLabel": "確認コード",
692
747
"codeHelp": "ダッシュを含む完全なコードをメッセージからコピーしてください",
693
748
"verifyButton": "アカウントを確認",
694
-
"verify": "確認",
695
-
"verifying": "確認中...",
696
749
"pleaseWait": "お待ちください...",
697
-
"sending": "送信中...",
698
-
"resendCode": "コードを再送信",
699
-
"resending": "送信中...",
700
750
"codeResent": "確認コードを再送信しました!",
701
751
"codeResentDetail": "確認コードを送信しました!受信トレイを確認してください。",
702
752
"verified": "確認完了!",
···
706
756
"identifierLabel": "メールまたは識別子",
707
757
"identifierPlaceholder": "you@example.com",
708
758
"identifierHelp": "コードが送信されたメールアドレスまたは識別子",
709
-
"backToLogin": "ログインに戻る",
710
759
"verifyingAccount": "確認中のアカウント: @{handle}",
711
760
"startOver": "別のアカウントでやり直す",
712
761
"noPending": "保留中の確認が見つかりません。",
713
762
"noPendingInfo": "最近アカウントを作成して確認が必要な場合は、新しいアカウントを作成する必要があります。すでにアカウントを確認した場合は、サインインできます。",
714
763
"createAccount": "アカウントを作成",
715
764
"signIn": "サインイン",
716
-
"backToSettings": "設定に戻る",
717
765
"emailUpdateCodeHelp": "コードは現在のメールアドレスに送信されました",
718
766
"emailUpdateFailed": "メールアドレスの更新に失敗しました",
719
767
"emailUpdateRequiresAuth": "メールアドレスを更新するにはサインインが必要です。",
···
746
794
"resetButton": "パスワードをリセット",
747
795
"resetting": "リセット中...",
748
796
"success": "パスワードをリセットしました!",
749
-
"backToLogin": "サインインに戻る",
750
797
"requestNewCode": "新しいコードをリクエスト",
751
798
"passwordsMismatch": "パスワードが一致しません",
752
799
"passwordLength": "パスワードは8文字以上である必要があります"
···
790
837
"howItWorks": "仕組み",
791
838
"howItWorksDetail": "登録された通知チャンネルに安全なリンクを送信します。リンクをクリックして一時パスワードを設定します。その後サインインして新しいパスキーを追加できます。",
792
839
"sendRecoveryLink": "復旧リンクを送信",
793
-
"sending": "送信中...",
794
-
"backToLogin": "サインインに戻る"
840
+
"sending": "送信中..."
795
841
},
796
842
"registerPasskey": {
797
843
"title": "パスキーアカウントを作成",
···
812
858
"externalDid": "あなたの did:web",
813
859
"externalDidPlaceholder": "did:web:yourdomain.com",
814
860
"createButton": "アカウントを作成",
815
-
"creating": "作成中...",
816
861
"alreadyHaveAccount": "すでにアカウントをお持ちですか?",
817
862
"signIn": "サインイン",
818
863
"wantPassword": "パスワードを使用しますか?",
···
911
956
"useTotp": "認証アプリを使用",
912
957
"passwordPlaceholder": "パスワードを入力",
913
958
"totpPlaceholder": "6桁のコードを入力",
914
-
"verify": "確認",
915
-
"verifying": "確認中...",
916
959
"authenticating": "認証中...",
917
960
"passkeyPrompt": "下のボタンをクリックしてパスキーで認証してください。",
918
961
"cancel": "キャンセル"
···
985
1028
"createAccount": "アカウントを作成",
986
1029
"createDelegatedAccount": "委任アカウントを作成",
987
1030
"createDelegatedAccountButton": "+ 委任アカウントを作成",
988
-
"creating": "作成中...",
989
1031
"emailOptional": "メール(任意)",
990
1032
"failedToAddController": "コントローラーの追加に失敗しました",
991
1033
"failedToCreateAccount": "委任アカウントの作成に失敗しました",
···
1059
1101
"navDesc": "別のPDSへ、または別のPDSからアカウントを移動",
1060
1102
"migrateHere": "ここに移行",
1061
1103
"migrateHereDesc": "既存のAT ProtocolアカウントをこのPDSに移動します。",
1062
-
"migrateAway": "別の場所に移行",
1063
-
"migrateAwayDesc": "このPDSから別のサーバーにアカウントを移動します。",
1064
-
"loginRequired": "ログインが必要です",
1065
1104
"bringDid": "DIDとアイデンティティを持ち込む",
1066
1105
"transferData": "すべてのデータを転送",
1067
1106
"keepFollowers": "フォロワーを維持",
1068
-
"exportRepo": "リポジトリをエクスポート",
1069
-
"transferToPds": "新しいPDSに転送",
1070
-
"updateIdentity": "アイデンティティを更新",
1071
1107
"whatIsMigration": "アカウント移行とは?",
1072
1108
"whatIsMigrationDesc": "アカウント移行により、AT Protocolアイデンティティをパーソナルデータサーバー(PDS)間で移動できます。DID(分散型識別子)は変わらないため、フォロワーやソーシャルコネクションは維持されます。",
1073
1109
"beforeMigrate": "移行前の確認事項",
···
1077
1113
"beforeMigrate4": "古いPDSにアカウントの無効化が通知されます",
1078
1114
"importantWarning": "アカウント移行は重要な操作です。移行先のPDSを信頼し、データが移動されることを理解してください。問題が発生した場合、手動での復旧が必要になる可能性があります。",
1079
1115
"learnMore": "移行のリスクについて詳しく",
1080
-
"comingSoon": "近日公開",
1116
+
"offlineRestore": "オフライン復元",
1117
+
"offlineRestoreDesc": "旧PDSが利用できない場合にバックアップから復元します。",
1118
+
"offlineFeature1": "CARファイルバックアップを使用",
1119
+
"offlineFeature2": "ローテーションキーで所有権を証明",
1120
+
"offlineFeature3": "シャットダウンしたサーバーの復旧",
1081
1121
"oauthCompleting": "認証を完了しています...",
1082
1122
"oauthFailed": "認証に失敗しました",
1083
1123
"tryAgain": "再試行",
···
1086
1126
"incomplete": "未完了の移行があります:",
1087
1127
"direction": "方向",
1088
1128
"migratingHere": "ここに移行中",
1089
-
"migratingAway": "別の場所に移行中",
1090
1129
"from": "移行元",
1091
1130
"to": "移行先",
1092
1131
"progress": "進行状況",
···
1229
1268
"error": {
1230
1269
"title": "移行エラー",
1231
1270
"desc": "移行中にエラーが発生しました。",
1232
-
"startOver": "最初からやり直す"
1271
+
"startOver": "最初からやり直す",
1272
+
"unknown": "不明なエラーが発生しました。"
1233
1273
},
1234
1274
"common": {
1235
1275
"back": "戻る",
···
1247
1287
"warning3": "移行後、古いアカウントは無効化されます"
1248
1288
}
1249
1289
},
1250
-
"outbound": {
1290
+
"offline": {
1251
1291
"welcome": {
1252
-
"title": "このPDSから移行",
1253
-
"desc": "アカウントを別のパーソナルデータサーバーに移動します。",
1254
-
"warning": "移行後、ここでのアカウントは無効化されます。",
1255
-
"didWebNotice": "did:web移行のお知らせ",
1256
-
"didWebNoticeDesc": "あなたのアカウントはdid:web識別子({did})を使用しています。移行後、このPDSは新しいPDSを指すDIDドキュメントを引き続き提供します。このサーバーがオンラインである限り、アイデンティティは機能し続けます。",
1257
-
"understand": "リスクを理解し、続行します"
1292
+
"title": "バックアップから復元",
1293
+
"desc": "CARファイルバックアップとローテーションキーを使用してアカウントを復元します。以前のPDSが利用できない場合に使用してください。",
1294
+
"warningTitle": "この方法を使用するタイミング",
1295
+
"warningDesc": "このオフライン復元は、古いPDSがシャットダウンした、アクセスできない、またはロックアウトされた場合の災害復旧用です。古いPDSがまだ利用可能な場合は、代わりに標準の移行を使用してください。",
1296
+
"requirementsTitle": "必要なもの",
1297
+
"requirement1": "リポジトリのCARファイルバックアップ",
1298
+
"requirement2": "ローテーションキー(DIDの秘密鍵)",
1299
+
"requirement3": "あなたのDID (did:plc:xxx)",
1300
+
"understand": "理解し、続行します"
1258
1301
},
1259
-
"targetPds": {
1260
-
"title": "移行先PDSを選択",
1261
-
"desc": "移行先のPDSのURLを入力してください。",
1262
-
"url": "PDS URL",
1263
-
"urlPlaceholder": "https://pds.example.com",
1264
-
"validate": "検証して続行",
1265
-
"validating": "検証中...",
1266
-
"connected": "{name}に接続しました",
1267
-
"inviteRequired": "招待コードが必要です",
1268
-
"privacyPolicy": "プライバシーポリシー",
1269
-
"termsOfService": "利用規約"
1302
+
"provideDid": {
1303
+
"title": "DIDを入力",
1304
+
"desc": "復元するアカウントのDIDを入力してください。",
1305
+
"label": "あなたのDID",
1306
+
"hint": "分散型識別子(例:did:plc:abc123)"
1270
1307
},
1271
-
"newAccount": {
1272
-
"title": "新しいアカウントの詳細",
1273
-
"desc": "新しいPDSでアカウントを設定します。",
1274
-
"handle": "ハンドル",
1275
-
"availableDomains": "利用可能なドメイン",
1276
-
"email": "メール",
1277
-
"password": "パスワード",
1278
-
"confirmPassword": "パスワードを確認",
1279
-
"inviteCode": "招待コード"
1308
+
"uploadCar": {
1309
+
"title": "CARファイルをアップロード",
1310
+
"desc": "リポジトリバックアップファイルをアップロードしてください。",
1311
+
"label": "CARファイル",
1312
+
"hint": "バックアップから.carファイルを選択",
1313
+
"reuploadWarningTitle": "CARファイルが必要です",
1314
+
"reuploadWarning": "セッションは復元されましたが、CARファイルを再アップロードする必要があります。セキュリティ上の理由から、ファイルの内容はセッション間で保存されません。"
1280
1315
},
1281
-
"review": {
1282
-
"title": "移行の確認",
1283
-
"desc": "移行の詳細を確認してください。",
1284
-
"currentHandle": "現在のハンドル",
1285
-
"newHandle": "新しいハンドル",
1286
-
"sourcePds": "このPDS",
1287
-
"targetPds": "移行先PDS",
1288
-
"confirm": "アカウントを移行することを確認します",
1289
-
"startMigration": "移行を開始"
1316
+
"rotationKey": {
1317
+
"title": "ローテーションキーを提供",
1318
+
"desc": "このDIDの所有権を証明するためにローテーションキーを入力してください。",
1319
+
"securityWarningTitle": "セキュリティ警告",
1320
+
"securityWarning1": "ローテーションキーは非常に機密性が高いです - マスターパスワードのように扱ってください",
1321
+
"securityWarning2": "信頼できるデバイスとネットワークでのみ入力してください",
1322
+
"securityWarning3": "このキーは移行完了後に保存されません",
1323
+
"label": "ローテーションキー",
1324
+
"placeholder": "秘密鍵を入力(hex、base58、またはJWK)",
1325
+
"hint": "DIDドキュメントのローテーションキーの1つに対応する秘密鍵",
1326
+
"valid": "キーは有効で、DIDのローテーションキーと一致します",
1327
+
"invalid": "キーはDIDドキュメントのどのローテーションキーとも一致しません",
1328
+
"validating": "キーを検証中...",
1329
+
"validate": "キーを検証"
1290
1330
},
1291
-
"migrating": {
1292
-
"title": "アカウントを移行中",
1293
-
"desc": "データを転送しています..."
1331
+
"chooseHandle": {
1332
+
"migratingDid": "DIDを復元中"
1294
1333
},
1295
-
"plcToken": {
1296
-
"title": "本人確認",
1297
-
"desc": "確認コードがメールに送信されました。"
1334
+
"review": {
1335
+
"desc": "オフライン復元の詳細を確認してください。",
1336
+
"carFile": "CARファイル",
1337
+
"rotationKey": "ローテーションキー",
1338
+
"warning": "復元を開始すると、アイデンティティがこのPDSを指すように更新されます。これは簡単に元に戻すことができません。",
1339
+
"plcWarningTitle": "引き返せないポイント",
1340
+
"plcWarning": "開始すると、DIDドキュメントがこのPDSを指すように更新されます。問題が発生した場合はローテーションキーを使用して回復できますが、壊れたアイデンティティ状態を避けるために移行を完了する必要があります。"
1298
1341
},
1299
-
"finalizing": {
1300
-
"title": "移行を完了中",
1301
-
"desc": "移行を完了しています...",
1302
-
"updatingForwarding": "DIDドキュメントの転送先を更新中..."
1342
+
"migrating": {
1343
+
"title": "アカウントを復元中",
1344
+
"desc": "アカウントを復元しています...",
1345
+
"creating": "アカウントを作成中",
1346
+
"importing": "リポジトリをインポート中",
1347
+
"plcSigning": "アイデンティティを更新中",
1348
+
"activating": "アカウントをアクティベート中"
1303
1349
},
1304
1350
"success": {
1305
-
"title": "移行完了!",
1306
-
"desc": "アカウントは新しいPDSに正常に移行されました。",
1307
-
"newHandle": "新しいハンドル",
1308
-
"newPds": "新しいPDS",
1309
-
"nextSteps": "次のステップ",
1310
-
"nextSteps1": "新しいPDSにサインイン",
1311
-
"nextSteps2": "アプリの認証情報を更新",
1312
-
"nextSteps3": "フォロワーは自動的に新しい場所を確認できます",
1313
-
"loggingOut": "{seconds}秒後にログアウトします..."
1351
+
"desc": "アカウントはこのPDSに正常に復元されました。"
1352
+
},
1353
+
"blobs": {
1354
+
"title": "Blobを移行中",
1355
+
"desc": "古いPDSから画像とメディアの復元を試みています...",
1356
+
"migrating": "Blobを移行中",
1357
+
"failedTitle": "一部のBlobを移行できませんでした",
1358
+
"failedDesc": "{count}個のBlobを古いPDSから取得できませんでした。サーバーに接続できないか、ファイルが削除された可能性があります。",
1359
+
"sourceUnreachableTitle": "ソースPDSに接続できません",
1360
+
"sourceUnreachable": "古いPDSに接続してメディアファイルを取得できませんでした。シャットダウンしたサーバーからの移行ではよくあることです。投稿は機能しますが、一部の画像が欠落する可能性があります。"
1314
1361
}
1315
1362
},
1316
1363
"progress": {
+147
-100
frontend/src/locales/ko.json
+147
-100
frontend/src/locales/ko.json
···
17
17
"dashboard": "대시보드",
18
18
"backToDashboard": "← 대시보드",
19
19
"copied": "복사됨!",
20
-
"copyToClipboard": "클립보드에 복사"
20
+
"copyToClipboard": "클립보드에 복사",
21
+
"verifying": "확인 중...",
22
+
"saving": "저장 중...",
23
+
"creating": "생성 중...",
24
+
"updating": "업데이트 중...",
25
+
"sending": "전송 중...",
26
+
"authenticating": "인증 중...",
27
+
"checking": "확인 중...",
28
+
"redirecting": "리디렉션 중...",
29
+
"signIn": "로그인",
30
+
"verify": "확인",
31
+
"remove": "삭제",
32
+
"revoke": "취소",
33
+
"resendCode": "코드 재전송",
34
+
"startOver": "처음부터 다시",
35
+
"tryAgain": "다시 시도",
36
+
"password": "비밀번호",
37
+
"email": "이메일",
38
+
"emailAddress": "이메일 주소",
39
+
"handle": "핸들",
40
+
"did": "DID",
41
+
"verificationCode": "인증 코드",
42
+
"inviteCode": "초대 코드",
43
+
"newPassword": "새 비밀번호",
44
+
"confirmPassword": "비밀번호 확인",
45
+
"enterSixDigitCode": "6자리 코드 입력",
46
+
"passwordHint": "8자 이상",
47
+
"enterPassword": "비밀번호를 입력하세요",
48
+
"emailPlaceholder": "you@example.com",
49
+
"verified": "인증됨",
50
+
"disabled": "비활성화됨",
51
+
"available": "사용 가능",
52
+
"deactivated": "비활성화됨",
53
+
"unverified": "미인증",
54
+
"backToLogin": "로그인으로 돌아가기",
55
+
"backToSettings": "설정으로 돌아가기",
56
+
"alreadyHaveAccount": "이미 계정이 있으신가요?",
57
+
"createAccount": "계정 만들기",
58
+
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
59
+
"passwordTooShort": "비밀번호는 8자 이상이어야 합니다"
21
60
},
22
61
"login": {
23
62
"title": "로그인",
···
49
88
"codeLabel": "인증 코드",
50
89
"codePlaceholder": "6자리 코드 입력",
51
90
"verifyButton": "계정 인증",
52
-
"verifying": "인증 중...",
53
-
"resendButton": "코드 다시 보내기",
54
-
"resending": "전송 중...",
55
-
"resent": "인증 코드를 다시 보냈습니다!",
56
-
"backToLogin": "로그인으로 돌아가기"
91
+
"resent": "인증 코드를 다시 보냈습니다!"
57
92
},
58
93
"register": {
59
94
"title": "계정 만들기",
···
124
159
"inviteCodePlaceholder": "초대 코드 입력",
125
160
"inviteCodeRequired": "필수",
126
161
"createButton": "계정 만들기",
127
-
"creating": "계정 생성 중...",
128
162
"alreadyHaveAccount": "이미 계정이 있으신가요?",
129
163
"signIn": "로그인",
130
164
"wantPasswordless": "비밀번호 없는 보안을 원하시나요?",
···
179
213
"navAdminDesc": "서버 통계 및 관리 작업",
180
214
"navDidDocument": "DID 문서",
181
215
"navDidDocumentDesc": "DID 문서 및 키 관리",
216
+
"navDidDocumentDescActive": "DID 문서 설정 편집",
217
+
"navBackup": "백업 다운로드",
218
+
"navBackupDesc": "저장소를 CAR 파일로 다운로드",
219
+
"downloadingBackup": "다운로드 중...",
220
+
"backupFailed": "백업 다운로드 실패",
182
221
"migrated": "마이그레이션됨",
183
222
"migratedTitle": "계정 마이그레이션됨",
184
223
"migratedMessage": "계정이 {pds}로 마이그레이션되었습니다. DID 문서는 여전히 여기에서 호스팅됩니다.",
···
208
247
"serviceEndpointDesc": "현재 계정 데이터를 호스팅하는 PDS입니다. 마이그레이션할 때 업데이트하세요.",
209
248
"currentPds": "현재 PDS URL",
210
249
"save": "변경사항 저장",
211
-
"saving": "저장 중...",
212
250
"success": "DID 문서가 업데이트되었습니다",
213
251
"saveFailed": "DID 문서 저장에 실패했습니다",
214
252
"loadFailed": "DID 문서 로드에 실패했습니다",
···
246
284
"yourDomain": "도메인",
247
285
"yourDomainPlaceholder": "example.com",
248
286
"verifyAndUpdate": "확인 후 핸들 업데이트",
249
-
"verifying": "확인 중...",
250
287
"newHandle": "새 핸들",
251
288
"newHandlePlaceholder": "yourhandle",
252
289
"changeHandleButton": "핸들 변경",
···
262
299
"exportData": "데이터 내보내기",
263
300
"exportDataDescription": "전체 저장소를 CAR (Content Addressable Archive) 파일로 다운로드합니다. 모든 게시물, 좋아요, 팔로우 및 기타 데이터가 포함됩니다.",
264
301
"downloadRepo": "저장소 다운로드",
302
+
"downloadBlobs": "미디어 다운로드",
265
303
"exporting": "내보내기 중...",
304
+
"backups": {
305
+
"title": "백업",
306
+
"description": "자동 백업을 관리하고 계정 데이터를 복원하세요. 백업에는 모든 기록과 blob이 포함됩니다.",
307
+
"enableAutomatic": "자동 백업",
308
+
"enabled": "활성화됨",
309
+
"disabled": "비활성화됨",
310
+
"toggleFailed": "백업 설정 변경 실패",
311
+
"noBackups": "아직 백업이 없습니다",
312
+
"blocks": "블록",
313
+
"download": "다운로드",
314
+
"delete": "삭제",
315
+
"createNow": "지금 백업 생성",
316
+
"created": "백업이 생성되었습니다",
317
+
"createFailed": "백업 생성 실패",
318
+
"downloadFailed": "백업 다운로드 실패",
319
+
"deleted": "백업이 삭제되었습니다",
320
+
"deleteFailed": "백업 삭제 실패",
321
+
"restoreTitle": "백업에서 복원",
322
+
"restoreDescription": "이전에 내보낸 CAR 파일에서 계정 데이터를 복원합니다. 이렇게 하면 현재 저장소가 업로드한 백업으로 교체됩니다.",
323
+
"selectFile": "CAR 파일 선택",
324
+
"selectedFile": "선택된 파일",
325
+
"restore": "백업 복원",
326
+
"restoring": "복원 중...",
327
+
"restored": "백업이 성공적으로 복원되었습니다",
328
+
"restoreFailed": "백업 복원 실패"
329
+
},
266
330
"deleteAccount": "계정 삭제",
267
331
"deleteWarning": "이 작업은 되돌릴 수 없습니다. 모든 데이터가 영구적으로 삭제됩니다.",
268
332
"requestDeletion": "계정 삭제 요청",
···
291
355
"deleteConfirmation": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.",
292
356
"deletionFailed": "계정 삭제에 실패했습니다",
293
357
"repoExported": "저장소를 내보냈습니다",
294
-
"exportFailed": "저장소 내보내기에 실패했습니다",
358
+
"blobsExported": "미디어 파일을 내보냈습니다",
359
+
"noBlobsToExport": "내보낼 미디어 파일이 없습니다",
360
+
"exportFailed": "내보내기에 실패했습니다",
295
361
"confirmDelete": "정말로 계정을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
296
362
}
297
363
},
···
306
372
"noPasswords": "앱 비밀번호가 아직 없습니다",
307
373
"revoke": "취소",
308
374
"revoking": "취소 중...",
309
-
"creating": "생성 중...",
310
375
"revokeConfirm": "앱 비밀번호 \"{name}\"을(를) 취소하시겠습니까? 이 비밀번호를 사용하는 앱은 더 이상 계정에 액세스할 수 없습니다.",
311
376
"saveWarningTitle": "중요: 이 앱 비밀번호를 저장하세요!",
312
377
"saveWarningMessage": "이 비밀번호는 패스키 또는 OAuth를 지원하지 않는 앱에 로그인하는 데 필요합니다. 한 번만 볼 수 있습니다.",
···
354
419
"used": "@{handle}이(가) 사용함",
355
420
"disabled": "비활성화됨",
356
421
"usedBy": "사용자",
357
-
"creating": "생성 중...",
358
422
"disableConfirm": "이 초대 코드를 비활성화하시겠습니까? 더 이상 사용할 수 없습니다.",
359
423
"created": "초대 코드가 생성되었습니다",
360
424
"copy": "복사",
···
482
546
"verifyButton": "인증",
483
547
"verifyCodePlaceholder": "인증 코드 입력",
484
548
"submit": "제출",
485
-
"saving": "저장 중...",
486
549
"savePreferences": "설정 저장",
487
550
"preferencesSaved": "통신 설정이 저장되었습니다",
488
551
"verifiedSuccess": "{channel} 인증 완료",
···
521
584
"noCollectionsYet": "컬렉션이 아직 없습니다. 첫 번째 레코드를 만들어 시작하세요.",
522
585
"loadMore": "더 불러오기",
523
586
"recordJson": "레코드 JSON",
524
-
"saving": "저장 중...",
525
587
"updateRecord": "레코드 업데이트",
526
588
"collectionNsid": "컬렉션 (NSID)",
527
589
"recordKeyOptional": "레코드 키 (선택사항)",
528
590
"autoGenerated": "비워두면 자동 생성 (TID)",
529
591
"autoGeneratedHint": "비워두면 TID 기반 키가 자동 생성됩니다",
530
-
"creating": "생성 중...",
531
592
"demoPostText": "안녕하세요, 제 PDS에서 보내는 첫 번째 게시물입니다!",
532
593
"demoDisplayName": "표시 이름",
533
594
"demoBio": "간단한 자기소개를 작성하세요."
···
548
609
"primaryLight": "기본 (라이트 모드)",
549
610
"primaryDark": "기본 (다크 모드)",
550
611
"configSaved": "서버 설정이 저장되었습니다",
551
-
"saving": "저장 중...",
552
612
"saveConfig": "설정 저장",
553
613
"serverStats": "서버 통계",
554
614
"users": "사용자",
···
639
699
"title": "2단계 인증",
640
700
"subtitle": "추가 확인이 필요합니다",
641
701
"usePasskey": "패스키 사용",
642
-
"useTotp": "인증 앱 사용",
643
-
"verifying": "확인 중..."
702
+
"useTotp": "인증 앱 사용"
644
703
},
645
704
"twoFactorCode": {
646
705
"title": "2단계 인증",
647
706
"subtitle": "{channel}(으)로 인증 코드를 보냈습니다. 아래에 코드를 입력하여 계속하세요.",
648
707
"codeLabel": "인증 코드",
649
708
"codePlaceholder": "6자리 코드 입력",
650
-
"verify": "확인",
651
-
"verifying": "확인 중...",
652
709
"errors": {
653
710
"missingRequestUri": "request_uri 매개변수가 없습니다",
654
711
"verificationFailed": "인증에 실패했습니다",
···
660
717
"title": "인증 코드 입력",
661
718
"subtitle": "인증 앱의 6자리 코드를 입력하세요",
662
719
"codePlaceholder": "6자리 코드 입력",
663
-
"verify": "확인",
664
-
"verifying": "확인 중...",
665
720
"useBackupCode": "백업 코드 사용",
666
721
"backupCodePlaceholder": "백업 코드 입력",
667
722
"trustDevice": "이 기기를 30일간 신뢰",
···
691
746
"codeLabel": "인증 코드",
692
747
"codeHelp": "메시지에서 하이픈을 포함한 전체 코드를 복사하세요",
693
748
"verifyButton": "계정 인증",
694
-
"verify": "인증",
695
-
"verifying": "인증 중...",
696
749
"pleaseWait": "잠시 기다려 주세요...",
697
-
"sending": "전송 중...",
698
-
"resendCode": "코드 다시 보내기",
699
-
"resending": "전송 중...",
700
750
"codeResent": "인증 코드를 다시 보냈습니다!",
701
751
"codeResentDetail": "인증 코드가 전송되었습니다! 받은 편지함을 확인하세요.",
702
752
"verified": "인증 완료!",
···
706
756
"identifierLabel": "이메일 또는 식별자",
707
757
"identifierPlaceholder": "you@example.com",
708
758
"identifierHelp": "코드가 전송된 이메일 주소 또는 식별자",
709
-
"backToLogin": "로그인으로 돌아가기",
710
759
"verifyingAccount": "인증 중인 계정: @{handle}",
711
760
"startOver": "다른 계정으로 다시 시작",
712
761
"noPending": "보류 중인 인증이 없습니다.",
713
762
"noPendingInfo": "최근에 계정을 만들고 인증이 필요한 경우 새 계정을 만들어야 합니다. 이미 계정을 인증한 경우 로그인할 수 있습니다.",
714
763
"createAccount": "계정 만들기",
715
764
"signIn": "로그인",
716
-
"backToSettings": "설정으로 돌아가기",
717
765
"emailUpdateCodeHelp": "코드가 현재 이메일 주소로 전송되었습니다",
718
766
"emailUpdateFailed": "이메일 주소 업데이트 실패",
719
767
"emailUpdateRequiresAuth": "이메일 주소를 업데이트하려면 로그인해야 합니다.",
···
746
794
"resetButton": "비밀번호 재설정",
747
795
"resetting": "재설정 중...",
748
796
"success": "비밀번호가 재설정되었습니다!",
749
-
"backToLogin": "로그인으로 돌아가기",
750
797
"requestNewCode": "새 코드 요청",
751
798
"passwordsMismatch": "비밀번호가 일치하지 않습니다",
752
799
"passwordLength": "비밀번호는 8자 이상이어야 합니다"
···
790
837
"howItWorks": "작동 방식",
791
838
"howItWorksDetail": "등록된 알림 채널로 보안 링크를 보냅니다. 링크를 클릭하여 임시 비밀번호를 설정합니다. 그런 다음 로그인하여 새 패스키를 추가할 수 있습니다.",
792
839
"sendRecoveryLink": "복구 링크 보내기",
793
-
"sending": "전송 중...",
794
-
"backToLogin": "로그인으로 돌아가기"
840
+
"sending": "전송 중..."
795
841
},
796
842
"registerPasskey": {
797
843
"title": "패스키 계정 만들기",
···
812
858
"externalDid": "귀하의 did:web",
813
859
"externalDidPlaceholder": "did:web:yourdomain.com",
814
860
"createButton": "계정 만들기",
815
-
"creating": "생성 중...",
816
861
"alreadyHaveAccount": "이미 계정이 있으신가요?",
817
862
"signIn": "로그인",
818
863
"wantPassword": "비밀번호를 사용하시겠습니까?",
···
911
956
"useTotp": "인증 앱 사용",
912
957
"passwordPlaceholder": "비밀번호 입력",
913
958
"totpPlaceholder": "6자리 코드 입력",
914
-
"verify": "확인",
915
-
"verifying": "확인 중...",
916
959
"authenticating": "인증 중...",
917
960
"passkeyPrompt": "아래 버튼을 클릭하여 패스키로 인증하세요.",
918
961
"cancel": "취소"
···
985
1028
"createAccount": "계정 생성",
986
1029
"createDelegatedAccount": "위임 계정 생성",
987
1030
"createDelegatedAccountButton": "+ 위임 계정 생성",
988
-
"creating": "생성 중...",
989
1031
"emailOptional": "이메일 (선택사항)",
990
1032
"failedToAddController": "컨트롤러 추가에 실패했습니다",
991
1033
"failedToCreateAccount": "위임 계정 생성에 실패했습니다",
···
1059
1101
"navDesc": "다른 PDS로 또는 다른 PDS에서 계정 이동",
1060
1102
"migrateHere": "여기로 마이그레이션",
1061
1103
"migrateHereDesc": "기존 AT Protocol 계정을 다른 서버에서 이 PDS로 이동합니다.",
1062
-
"migrateAway": "다른 곳으로 마이그레이션",
1063
-
"migrateAwayDesc": "이 PDS에서 다른 서버로 계정을 이동합니다.",
1064
-
"loginRequired": "로그인 필요",
1065
1104
"bringDid": "DID와 아이덴티티 가져오기",
1066
1105
"transferData": "모든 데이터 전송",
1067
1106
"keepFollowers": "팔로워 유지",
1068
-
"exportRepo": "저장소 내보내기",
1069
-
"transferToPds": "새 PDS로 전송",
1070
-
"updateIdentity": "아이덴티티 업데이트",
1071
1107
"whatIsMigration": "계정 마이그레이션이란?",
1072
1108
"whatIsMigrationDesc": "계정 마이그레이션을 통해 AT Protocol 아이덴티티를 개인 데이터 서버(PDS) 간에 이동할 수 있습니다. DID(분산 식별자)는 동일하게 유지되므로 팔로워와 소셜 연결이 보존됩니다.",
1073
1109
"beforeMigrate": "마이그레이션 전 확인사항",
···
1077
1113
"beforeMigrate4": "이전 PDS에 계정 비활성화가 통보됩니다",
1078
1114
"importantWarning": "계정 마이그레이션은 중요한 작업입니다. 대상 PDS를 신뢰하고 데이터가 이동된다는 것을 이해하세요. 문제가 발생하면 수동 복구가 필요할 수 있습니다.",
1079
1115
"learnMore": "마이그레이션 위험에 대해 자세히 알아보기",
1080
-
"comingSoon": "곧 출시 예정",
1116
+
"offlineRestore": "오프라인 복원",
1117
+
"offlineRestoreDesc": "이전 PDS를 사용할 수 없을 때 백업에서 복원합니다.",
1118
+
"offlineFeature1": "CAR 파일 백업 사용",
1119
+
"offlineFeature2": "회전 키로 소유권 증명",
1120
+
"offlineFeature3": "종료된 서버 복구",
1081
1121
"oauthCompleting": "인증 완료 중...",
1082
1122
"oauthFailed": "인증 실패",
1083
1123
"tryAgain": "다시 시도",
···
1086
1126
"incomplete": "완료되지 않은 마이그레이션이 있습니다:",
1087
1127
"direction": "방향",
1088
1128
"migratingHere": "여기로 마이그레이션 중",
1089
-
"migratingAway": "다른 곳으로 마이그레이션 중",
1090
1129
"from": "출발지",
1091
1130
"to": "목적지",
1092
1131
"progress": "진행 상황",
···
1229
1268
"error": {
1230
1269
"title": "마이그레이션 오류",
1231
1270
"desc": "마이그레이션 중 오류가 발생했습니다.",
1232
-
"startOver": "처음부터 다시 시작"
1271
+
"startOver": "처음부터 다시 시작",
1272
+
"unknown": "알 수 없는 오류가 발생했습니다."
1233
1273
},
1234
1274
"common": {
1235
1275
"back": "뒤로",
···
1247
1287
"warning3": "마이그레이션 후 이전 계정은 비활성화됩니다"
1248
1288
}
1249
1289
},
1250
-
"outbound": {
1290
+
"offline": {
1251
1291
"welcome": {
1252
-
"title": "이 PDS에서 마이그레이션",
1253
-
"desc": "계정을 다른 개인 데이터 서버로 이동합니다.",
1254
-
"warning": "마이그레이션 후 이 PDS에서 계정이 비활성화됩니다.",
1255
-
"didWebNotice": "did:web 마이그레이션 알림",
1256
-
"didWebNoticeDesc": "귀하의 계정은 did:web 식별자({did})를 사용합니다. 마이그레이션 후 이 PDS는 새 PDS를 가리키는 DID 문서를 계속 제공합니다. 이 서버가 온라인인 한 아이덴티티는 계속 작동합니다.",
1257
-
"understand": "위험을 이해하고 계속 진행합니다"
1292
+
"title": "백업에서 복원",
1293
+
"desc": "CAR 파일 백업과 회전 키를 사용하여 계정을 복원합니다. 이전 PDS를 사용할 수 없을 때 사용하세요.",
1294
+
"warningTitle": "이 방법을 사용해야 할 때",
1295
+
"warningDesc": "이 오프라인 복원은 이전 PDS가 종료되었거나, 접근할 수 없거나, 잠긴 경우의 재해 복구용입니다. 이전 PDS가 여전히 사용 가능하면 표준 마이그레이션을 사용하세요.",
1296
+
"requirementsTitle": "필요한 것",
1297
+
"requirement1": "저장소의 CAR 파일 백업",
1298
+
"requirement2": "회전 키 (DID의 개인 키)",
1299
+
"requirement3": "당신의 DID (did:plc:xxx)",
1300
+
"understand": "이해하고 계속 진행합니다"
1258
1301
},
1259
-
"targetPds": {
1260
-
"title": "대상 PDS 선택",
1261
-
"desc": "마이그레이션할 PDS의 URL을 입력하세요.",
1262
-
"url": "PDS URL",
1263
-
"urlPlaceholder": "https://pds.example.com",
1264
-
"validate": "확인 및 계속",
1265
-
"validating": "확인 중...",
1266
-
"connected": "{name}에 연결됨",
1267
-
"inviteRequired": "초대 코드 필요",
1268
-
"privacyPolicy": "개인정보 처리방침",
1269
-
"termsOfService": "서비스 약관"
1302
+
"provideDid": {
1303
+
"title": "DID 입력",
1304
+
"desc": "복원할 계정의 DID를 입력하세요.",
1305
+
"label": "당신의 DID",
1306
+
"hint": "분산 식별자 (예: did:plc:abc123)"
1270
1307
},
1271
-
"newAccount": {
1272
-
"title": "새 계정 세부 정보",
1273
-
"desc": "새 PDS에서 계정을 설정합니다.",
1274
-
"handle": "핸들",
1275
-
"availableDomains": "사용 가능한 도메인",
1276
-
"email": "이메일",
1277
-
"password": "비밀번호",
1278
-
"confirmPassword": "비밀번호 확인",
1279
-
"inviteCode": "초대 코드"
1308
+
"uploadCar": {
1309
+
"title": "CAR 파일 업로드",
1310
+
"desc": "저장소 백업 파일을 업로드하세요.",
1311
+
"label": "CAR 파일",
1312
+
"hint": "백업에서 .car 파일을 선택하세요",
1313
+
"reuploadWarningTitle": "CAR 파일 필요",
1314
+
"reuploadWarning": "세션이 복원되었지만 CAR 파일을 다시 업로드해야 합니다. 보안상의 이유로 파일 내용은 세션 간에 저장되지 않습니다."
1280
1315
},
1281
-
"review": {
1282
-
"title": "마이그레이션 검토",
1283
-
"desc": "마이그레이션 세부 정보를 검토하고 확인하세요.",
1284
-
"currentHandle": "현재 핸들",
1285
-
"newHandle": "새 핸들",
1286
-
"sourcePds": "이 PDS",
1287
-
"targetPds": "대상 PDS",
1288
-
"confirm": "계정 마이그레이션을 확인합니다",
1289
-
"startMigration": "마이그레이션 시작"
1316
+
"rotationKey": {
1317
+
"title": "회전 키 제공",
1318
+
"desc": "이 DID의 소유권을 증명하기 위해 회전 키를 입력하세요.",
1319
+
"securityWarningTitle": "보안 경고",
1320
+
"securityWarning1": "회전 키는 매우 민감합니다 - 마스터 비밀번호처럼 취급하세요",
1321
+
"securityWarning2": "신뢰할 수 있는 장치와 네트워크에서만 입력하세요",
1322
+
"securityWarning3": "이 키는 마이그레이션 완료 후 저장되지 않습니다",
1323
+
"label": "회전 키",
1324
+
"placeholder": "개인 키 입력 (hex, base58 또는 JWK)",
1325
+
"hint": "DID 문서의 회전 키 중 하나에 해당하는 개인 키",
1326
+
"valid": "키가 유효하고 DID의 회전 키와 일치합니다",
1327
+
"invalid": "키가 DID 문서의 어떤 회전 키와도 일치하지 않습니다",
1328
+
"validating": "키 검증 중...",
1329
+
"validate": "키 검증"
1290
1330
},
1291
-
"migrating": {
1292
-
"title": "계정 마이그레이션 중",
1293
-
"desc": "데이터를 전송하는 중입니다..."
1331
+
"chooseHandle": {
1332
+
"migratingDid": "DID 복원 중"
1294
1333
},
1295
-
"plcToken": {
1296
-
"title": "신원 확인",
1297
-
"desc": "이메일로 인증 코드가 전송되었습니다."
1334
+
"review": {
1335
+
"desc": "오프라인 복원 세부 정보를 확인하세요.",
1336
+
"carFile": "CAR 파일",
1337
+
"rotationKey": "회전 키",
1338
+
"warning": "복원을 시작하면 아이덴티티가 이 PDS를 가리키도록 업데이트됩니다. 이것은 쉽게 되돌릴 수 없습니다.",
1339
+
"plcWarningTitle": "되돌릴 수 없는 지점",
1340
+
"plcWarning": "시작하면 DID 문서가 이 PDS를 가리키도록 업데이트됩니다. 문제가 발생하면 회전 키를 사용하여 복구할 수 있지만, 손상된 아이덴티티 상태를 피하려면 마이그레이션을 완료해야 합니다."
1298
1341
},
1299
-
"finalizing": {
1300
-
"title": "마이그레이션 완료 중",
1301
-
"desc": "마이그레이션을 완료하는 중입니다...",
1302
-
"updatingForwarding": "DID 문서 포워딩 업데이트 중..."
1342
+
"migrating": {
1343
+
"title": "계정 복원 중",
1344
+
"desc": "계정을 복원하는 중입니다...",
1345
+
"creating": "계정 생성 중",
1346
+
"importing": "저장소 가져오는 중",
1347
+
"plcSigning": "아이덴티티 업데이트 중",
1348
+
"activating": "계정 활성화 중"
1303
1349
},
1304
1350
"success": {
1305
-
"title": "마이그레이션 완료!",
1306
-
"desc": "계정이 새 PDS로 성공적으로 마이그레이션되었습니다.",
1307
-
"newHandle": "새 핸들",
1308
-
"newPds": "새 PDS",
1309
-
"nextSteps": "다음 단계",
1310
-
"nextSteps1": "새 PDS에 로그인",
1311
-
"nextSteps2": "새 인증 정보로 앱 업데이트",
1312
-
"nextSteps3": "팔로워가 자동으로 새 위치를 확인할 수 있습니다",
1313
-
"loggingOut": "{seconds}초 후 로그아웃됩니다..."
1351
+
"desc": "계정이 이 PDS에 성공적으로 복원되었습니다."
1352
+
},
1353
+
"blobs": {
1354
+
"title": "Blob 마이그레이션 중",
1355
+
"desc": "이전 PDS에서 이미지와 미디어를 복구하는 중...",
1356
+
"migrating": "Blob 마이그레이션 중",
1357
+
"failedTitle": "일부 Blob을 마이그레이션할 수 없음",
1358
+
"failedDesc": "{count}개의 Blob을 이전 PDS에서 가져올 수 없습니다. 서버에 연결할 수 없거나 파일이 삭제되었을 수 있습니다.",
1359
+
"sourceUnreachableTitle": "원본 PDS에 연결할 수 없음",
1360
+
"sourceUnreachable": "이전 PDS에 연결하여 미디어 파일을 가져올 수 없습니다. 종료된 서버에서 마이그레이션할 때 흔히 발생합니다. 게시물은 작동하지만 일부 이미지가 누락될 수 있습니다."
1314
1361
}
1315
1362
},
1316
1363
"progress": {
+147
-100
frontend/src/locales/sv.json
+147
-100
frontend/src/locales/sv.json
···
17
17
"dashboard": "Kontrollpanel",
18
18
"backToDashboard": "← Kontrollpanel",
19
19
"copied": "Kopierat!",
20
-
"copyToClipboard": "Kopiera"
20
+
"copyToClipboard": "Kopiera",
21
+
"verifying": "Verifierar...",
22
+
"saving": "Sparar...",
23
+
"creating": "Skapar...",
24
+
"updating": "Uppdaterar...",
25
+
"sending": "Skickar...",
26
+
"authenticating": "Autentiserar...",
27
+
"checking": "Kontrollerar...",
28
+
"redirecting": "Omdirigerar...",
29
+
"signIn": "Logga in",
30
+
"verify": "Verifiera",
31
+
"remove": "Ta bort",
32
+
"revoke": "Återkalla",
33
+
"resendCode": "Skicka kod igen",
34
+
"startOver": "Börja om",
35
+
"tryAgain": "Försök igen",
36
+
"password": "Lösenord",
37
+
"email": "E-post",
38
+
"emailAddress": "E-postadress",
39
+
"handle": "Användarnamn",
40
+
"did": "DID",
41
+
"verificationCode": "Verifieringskod",
42
+
"inviteCode": "Inbjudningskod",
43
+
"newPassword": "Nytt lösenord",
44
+
"confirmPassword": "Bekräfta lösenord",
45
+
"enterSixDigitCode": "Ange 6-siffrig kod",
46
+
"passwordHint": "Minst 8 tecken",
47
+
"enterPassword": "Ange ditt lösenord",
48
+
"emailPlaceholder": "du@exempel.se",
49
+
"verified": "Verifierad",
50
+
"disabled": "Inaktiverad",
51
+
"available": "Tillgänglig",
52
+
"deactivated": "Avaktiverad",
53
+
"unverified": "Overifierad",
54
+
"backToLogin": "Tillbaka till inloggning",
55
+
"backToSettings": "Tillbaka till inställningar",
56
+
"alreadyHaveAccount": "Har du redan ett konto?",
57
+
"createAccount": "Skapa konto",
58
+
"passwordsMismatch": "Lösenorden matchar inte",
59
+
"passwordTooShort": "Lösenordet måste vara minst 8 tecken"
21
60
},
22
61
"login": {
23
62
"title": "Logga in",
···
49
88
"codeLabel": "Verifieringskod",
50
89
"codePlaceholder": "Ange 6-siffrig kod",
51
90
"verifyButton": "Verifiera konto",
52
-
"verifying": "Verifierar...",
53
-
"resendButton": "Skicka kod igen",
54
-
"resending": "Skickar igen...",
55
-
"resent": "Verifieringskod skickad igen!",
56
-
"backToLogin": "Tillbaka till inloggning"
91
+
"resent": "Verifieringskod skickad igen!"
57
92
},
58
93
"register": {
59
94
"title": "Skapa konto",
···
124
159
"inviteCodePlaceholder": "Ange din inbjudningskod",
125
160
"inviteCodeRequired": "krävs",
126
161
"createButton": "Skapa konto",
127
-
"creating": "Skapar konto...",
128
162
"alreadyHaveAccount": "Har du redan ett konto?",
129
163
"signIn": "Logga in",
130
164
"wantPasswordless": "Vill du ha lösenordsfri säkerhet?",
···
179
213
"navAdminDesc": "Serverstatistik och administratörsoperationer",
180
214
"navDidDocument": "DID-dokument",
181
215
"navDidDocumentDesc": "Hantera ditt DID-dokument och nycklar",
216
+
"navDidDocumentDescActive": "Redigera dina DID-dokumentinställningar",
217
+
"navBackup": "Ladda ner säkerhetskopia",
218
+
"navBackupDesc": "Ladda ner ditt dataförvar som en CAR-fil",
219
+
"downloadingBackup": "Laddar ner...",
220
+
"backupFailed": "Kunde inte ladda ner säkerhetskopia",
182
221
"migrated": "Flyttad",
183
222
"migratedTitle": "Konto flyttat",
184
223
"migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande här.",
···
208
247
"serviceEndpointDesc": "PDS som för närvarande lagrar din kontodata. Uppdatera detta vid migrering.",
209
248
"currentPds": "Nuvarande PDS-URL",
210
249
"save": "Spara ändringar",
211
-
"saving": "Sparar...",
212
250
"success": "DID-dokumentet har uppdaterats",
213
251
"saveFailed": "Kunde inte spara DID-dokument",
214
252
"loadFailed": "Kunde inte ladda DID-dokument",
···
246
284
"yourDomain": "Din domän",
247
285
"yourDomainPlaceholder": "exempel.se",
248
286
"verifyAndUpdate": "Verifiera och uppdatera användarnamn",
249
-
"verifying": "Verifierar...",
250
287
"newHandle": "Nytt användarnamn",
251
288
"newHandlePlaceholder": "dittanvändarnamn",
252
289
"changeHandleButton": "Ändra användarnamn",
···
262
299
"exportData": "Exportera data",
263
300
"exportDataDescription": "Ladda ner hela ditt arkiv som en CAR-fil (Content Addressable Archive). Detta inkluderar alla dina inlägg, gillanden, följningar och annan data.",
264
301
"downloadRepo": "Ladda ner arkiv",
302
+
"downloadBlobs": "Ladda ner media",
265
303
"exporting": "Exporterar...",
304
+
"backups": {
305
+
"title": "Säkerhetskopior",
306
+
"description": "Hantera automatiska säkerhetskopior och återställ din kontodata. Säkerhetskopior inkluderar alla poster och blobbar.",
307
+
"enableAutomatic": "Automatiska säkerhetskopior",
308
+
"enabled": "Aktiverad",
309
+
"disabled": "Inaktiverad",
310
+
"toggleFailed": "Kunde inte ändra säkerhetskopieringsinställning",
311
+
"noBackups": "Inga säkerhetskopior ännu",
312
+
"blocks": "block",
313
+
"download": "Ladda ner",
314
+
"delete": "Radera",
315
+
"createNow": "Skapa säkerhetskopia nu",
316
+
"created": "Säkerhetskopia skapad",
317
+
"createFailed": "Kunde inte skapa säkerhetskopia",
318
+
"downloadFailed": "Kunde inte ladda ner säkerhetskopia",
319
+
"deleted": "Säkerhetskopia raderad",
320
+
"deleteFailed": "Kunde inte radera säkerhetskopia",
321
+
"restoreTitle": "Återställ från säkerhetskopia",
322
+
"restoreDescription": "Återställ din kontodata från en tidigare exporterad CAR-fil. Detta ersätter ditt nuvarande dataförvar med den uppladdade säkerhetskopian.",
323
+
"selectFile": "Välj CAR-fil",
324
+
"selectedFile": "Vald fil",
325
+
"restore": "Återställ säkerhetskopia",
326
+
"restoring": "Återställer...",
327
+
"restored": "Säkerhetskopia återställd",
328
+
"restoreFailed": "Kunde inte återställa säkerhetskopia"
329
+
},
266
330
"deleteAccount": "Radera konto",
267
331
"deleteWarning": "Denna åtgärd är oåterkallelig. All din data kommer att raderas permanent.",
268
332
"requestDeletion": "Begär kontoradering",
···
291
355
"deleteConfirmation": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras.",
292
356
"deletionFailed": "Kunde inte radera kontot",
293
357
"repoExported": "Arkiv exporterat",
294
-
"exportFailed": "Kunde inte exportera arkiv",
358
+
"blobsExported": "Mediafiler exporterade",
359
+
"noBlobsToExport": "Inga mediafiler att exportera",
360
+
"exportFailed": "Export misslyckades",
295
361
"confirmDelete": "Är du helt säker på att du vill radera ditt konto? Detta kan inte ångras."
296
362
}
297
363
},
···
306
372
"noPasswords": "Inga applösenord ännu",
307
373
"revoke": "Återkalla",
308
374
"revoking": "Återkallar...",
309
-
"creating": "Skapar...",
310
375
"revokeConfirm": "Återkalla applösenord \"{name}\"? Appar som använder detta lösenord kommer inte längre att kunna komma åt ditt konto.",
311
376
"saveWarningTitle": "Viktigt: Spara detta applösenord!",
312
377
"saveWarningMessage": "Detta lösenord krävs för att logga in i appar som inte stöder passkeys eller OAuth. Du ser det bara en gång.",
···
354
419
"used": "Använd av @{handle}",
355
420
"disabled": "Inaktiverad",
356
421
"usedBy": "Använd av",
357
-
"creating": "Skapar...",
358
422
"disableConfirm": "Inaktivera denna inbjudningskod? Den kan inte längre användas.",
359
423
"created": "Inbjudningskod skapad",
360
424
"copy": "Kopiera",
···
482
546
"verifyButton": "Verifiera",
483
547
"verifyCodePlaceholder": "Ange verifieringskod",
484
548
"submit": "Skicka",
485
-
"saving": "Sparar...",
486
549
"savePreferences": "Spara inställningar",
487
550
"preferencesSaved": "Kommunikationsinställningar sparade",
488
551
"verifiedSuccess": "{channel} verifierad",
···
521
584
"noCollectionsYet": "Inga samlingar ännu. Skapa din första post för att komma igång.",
522
585
"loadMore": "Ladda fler",
523
586
"recordJson": "Post-JSON",
524
-
"saving": "Sparar...",
525
587
"updateRecord": "Uppdatera post",
526
588
"collectionNsid": "Samling (NSID)",
527
589
"recordKeyOptional": "Postnyckel (valfri)",
528
590
"autoGenerated": "Genereras automatiskt om tom (TID)",
529
591
"autoGeneratedHint": "Lämna tom för att automatiskt generera en TID-baserad nyckel",
530
-
"creating": "Skapar...",
531
592
"demoPostText": "Hej från min PDS! Detta är mitt första inlägg.",
532
593
"demoDisplayName": "Ditt visningsnamn",
533
594
"demoBio": "En kort presentation om dig själv."
···
548
609
"primaryLight": "Primär (ljust läge)",
549
610
"primaryDark": "Primär (mörkt läge)",
550
611
"configSaved": "Serverkonfiguration sparad",
551
-
"saving": "Sparar...",
552
612
"saveConfig": "Spara konfiguration",
553
613
"serverStats": "Serverstatistik",
554
614
"users": "Användare",
···
639
699
"title": "Tvåfaktorsautentisering",
640
700
"subtitle": "Ytterligare verifiering krävs",
641
701
"usePasskey": "Använd nyckel",
642
-
"useTotp": "Använd autentiseringsapp",
643
-
"verifying": "Verifierar..."
702
+
"useTotp": "Använd autentiseringsapp"
644
703
},
645
704
"twoFactorCode": {
646
705
"title": "Tvåfaktorsautentisering",
647
706
"subtitle": "En verifieringskod har skickats till din {channel}. Ange koden nedan för att fortsätta.",
648
707
"codeLabel": "Verifieringskod",
649
708
"codePlaceholder": "Ange 6-siffrig kod",
650
-
"verify": "Verifiera",
651
-
"verifying": "Verifierar...",
652
709
"errors": {
653
710
"missingRequestUri": "Saknar request_uri-parameter",
654
711
"verificationFailed": "Verifiering misslyckades",
···
660
717
"title": "Ange autentiseringskod",
661
718
"subtitle": "Ange den 6-siffriga koden från din autentiseringsapp",
662
719
"codePlaceholder": "Ange 6-siffrig kod",
663
-
"verify": "Verifiera",
664
-
"verifying": "Verifierar...",
665
720
"useBackupCode": "Använd reservkod istället",
666
721
"backupCodePlaceholder": "Ange reservkod",
667
722
"trustDevice": "Lita på denna enhet i 30 dagar",
···
691
746
"codeLabel": "Verifieringskod",
692
747
"codeHelp": "Kopiera hela koden från ditt meddelande, inklusive bindestreck",
693
748
"verifyButton": "Verifiera konto",
694
-
"verify": "Verifiera",
695
-
"verifying": "Verifierar...",
696
749
"pleaseWait": "Vänta...",
697
-
"sending": "Skickar...",
698
-
"resendCode": "Skicka kod igen",
699
-
"resending": "Skickar igen...",
700
750
"codeResent": "Verifieringskod skickad igen!",
701
751
"codeResentDetail": "Verifieringskod skickad! Kontrollera din inkorg.",
702
752
"verified": "Verifierad!",
···
706
756
"identifierLabel": "E-post eller identifierare",
707
757
"identifierPlaceholder": "du@exempel.se",
708
758
"identifierHelp": "E-postadressen eller identifieraren koden skickades till",
709
-
"backToLogin": "Tillbaka till inloggning",
710
759
"verifyingAccount": "Verifierar konto: @{handle}",
711
760
"startOver": "Börja om med ett annat konto",
712
761
"noPending": "Ingen väntande verifiering hittades.",
713
762
"noPendingInfo": "Om du nyligen skapade ett konto och behöver verifiera det kan du behöva skapa ett nytt konto. Om du redan verifierat ditt konto kan du logga in.",
714
763
"createAccount": "Skapa konto",
715
764
"signIn": "Logga in",
716
-
"backToSettings": "Tillbaka till inställningar",
717
765
"emailUpdateCodeHelp": "Koden skickades till din nuvarande e-postadress",
718
766
"emailUpdateFailed": "Kunde inte uppdatera e-postadress",
719
767
"emailUpdateRequiresAuth": "Du måste vara inloggad för att uppdatera din e-postadress.",
···
746
794
"resetButton": "Återställ lösenord",
747
795
"resetting": "Återställer...",
748
796
"success": "Lösenord återställt!",
749
-
"backToLogin": "Tillbaka till inloggning",
750
797
"requestNewCode": "Begär ny kod",
751
798
"passwordsMismatch": "Lösenorden matchar inte",
752
799
"passwordLength": "Lösenordet måste vara minst 8 tecken"
···
790
837
"howItWorks": "Så fungerar det",
791
838
"howItWorksDetail": "Vi skickar en säker länk till din registrerade meddelandekanal. Klicka på länken för att ställa in ett tillfälligt lösenord. Sedan kan du logga in och lägga till en ny nyckel.",
792
839
"sendRecoveryLink": "Skicka återställningslänk",
793
-
"sending": "Skickar...",
794
-
"backToLogin": "Tillbaka till inloggning"
840
+
"sending": "Skickar..."
795
841
},
796
842
"registerPasskey": {
797
843
"title": "Skapa nyckelkonto",
···
812
858
"externalDid": "Din did:web",
813
859
"externalDidPlaceholder": "did:web:dindomän.se",
814
860
"createButton": "Skapa konto",
815
-
"creating": "Skapar...",
816
861
"alreadyHaveAccount": "Har du redan ett konto?",
817
862
"signIn": "Logga in",
818
863
"wantPassword": "Vill du använda ett lösenord?",
···
911
956
"useTotp": "Använd autentiserare",
912
957
"passwordPlaceholder": "Ange ditt lösenord",
913
958
"totpPlaceholder": "Ange 6-siffrig kod",
914
-
"verify": "Verifiera",
915
-
"verifying": "Verifierar...",
916
959
"authenticating": "Autentiserar...",
917
960
"passkeyPrompt": "Klicka på knappen nedan för att autentisera med din passkey.",
918
961
"cancel": "Avbryt"
···
985
1028
"createAccount": "Skapa konto",
986
1029
"createDelegatedAccount": "Skapa delegerat konto",
987
1030
"createDelegatedAccountButton": "+ Skapa delegerat konto",
988
-
"creating": "Skapar...",
989
1031
"emailOptional": "E-post (valfritt)",
990
1032
"failedToAddController": "Kunde inte lägga till kontrollant",
991
1033
"failedToCreateAccount": "Kunde inte skapa delegerat konto",
···
1059
1101
"navDesc": "Flytta ditt konto till eller från en annan PDS",
1060
1102
"migrateHere": "Flytta hit",
1061
1103
"migrateHereDesc": "Flytta ditt befintliga AT Protocol-konto till denna PDS från en annan server.",
1062
-
"migrateAway": "Flytta bort",
1063
-
"migrateAwayDesc": "Flytta ditt konto från denna PDS till en annan server.",
1064
-
"loginRequired": "Inloggning krävs",
1065
1104
"bringDid": "Ta med din DID och identitet",
1066
1105
"transferData": "Överför all din data",
1067
1106
"keepFollowers": "Behåll dina följare",
1068
-
"exportRepo": "Exportera ditt arkiv",
1069
-
"transferToPds": "Överför till ny PDS",
1070
-
"updateIdentity": "Uppdatera din identitet",
1071
1107
"whatIsMigration": "Vad är kontoflyttning?",
1072
1108
"whatIsMigrationDesc": "Kontoflyttning låter dig flytta din AT Protocol-identitet mellan personliga dataservrar (PDS). Din DID (decentraliserad identifierare) förblir densamma, så dina följare och sociala kopplingar bevaras.",
1073
1109
"beforeMigrate": "Innan du flyttar",
···
1077
1113
"beforeMigrate4": "Din gamla PDS kommer att meddelas om kontoinaktivering",
1078
1114
"importantWarning": "Kontoflyttning är en betydande åtgärd. Se till att du litar på mål-PDS och förstår att din data kommer att flyttas. Om något går fel kan manuell återställning krävas.",
1079
1115
"learnMore": "Läs mer om flyttningsrisker",
1080
-
"comingSoon": "Kommer snart",
1116
+
"offlineRestore": "Offline-återställning",
1117
+
"offlineRestoreDesc": "Återställ från backup när din gamla PDS inte är tillgänglig.",
1118
+
"offlineFeature1": "Använd en CAR-fil backup",
1119
+
"offlineFeature2": "Bevisa ägande med rotationsnyckel",
1120
+
"offlineFeature3": "Återställning för nedstängda servrar",
1081
1121
"oauthCompleting": "Slutför autentisering...",
1082
1122
"oauthFailed": "Autentisering misslyckades",
1083
1123
"tryAgain": "Försök igen",
···
1086
1126
"incomplete": "Du har en ofullständig flytt pågående:",
1087
1127
"direction": "Riktning",
1088
1128
"migratingHere": "Flyttar hit",
1089
-
"migratingAway": "Flyttar bort",
1090
1129
"from": "Från",
1091
1130
"to": "Till",
1092
1131
"progress": "Framsteg",
···
1229
1268
"error": {
1230
1269
"title": "Flyttfel",
1231
1270
"desc": "Ett fel uppstod under flytten.",
1232
-
"startOver": "Börja om"
1271
+
"startOver": "Börja om",
1272
+
"unknown": "Ett okänt fel uppstod."
1233
1273
},
1234
1274
"common": {
1235
1275
"back": "Tillbaka",
···
1247
1287
"warning3": "Ditt gamla konto kommer att inaktiveras efter flytten"
1248
1288
}
1249
1289
},
1250
-
"outbound": {
1290
+
"offline": {
1251
1291
"welcome": {
1252
-
"title": "Flytta från denna PDS",
1253
-
"desc": "Flytta ditt konto till en annan personlig dataserver.",
1254
-
"warning": "Efter flytten kommer ditt konto här att inaktiveras.",
1255
-
"didWebNotice": "did:web-flyttmeddelande",
1256
-
"didWebNoticeDesc": "Ditt konto använder en did:web-identifierare ({did}). Efter flytten kommer denna PDS att fortsätta servera ditt DID-dokument som pekar till den nya PDS. Din identitet kommer att fungera så länge denna server är online.",
1257
-
"understand": "Jag förstår riskerna och vill fortsätta"
1292
+
"title": "Återställ från backup",
1293
+
"desc": "Återställ ditt konto med en CAR-fil backup och rotationsnyckel. Använd detta när din tidigare PDS inte är tillgänglig.",
1294
+
"warningTitle": "När du ska använda denna metod",
1295
+
"warningDesc": "Denna offline-återställning är för katastrofåterställning när din gamla PDS har stängts ner, är oåtkomlig eller du blev utelåst. Om din gamla PDS fortfarande är tillgänglig, använd standardflytten istället.",
1296
+
"requirementsTitle": "Du behöver",
1297
+
"requirement1": "En CAR-fil backup av ditt arkiv",
1298
+
"requirement2": "Din rotationsnyckel (privat nyckel för ditt DID)",
1299
+
"requirement3": "Ditt DID (did:plc:xxx)",
1300
+
"understand": "Jag förstår och vill fortsätta"
1258
1301
},
1259
-
"targetPds": {
1260
-
"title": "Välj mål-PDS",
1261
-
"desc": "Ange URL:en för PDS du vill flytta till.",
1262
-
"url": "PDS URL",
1263
-
"urlPlaceholder": "https://pds.example.com",
1264
-
"validate": "Validera och fortsätt",
1265
-
"validating": "Validerar...",
1266
-
"connected": "Ansluten till {name}",
1267
-
"inviteRequired": "Inbjudningskod krävs",
1268
-
"privacyPolicy": "Integritetspolicy",
1269
-
"termsOfService": "Användarvillkor"
1302
+
"provideDid": {
1303
+
"title": "Ange ditt DID",
1304
+
"desc": "Ange DID för kontot du vill återställa.",
1305
+
"label": "Ditt DID",
1306
+
"hint": "Din decentraliserade identifierare (t.ex. did:plc:abc123)"
1270
1307
},
1271
-
"newAccount": {
1272
-
"title": "Nya kontouppgifter",
1273
-
"desc": "Konfigurera ditt konto på den nya PDS.",
1274
-
"handle": "Användarnamn",
1275
-
"availableDomains": "Tillgängliga domäner",
1276
-
"email": "E-post",
1277
-
"password": "Lösenord",
1278
-
"confirmPassword": "Bekräfta lösenord",
1279
-
"inviteCode": "Inbjudningskod"
1308
+
"uploadCar": {
1309
+
"title": "Ladda upp CAR-fil",
1310
+
"desc": "Ladda upp din arkiv-backupfil.",
1311
+
"label": "CAR-fil",
1312
+
"hint": "Välj .car-filen från din backup",
1313
+
"reuploadWarningTitle": "CAR-fil krävs",
1314
+
"reuploadWarning": "Din session har återställts, men du måste ladda upp din CAR-fil igen. Av säkerhetsskäl lagras inte filinnehåll mellan sessioner."
1280
1315
},
1281
-
"review": {
1282
-
"title": "Granska flytt",
1283
-
"desc": "Granska och bekräfta dina flyttdetaljer.",
1284
-
"currentHandle": "Nuvarande användarnamn",
1285
-
"newHandle": "Nytt användarnamn",
1286
-
"sourcePds": "Denna PDS",
1287
-
"targetPds": "Mål-PDS",
1288
-
"confirm": "Jag bekräftar att jag vill flytta mitt konto",
1289
-
"startMigration": "Starta flytt"
1316
+
"rotationKey": {
1317
+
"title": "Ange rotationsnyckel",
1318
+
"desc": "Ange din rotationsnyckel för att bevisa ägande av detta DID.",
1319
+
"securityWarningTitle": "Säkerhetsvarning",
1320
+
"securityWarning1": "Din rotationsnyckel är extremt känslig - behandla den som ett huvudlösenord",
1321
+
"securityWarning2": "Ange den endast på betrodda enheter och nätverk",
1322
+
"securityWarning3": "Denna nyckel kommer inte att lagras efter att flytten slutförts",
1323
+
"label": "Rotationsnyckel",
1324
+
"placeholder": "Ange privat nyckel (hex, base58 eller JWK)",
1325
+
"hint": "Den privata nyckeln som motsvarar en av rotationsnycklarna i ditt DID-dokument",
1326
+
"valid": "Nyckeln är giltig och matchar en rotationsnyckel i ditt DID",
1327
+
"invalid": "Nyckeln matchar inte någon rotationsnyckel i ditt DID-dokument",
1328
+
"validating": "Validerar nyckel...",
1329
+
"validate": "Validera nyckel"
1290
1330
},
1291
-
"migrating": {
1292
-
"title": "Flyttar ditt konto",
1293
-
"desc": "Vänta medan vi överför din data..."
1331
+
"chooseHandle": {
1332
+
"migratingDid": "Återställer DID"
1294
1333
},
1295
-
"plcToken": {
1296
-
"title": "Verifiera din identitet",
1297
-
"desc": "En verifieringskod har skickats till din e-post."
1334
+
"review": {
1335
+
"desc": "Granska dina offline-återställningsuppgifter.",
1336
+
"carFile": "CAR-fil",
1337
+
"rotationKey": "Rotationsnyckel",
1338
+
"warning": "När du startar återställningen kommer din identitet att uppdateras för att peka på denna PDS. Detta kan inte enkelt ångras.",
1339
+
"plcWarningTitle": "Ingen återvändo",
1340
+
"plcWarning": "När du startar kommer ditt DID-dokument att uppdateras för att peka på denna PDS. Om något går fel kan du använda din rotationsnyckel för att återställa, men du bör slutföra flytten för att undvika ett trasigt identitetstillstånd."
1298
1341
},
1299
-
"finalizing": {
1300
-
"title": "Slutför flytt",
1301
-
"desc": "Vänta medan vi slutför flytten...",
1302
-
"updatingForwarding": "Uppdaterar DID-dokumentvidarebefordran..."
1342
+
"migrating": {
1343
+
"title": "Återställer konto",
1344
+
"desc": "Vänta medan ditt konto återställs...",
1345
+
"creating": "Skapar konto",
1346
+
"importing": "Importerar arkiv",
1347
+
"plcSigning": "Uppdaterar identitet",
1348
+
"activating": "Aktiverar konto"
1303
1349
},
1304
1350
"success": {
1305
-
"title": "Flytt klar!",
1306
-
"desc": "Ditt konto har framgångsrikt flyttats till din nya PDS.",
1307
-
"newHandle": "Nytt användarnamn",
1308
-
"newPds": "Ny PDS",
1309
-
"nextSteps": "Nästa steg",
1310
-
"nextSteps1": "Logga in på din nya PDS",
1311
-
"nextSteps2": "Uppdatera dina appar med nya uppgifter",
1312
-
"nextSteps3": "Dina följare kommer automatiskt se din nya plats",
1313
-
"loggingOut": "Loggar ut om {seconds} sekunder..."
1351
+
"desc": "Ditt konto har framgångsrikt återställts till denna PDS."
1352
+
},
1353
+
"blobs": {
1354
+
"title": "Flyttar blobbar",
1355
+
"desc": "Försöker återställa bilder och media från din gamla PDS...",
1356
+
"migrating": "Flyttar blobbar",
1357
+
"failedTitle": "Vissa blobbar kunde inte flyttas",
1358
+
"failedDesc": "{count} blobbar kunde inte hämtas från din gamla PDS. Detta kan hända om servern är otillgänglig eller om filerna raderades.",
1359
+
"sourceUnreachableTitle": "Käll-PDS otillgänglig",
1360
+
"sourceUnreachable": "Kunde inte ansluta till din gamla PDS för att hämta mediafiler. Detta är vanligt vid flytt från en nedstängd server. Dina inlägg kommer att fungera, men vissa bilder kan saknas."
1314
1361
}
1315
1362
},
1316
1363
"progress": {
+147
-100
frontend/src/locales/zh.json
+147
-100
frontend/src/locales/zh.json
···
17
17
"dashboard": "控制台",
18
18
"backToDashboard": "← 返回控制台",
19
19
"copied": "已复制!",
20
-
"copyToClipboard": "复制"
20
+
"copyToClipboard": "复制",
21
+
"verifying": "验证中...",
22
+
"saving": "保存中...",
23
+
"creating": "创建中...",
24
+
"updating": "更新中...",
25
+
"sending": "发送中...",
26
+
"authenticating": "认证中...",
27
+
"checking": "检查中...",
28
+
"redirecting": "跳转中...",
29
+
"signIn": "登录",
30
+
"verify": "验证",
31
+
"remove": "移除",
32
+
"revoke": "撤销",
33
+
"resendCode": "重新发送验证码",
34
+
"startOver": "重新开始",
35
+
"tryAgain": "重试",
36
+
"password": "密码",
37
+
"email": "邮箱",
38
+
"emailAddress": "邮箱地址",
39
+
"handle": "用户名",
40
+
"did": "DID",
41
+
"verificationCode": "验证码",
42
+
"inviteCode": "邀请码",
43
+
"newPassword": "新密码",
44
+
"confirmPassword": "确认密码",
45
+
"enterSixDigitCode": "输入6位验证码",
46
+
"passwordHint": "至少8个字符",
47
+
"enterPassword": "请输入密码",
48
+
"emailPlaceholder": "you@example.com",
49
+
"verified": "已验证",
50
+
"disabled": "已禁用",
51
+
"available": "可用",
52
+
"deactivated": "已停用",
53
+
"unverified": "未验证",
54
+
"backToLogin": "返回登录",
55
+
"backToSettings": "返回设置",
56
+
"alreadyHaveAccount": "已有账户?",
57
+
"createAccount": "立即注册",
58
+
"passwordsMismatch": "密码不匹配",
59
+
"passwordTooShort": "密码至少需要8个字符"
21
60
},
22
61
"login": {
23
62
"title": "登录",
···
49
88
"codeLabel": "验证码",
50
89
"codePlaceholder": "输入6位验证码",
51
90
"verifyButton": "验证账户",
52
-
"verifying": "验证中...",
53
-
"resendButton": "重新发送验证码",
54
-
"resending": "发送中...",
55
-
"resent": "验证码已重新发送!",
56
-
"backToLogin": "返回登录"
91
+
"resent": "验证码已重新发送!"
57
92
},
58
93
"register": {
59
94
"title": "创建账户",
···
124
159
"inviteCodePlaceholder": "输入您的邀请码",
125
160
"inviteCodeRequired": "必填",
126
161
"createButton": "创建账户",
127
-
"creating": "正在创建...",
128
162
"alreadyHaveAccount": "已有账户?",
129
163
"signIn": "立即登录",
130
164
"wantPasswordless": "想要无密码登录?",
···
179
213
"navAdminDesc": "服务器统计和管理操作",
180
214
"navDidDocument": "DID 文档",
181
215
"navDidDocumentDesc": "管理您的 DID 文档和密钥",
216
+
"navDidDocumentDescActive": "编辑您的 DID 文档设置",
217
+
"navBackup": "下载备份",
218
+
"navBackupDesc": "将您的存储库下载为 CAR 文件",
219
+
"downloadingBackup": "下载中...",
220
+
"backupFailed": "下载备份失败",
182
221
"migrated": "已迁移",
183
222
"migratedTitle": "账户已迁移",
184
223
"migratedMessage": "您的账户已迁移到 {pds}。您的 DID 文档仍在此处托管。",
···
208
247
"serviceEndpointDesc": "当前托管您账户数据的 PDS。迁移时请更新此项。",
209
248
"currentPds": "当前 PDS URL",
210
249
"save": "保存更改",
211
-
"saving": "保存中...",
212
250
"success": "DID 文档已更新",
213
251
"saveFailed": "保存 DID 文档失败",
214
252
"loadFailed": "加载 DID 文档失败",
···
246
284
"yourDomain": "您的域名",
247
285
"yourDomainPlaceholder": "example.com",
248
286
"verifyAndUpdate": "验证并更新用户名",
249
-
"verifying": "验证中...",
250
287
"newHandle": "新用户名",
251
288
"newHandlePlaceholder": "yourhandle",
252
289
"changeHandleButton": "更改用户名",
···
262
299
"exportData": "导出数据",
263
300
"exportDataDescription": "将您的所有数据下载为 CAR 文件。包括您的所有帖子、点赞、关注等数据。",
264
301
"downloadRepo": "下载数据",
302
+
"downloadBlobs": "下载媒体文件",
265
303
"exporting": "导出中...",
304
+
"backups": {
305
+
"title": "备份",
306
+
"description": "管理自动备份并恢复账户数据。备份包括所有记录和文件。",
307
+
"enableAutomatic": "自动备份",
308
+
"enabled": "已启用",
309
+
"disabled": "已禁用",
310
+
"toggleFailed": "更改备份设置失败",
311
+
"noBackups": "暂无备份",
312
+
"blocks": "块",
313
+
"download": "下载",
314
+
"delete": "删除",
315
+
"createNow": "立即创建备份",
316
+
"created": "备份已创建",
317
+
"createFailed": "创建备份失败",
318
+
"downloadFailed": "下载备份失败",
319
+
"deleted": "备份已删除",
320
+
"deleteFailed": "删除备份失败",
321
+
"restoreTitle": "从备份恢复",
322
+
"restoreDescription": "从之前导出的 CAR 文件恢复账户数据。这将用上传的备份替换当前的存储库。",
323
+
"selectFile": "选择 CAR 文件",
324
+
"selectedFile": "已选文件",
325
+
"restore": "恢复备份",
326
+
"restoring": "恢复中...",
327
+
"restored": "备份恢复成功",
328
+
"restoreFailed": "备份恢复失败"
329
+
},
266
330
"deleteAccount": "删除账户",
267
331
"deleteWarning": "此操作不可逆。您的所有数据将被永久删除。",
268
332
"requestDeletion": "请求删除账户",
···
291
355
"deleteConfirmation": "您确定要删除账户吗?此操作无法撤销。",
292
356
"deletionFailed": "账户删除失败",
293
357
"repoExported": "数据导出成功",
294
-
"exportFailed": "数据导出失败",
358
+
"blobsExported": "媒体文件导出成功",
359
+
"noBlobsToExport": "没有可导出的媒体文件",
360
+
"exportFailed": "导出失败",
295
361
"confirmDelete": "您确定要删除账户吗?此操作无法撤销。"
296
362
}
297
363
},
···
306
372
"noPasswords": "暂无应用专用密码",
307
373
"revoke": "撤销",
308
374
"revoking": "撤销中...",
309
-
"creating": "创建中...",
310
375
"revokeConfirm": "撤销「{name}」的密码?使用此密码的应用将无法再访问您的账户。",
311
376
"saveWarningTitle": "重要:请保存此应用专用密码!",
312
377
"saveWarningMessage": "此密码用于登录不支持通行密钥或 OAuth 的应用。您只能看到一次。",
···
354
419
"used": "已被 @{handle} 使用",
355
420
"disabled": "已禁用",
356
421
"usedBy": "使用者",
357
-
"creating": "创建中...",
358
422
"disableConfirm": "禁用此邀请码?它将无法再被使用。",
359
423
"created": "邀请码已创建",
360
424
"copy": "复制",
···
482
546
"verifyButton": "验证",
483
547
"verifyCodePlaceholder": "输入验证码",
484
548
"submit": "提交",
485
-
"saving": "保存中...",
486
549
"savePreferences": "保存偏好设置",
487
550
"preferencesSaved": "通讯偏好已保存",
488
551
"verifiedSuccess": "{channel} 验证成功",
···
521
584
"noCollectionsYet": "暂无集合。创建您的第一条记录开始使用。",
522
585
"loadMore": "加载更多",
523
586
"recordJson": "记录 JSON",
524
-
"saving": "保存中...",
525
587
"updateRecord": "更新记录",
526
588
"collectionNsid": "集合 (NSID)",
527
589
"recordKeyOptional": "记录键(可选)",
528
590
"autoGenerated": "留空自动生成 (TID)",
529
591
"autoGeneratedHint": "留空将自动生成基于 TID 的键",
530
-
"creating": "创建中...",
531
592
"demoPostText": "你好,这是我的第一条帖子!来自我的 PDS。",
532
593
"demoDisplayName": "你的显示名称",
533
594
"demoBio": "写一段简短的自我介绍。"
···
551
612
"secondaryLight": "副色(浅色模式)",
552
613
"secondaryDark": "副色(深色模式)",
553
614
"configSaved": "服务器配置已保存",
554
-
"saving": "保存中...",
555
615
"saveConfig": "保存配置",
556
616
"serverStats": "服务器统计",
557
617
"users": "用户",
···
639
699
"title": "双重身份验证",
640
700
"subtitle": "需要额外验证",
641
701
"usePasskey": "使用通行密钥",
642
-
"useTotp": "使用身份验证器",
643
-
"verifying": "验证中..."
702
+
"useTotp": "使用身份验证器"
644
703
},
645
704
"twoFactorCode": {
646
705
"title": "双重身份验证",
647
706
"subtitle": "验证码已发送到您的 {channel}。请在下方输入验证码继续。",
648
707
"codeLabel": "验证码",
649
708
"codePlaceholder": "输入6位验证码",
650
-
"verify": "验证",
651
-
"verifying": "验证中...",
652
709
"errors": {
653
710
"missingRequestUri": "缺少 request_uri 参数",
654
711
"verificationFailed": "验证失败",
···
660
717
"title": "输入验证码",
661
718
"subtitle": "请输入身份验证器应用中的6位验证码",
662
719
"codePlaceholder": "输入6位验证码",
663
-
"verify": "验证",
664
-
"verifying": "验证中...",
665
720
"useBackupCode": "使用备用验证码",
666
721
"backupCodePlaceholder": "输入备用验证码",
667
722
"trustDevice": "信任此设备30天",
···
691
746
"codeLabel": "验证码",
692
747
"codeHelp": "复制消息中的完整验证码,包括横线",
693
748
"verifyButton": "验证账户",
694
-
"verify": "验证",
695
-
"verifying": "验证中...",
696
749
"pleaseWait": "请稍候...",
697
-
"resendCode": "重新发送验证码",
698
-
"resending": "发送中...",
699
-
"sending": "发送中...",
700
750
"codeResent": "验证码已重新发送!",
701
751
"codeResentDetail": "验证码已发送!请查收。",
702
-
"backToLogin": "返回登录",
703
752
"verifyingAccount": "正在验证账户:@{handle}",
704
753
"startOver": "使用其他账户重新开始",
705
754
"noPending": "未找到待验证的账户",
···
713
762
"identifierLabel": "邮箱或标识符",
714
763
"identifierPlaceholder": "you@example.com",
715
764
"identifierHelp": "接收验证码的邮箱地址或标识符",
716
-
"backToSettings": "返回设置",
717
765
"emailUpdateCodeHelp": "验证码已发送到您当前的邮箱地址",
718
766
"emailUpdateFailed": "更新邮箱地址失败",
719
767
"emailUpdateRequiresAuth": "您需要登录才能更新邮箱地址。",
···
746
794
"resetButton": "重置密码",
747
795
"resetting": "重置中...",
748
796
"success": "密码重置成功!",
749
-
"backToLogin": "返回登录",
750
797
"requestNewCode": "重新获取验证码",
751
798
"passwordsMismatch": "两次输入的密码不一致",
752
799
"passwordLength": "密码至少需要8位字符"
···
790
837
"howItWorks": "如何恢复",
791
838
"howItWorksDetail": "我们将向您注册的通知渠道发送安全链接。点击链接设置临时密码,然后您就可以登录并添加新的通行密钥。",
792
839
"sendRecoveryLink": "发送恢复链接",
793
-
"sending": "发送中...",
794
-
"backToLogin": "返回登录"
840
+
"sending": "发送中..."
795
841
},
796
842
"registerPasskey": {
797
843
"title": "创建通行密钥账户",
···
814
860
"inviteCode": "邀请码",
815
861
"inviteCodePlaceholder": "输入您的邀请码",
816
862
"createButton": "创建账户",
817
-
"creating": "创建中...",
818
863
"continue": "继续",
819
864
"back": "返回",
820
865
"alreadyHaveAccount": "已有账户?",
···
911
956
"useTotp": "使用身份验证器",
912
957
"passwordPlaceholder": "输入您的密码",
913
958
"totpPlaceholder": "输入6位验证码",
914
-
"verify": "验证",
915
-
"verifying": "验证中...",
916
959
"authenticating": "正在验证...",
917
960
"passkeyPrompt": "点击下方按钮使用通行密钥进行验证。",
918
961
"cancel": "取消"
···
986
1029
"createAccount": "创建账户",
987
1030
"createDelegatedAccount": "创建委托账户",
988
1031
"createDelegatedAccountButton": "+ 创建委托账户",
989
-
"creating": "创建中...",
990
1032
"emailOptional": "邮箱(可选)",
991
1033
"failedToAddController": "添加控制者失败",
992
1034
"failedToCreateAccount": "创建委托账户失败",
···
1059
1101
"navDesc": "将您的账户移至其他PDS或从其他PDS移入",
1060
1102
"migrateHere": "迁移到此处",
1061
1103
"migrateHereDesc": "将您现有的AT Protocol账户从其他服务器移至此PDS。",
1062
-
"migrateAway": "迁移离开",
1063
-
"migrateAwayDesc": "将您的账户从此PDS移至其他服务器。",
1064
-
"loginRequired": "需要登录",
1065
1104
"bringDid": "携带您的DID和身份",
1066
1105
"transferData": "转移所有数据",
1067
1106
"keepFollowers": "保留您的关注者",
1068
-
"exportRepo": "导出您的存储库",
1069
-
"transferToPds": "转移到新PDS",
1070
-
"updateIdentity": "更新您的身份",
1071
1107
"whatIsMigration": "什么是账户迁移?",
1072
1108
"whatIsMigrationDesc": "账户迁移允许您在个人数据服务器(PDS)之间移动AT Protocol身份。您的DID(去中心化标识符)保持不变,因此您的关注者和社交连接得以保留。",
1073
1109
"beforeMigrate": "迁移前须知",
···
1077
1113
"beforeMigrate4": "您的旧PDS将收到账户停用通知",
1078
1114
"importantWarning": "账户迁移是一项重要操作。请确保您信任目标PDS,并了解您的数据将被移动。如果出现问题,可能需要手动恢复。",
1079
1115
"learnMore": "了解更多迁移风险",
1080
-
"comingSoon": "即将推出",
1116
+
"offlineRestore": "离线恢复",
1117
+
"offlineRestoreDesc": "当旧 PDS 不可用时从备份恢复。",
1118
+
"offlineFeature1": "使用 CAR 文件备份",
1119
+
"offlineFeature2": "使用轮换密钥证明所有权",
1120
+
"offlineFeature3": "用于已关闭服务器的恢复",
1081
1121
"oauthCompleting": "正在完成身份验证...",
1082
1122
"oauthFailed": "身份验证失败",
1083
1123
"tryAgain": "重试",
···
1086
1126
"incomplete": "您有一个未完成的迁移:",
1087
1127
"direction": "方向",
1088
1128
"migratingHere": "正在迁移到此处",
1089
-
"migratingAway": "正在迁移离开",
1090
1129
"from": "从",
1091
1130
"to": "到",
1092
1131
"progress": "进度",
···
1229
1268
"error": {
1230
1269
"title": "迁移错误",
1231
1270
"desc": "迁移过程中发生错误。",
1232
-
"startOver": "重新开始"
1271
+
"startOver": "重新开始",
1272
+
"unknown": "发生未知错误。"
1233
1273
},
1234
1274
"common": {
1235
1275
"back": "返回",
···
1247
1287
"warning3": "迁移后您的旧账户将被停用"
1248
1288
}
1249
1289
},
1250
-
"outbound": {
1290
+
"offline": {
1251
1291
"welcome": {
1252
-
"title": "从此PDS迁移离开",
1253
-
"desc": "将您的账户移至另一个个人数据服务器。",
1254
-
"warning": "迁移后,您在此处的账户将被停用。",
1255
-
"didWebNotice": "did:web迁移通知",
1256
-
"didWebNoticeDesc": "您的账户使用did:web标识符({did})。迁移后,此PDS将继续提供指向新PDS的DID文档。只要此服务器在线,您的身份将继续有效。",
1257
-
"understand": "我了解风险并希望继续"
1292
+
"title": "从备份恢复",
1293
+
"desc": "使用 CAR 文件备份和轮换密钥恢复您的账户。当您的旧 PDS 不可用时使用此方法。",
1294
+
"warningTitle": "何时使用此方法",
1295
+
"warningDesc": "此离线恢复用于灾难恢复,当您的旧 PDS 已关闭、无法访问或您被锁定时使用。如果您的旧 PDS 仍然可用,请使用标准迁移。",
1296
+
"requirementsTitle": "您需要",
1297
+
"requirement1": "您的存储库的 CAR 文件备份",
1298
+
"requirement2": "您的轮换密钥(DID 的私钥)",
1299
+
"requirement3": "您的 DID (did:plc:xxx)",
1300
+
"understand": "我了解并希望继续"
1258
1301
},
1259
-
"targetPds": {
1260
-
"title": "选择目标PDS",
1261
-
"desc": "输入您要迁移到的PDS的URL。",
1262
-
"url": "PDS URL",
1263
-
"urlPlaceholder": "https://pds.example.com",
1264
-
"validate": "验证并继续",
1265
-
"validating": "验证中...",
1266
-
"connected": "已连接到 {name}",
1267
-
"inviteRequired": "需要邀请码",
1268
-
"privacyPolicy": "隐私政策",
1269
-
"termsOfService": "服务条款"
1302
+
"provideDid": {
1303
+
"title": "输入您的 DID",
1304
+
"desc": "输入您要恢复的账户的 DID。",
1305
+
"label": "您的 DID",
1306
+
"hint": "您的去中心化标识符(例如 did:plc:abc123)"
1270
1307
},
1271
-
"newAccount": {
1272
-
"title": "新账户详情",
1273
-
"desc": "在新PDS上设置您的账户。",
1274
-
"handle": "用户名",
1275
-
"availableDomains": "可用域名",
1276
-
"email": "邮箱",
1277
-
"password": "密码",
1278
-
"confirmPassword": "确认密码",
1279
-
"inviteCode": "邀请码"
1308
+
"uploadCar": {
1309
+
"title": "上传 CAR 文件",
1310
+
"desc": "上传您的存储库备份文件。",
1311
+
"label": "CAR 文件",
1312
+
"hint": "从您的备份中选择 .car 文件",
1313
+
"reuploadWarningTitle": "需要 CAR 文件",
1314
+
"reuploadWarning": "您的会话已恢复,但您需要重新上传 CAR 文件。出于安全原因,文件内容不会在会话之间保存。"
1280
1315
},
1281
-
"review": {
1282
-
"title": "检查迁移",
1283
-
"desc": "请检查并确认您的迁移详情。",
1284
-
"currentHandle": "当前用户名",
1285
-
"newHandle": "新用户名",
1286
-
"sourcePds": "此PDS",
1287
-
"targetPds": "目标PDS",
1288
-
"confirm": "我确认要迁移我的账户",
1289
-
"startMigration": "开始迁移"
1316
+
"rotationKey": {
1317
+
"title": "提供轮换密钥",
1318
+
"desc": "输入您的轮换密钥以证明此 DID 的所有权。",
1319
+
"securityWarningTitle": "安全警告",
1320
+
"securityWarning1": "您的轮换密钥极为敏感 - 请像对待主密码一样对待它",
1321
+
"securityWarning2": "仅在受信任的设备和网络上输入",
1322
+
"securityWarning3": "迁移完成后此密钥不会被存储",
1323
+
"label": "轮换密钥",
1324
+
"placeholder": "输入私钥(hex、base58 或 JWK)",
1325
+
"hint": "与您的 DID 文档中的轮换密钥之一对应的私钥",
1326
+
"valid": "密钥有效并匹配您的 DID 中的轮换密钥",
1327
+
"invalid": "密钥与您的 DID 文档中的任何轮换密钥都不匹配",
1328
+
"validating": "验证密钥...",
1329
+
"validate": "验证密钥"
1290
1330
},
1291
-
"migrating": {
1292
-
"title": "正在迁移您的账户",
1293
-
"desc": "请稍候,正在转移您的数据..."
1331
+
"chooseHandle": {
1332
+
"migratingDid": "恢复 DID"
1294
1333
},
1295
-
"plcToken": {
1296
-
"title": "验证您的身份",
1297
-
"desc": "验证码已发送到您的邮箱。"
1334
+
"review": {
1335
+
"desc": "检查您的离线恢复详情。",
1336
+
"carFile": "CAR 文件",
1337
+
"rotationKey": "轮换密钥",
1338
+
"warning": "开始恢复后,您的身份将更新为指向此 PDS。此操作无法轻易撤销。",
1339
+
"plcWarningTitle": "不可逆转点",
1340
+
"plcWarning": "一旦开始,您的 DID 文档将更新为指向此 PDS。如果出现问题,您可以使用轮换密钥恢复,但您应该完成迁移以避免身份状态损坏。"
1298
1341
},
1299
-
"finalizing": {
1300
-
"title": "正在完成迁移",
1301
-
"desc": "请稍候,正在完成迁移...",
1302
-
"updatingForwarding": "正在更新DID文档转发..."
1342
+
"migrating": {
1343
+
"title": "恢复账户",
1344
+
"desc": "请稍候,正在恢复您的账户...",
1345
+
"creating": "创建账户",
1346
+
"importing": "导入存储库",
1347
+
"plcSigning": "更新身份",
1348
+
"activating": "激活账户"
1303
1349
},
1304
1350
"success": {
1305
-
"title": "迁移完成!",
1306
-
"desc": "您的账户已成功迁移到新PDS。",
1307
-
"newHandle": "新用户名",
1308
-
"newPds": "新PDS",
1309
-
"nextSteps": "后续步骤",
1310
-
"nextSteps1": "登录到您的新PDS",
1311
-
"nextSteps2": "使用新凭据更新您的应用",
1312
-
"nextSteps3": "您的关注者将自动看到您的新位置",
1313
-
"loggingOut": "{seconds}秒后退出登录..."
1351
+
"desc": "您的账户已成功恢复到此 PDS。"
1352
+
},
1353
+
"blobs": {
1354
+
"title": "迁移 Blob",
1355
+
"desc": "正在尝试从您的旧 PDS 恢复图片和媒体...",
1356
+
"migrating": "正在迁移 blob",
1357
+
"failedTitle": "部分 blob 无法迁移",
1358
+
"failedDesc": "{count} 个 blob 无法从您的旧 PDS 获取。这可能是因为服务器无法访问或文件已被删除。",
1359
+
"sourceUnreachableTitle": "源 PDS 无法访问",
1360
+
"sourceUnreachable": "无法连接到您的旧 PDS 来获取媒体文件。从已关闭的服务器迁移时这很常见。您的帖子将正常工作,但部分图片可能会丢失。"
1314
1361
}
1315
1362
},
1316
1363
"progress": {
+1
-1
frontend/src/routes/ActAs.svelte
+1
-1
frontend/src/routes/ActAs.svelte
+1
-1
frontend/src/routes/Admin.svelte
+1
-1
frontend/src/routes/Admin.svelte
···
435
435
<div class="message success">{$_('admin.configSaved')}</div>
436
436
{/if}
437
437
<button type="submit" disabled={serverConfigLoading || !hasConfigChanges()}>
438
-
{serverConfigLoading ? $_('admin.saving') : $_('admin.saveConfig')}
438
+
{serverConfigLoading ? $_('common.saving') : $_('admin.saveConfig')}
439
439
</button>
440
440
</form>
441
441
</section>
+1
-1
frontend/src/routes/AppPasswords.svelte
+1
-1
frontend/src/routes/AppPasswords.svelte
+1
-1
frontend/src/routes/Comms.svelte
+1
-1
frontend/src/routes/Comms.svelte
+7
-7
frontend/src/routes/Controllers.svelte
+7
-7
frontend/src/routes/Controllers.svelte
···
75
75
async function loadControllers() {
76
76
if (!auth.session) return
77
77
try {
78
-
const response = await fetch('/xrpc/com.tranquil.delegation.listControllers', {
78
+
const response = await fetch('/xrpc/_delegation.listControllers', {
79
79
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
80
80
})
81
81
if (response.ok) {
···
90
90
async function loadControlledAccounts() {
91
91
if (!auth.session) return
92
92
try {
93
-
const response = await fetch('/xrpc/com.tranquil.delegation.listControlledAccounts', {
93
+
const response = await fetch('/xrpc/_delegation.listControlledAccounts', {
94
94
headers: { 'Authorization': `Bearer ${auth.session.accessJwt}` }
95
95
})
96
96
if (response.ok) {
···
104
104
105
105
async function loadScopePresets() {
106
106
try {
107
-
const response = await fetch('/xrpc/com.tranquil.delegation.getScopePresets')
107
+
const response = await fetch('/xrpc/_delegation.getScopePresets')
108
108
if (response.ok) {
109
109
const data = await response.json()
110
110
scopePresets = data.presets || []
···
121
121
success = null
122
122
123
123
try {
124
-
const response = await fetch('/xrpc/com.tranquil.delegation.addController', {
124
+
const response = await fetch('/xrpc/_delegation.addController', {
125
125
method: 'POST',
126
126
headers: {
127
127
'Authorization': `Bearer ${auth.session.accessJwt}`,
···
159
159
success = null
160
160
161
161
try {
162
-
const response = await fetch('/xrpc/com.tranquil.delegation.removeController', {
162
+
const response = await fetch('/xrpc/_delegation.removeController', {
163
163
method: 'POST',
164
164
headers: {
165
165
'Authorization': `Bearer ${auth.session.accessJwt}`,
···
188
188
success = null
189
189
190
190
try {
191
-
const response = await fetch('/xrpc/com.tranquil.delegation.createDelegatedAccount', {
191
+
const response = await fetch('/xrpc/_delegation.createDelegatedAccount', {
192
192
method: 'POST',
193
193
headers: {
194
194
'Authorization': `Bearer ${auth.session.accessJwt}`,
···
407
407
{$_('common.cancel')}
408
408
</button>
409
409
<button onclick={createDelegatedAccount} disabled={creatingDelegated || !newDelegatedHandle.trim()}>
410
-
{creatingDelegated ? $_('delegation.creating') : $_('delegation.createAccount')}
410
+
{creatingDelegated ? $_('common.creating') : $_('delegation.createAccount')}
411
411
</button>
412
412
</div>
413
413
</div>
+21
frontend/src/routes/Dashboard.svelte
+21
frontend/src/routes/Dashboard.svelte
···
10
10
let switching = $state(false)
11
11
let inviteCodesEnabled = $state(false)
12
12
13
+
const isDidWeb = $derived(auth.session?.did?.startsWith('did:web:') ?? false)
14
+
13
15
onMount(async () => {
14
16
try {
15
17
const serverInfo = await api.describeServer()
···
176
178
<h3>{$_('dashboard.navSecurity')}</h3>
177
179
<p>{$_('dashboard.navSecurityDesc')}</p>
178
180
</a>
181
+
<a href="#/settings" class="nav-card">
182
+
<h3>{$_('dashboard.navSettings')}</h3>
183
+
<p>{$_('dashboard.navSettingsDesc')}</p>
184
+
</a>
179
185
<a href="#/migrate" class="nav-card">
180
186
<h3>{$_('dashboard.navMigrateAgain')}</h3>
181
187
<p>{$_('dashboard.navMigrateAgainDesc')}</p>
···
215
221
<h3>{$_('dashboard.navDelegation')}</h3>
216
222
<p>{$_('dashboard.navDelegationDesc')}</p>
217
223
</a>
224
+
{#if isDidWeb}
225
+
<a href="#/did-document" class="nav-card did-web-card">
226
+
<h3>{$_('dashboard.navDidDocument')}</h3>
227
+
<p>{$_('dashboard.navDidDocumentDescActive')}</p>
228
+
</a>
229
+
{/if}
218
230
<a href="#/migrate" class="nav-card">
219
231
<h3>{$_('migration.navTitle')}</h3>
220
232
<p>{$_('migration.navDesc')}</p>
···
503
515
504
516
.nav-card.migrated-card h3 {
505
517
color: var(--info-text, #0369a1);
518
+
}
519
+
520
+
.nav-card.did-web-card {
521
+
border-color: var(--accent);
522
+
background: linear-gradient(135deg, var(--bg-card) 0%, var(--accent-muted) 100%);
523
+
}
524
+
525
+
.nav-card.did-web-card:hover {
526
+
box-shadow: 0 2px 12px var(--accent-muted);
506
527
}
507
528
</style>
+1
-1
frontend/src/routes/DelegationAudit.svelte
+1
-1
frontend/src/routes/DelegationAudit.svelte
+1
-1
frontend/src/routes/DidDocumentEditor.svelte
+1
-1
frontend/src/routes/DidDocumentEditor.svelte
+5
frontend/src/routes/Home.svelte
+5
frontend/src/routes/Home.svelte
···
183
183
<h3>Delegate without sharing passwords</h3>
184
184
<p>Let team members or tools manage your account with specific permission levels. They authenticate with their own credentials, you see everything they do in an audit log.</p>
185
185
</div>
186
+
187
+
<div class="feature">
188
+
<h3>Automatic backups</h3>
189
+
<p>Your repository is backed up daily to object storage. Download any backup or restore with one click. You own your data, even if the worst happens.</p>
190
+
</div>
186
191
</div>
187
192
188
193
<h2>Everything in one place</h2>
+1
-1
frontend/src/routes/InviteCodes.svelte
+1
-1
frontend/src/routes/InviteCodes.svelte
···
111
111
{#if auth.session?.isAdmin}
112
112
<section class="create-section">
113
113
<button onclick={handleCreate} disabled={creating}>
114
-
{creating ? $_('inviteCodes.creating') : $_('inviteCodes.createNew')}
114
+
{creating ? $_('common.creating') : $_('inviteCodes.createNew')}
115
115
</button>
116
116
</section>
117
117
{/if}
+3
-3
frontend/src/routes/Login.svelte
+3
-3
frontend/src/routes/Login.svelte
···
107
107
</div>
108
108
<div class="actions">
109
109
<button type="submit" disabled={submitting || !verificationCode.trim()}>
110
-
{submitting ? $_('verification.verifying') : $_('verification.verifyButton')}
110
+
{submitting ? $_('common.verifying') : $_('common.verify')}
111
111
</button>
112
112
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
113
-
{resendingCode ? $_('verification.resending') : $_('verification.resendButton')}
113
+
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
114
114
</button>
115
115
<button type="button" class="tertiary" onclick={backToLogin}>
116
-
{$_('verification.backToLogin')}
116
+
{$_('common.backToLogin')}
117
117
</button>
118
118
</div>
119
119
</form>
+63
-69
frontend/src/routes/Migration.svelte
+63
-69
frontend/src/routes/Migration.svelte
···
1
1
<script lang="ts">
2
-
import { getAuthState, logout, setSession } from '../lib/auth.svelte'
2
+
import { setSession } from '../lib/auth.svelte'
3
3
import { navigate } from '../lib/router.svelte'
4
4
import { _ } from '../lib/i18n'
5
5
import {
6
6
createInboundMigrationFlow,
7
-
createOutboundMigrationFlow,
7
+
createOfflineInboundMigrationFlow,
8
8
hasPendingMigration,
9
+
hasPendingOfflineMigration,
9
10
getResumeInfo,
11
+
getOfflineResumeInfo,
10
12
clearMigrationState,
13
+
clearOfflineState,
11
14
loadMigrationState,
12
15
} from '../lib/migration'
13
16
import InboundWizard from '../components/migration/InboundWizard.svelte'
14
-
import OutboundWizard from '../components/migration/OutboundWizard.svelte'
17
+
import OfflineInboundWizard from '../components/migration/OfflineInboundWizard.svelte'
15
18
16
-
const auth = getAuthState()
17
-
18
-
type Direction = 'select' | 'inbound' | 'outbound'
19
+
type Direction = 'select' | 'inbound' | 'offline-inbound'
19
20
let direction = $state<Direction>('select')
20
21
let showResumeModal = $state(false)
21
22
let resumeInfo = $state<ReturnType<typeof getResumeInfo>>(null)
···
23
24
let oauthLoading = $state(false)
24
25
25
26
let inboundFlow = $state<ReturnType<typeof createInboundMigrationFlow> | null>(null)
26
-
let outboundFlow = $state<ReturnType<typeof createOutboundMigrationFlow> | null>(null)
27
+
let offlineFlow = $state<ReturnType<typeof createOfflineInboundMigrationFlow> | null>(null)
27
28
let oauthCallbackProcessed = $state(false)
28
29
29
30
$effect(() => {
···
66
67
const urlParams = new URLSearchParams(window.location.search)
67
68
const hasOAuthCallback = urlParams.has('code') || urlParams.has('error')
68
69
69
-
if (!hasOAuthCallback && hasPendingMigration()) {
70
-
resumeInfo = getResumeInfo()
71
-
if (resumeInfo) {
72
-
const stored = loadMigrationState()
73
-
if (stored) {
74
-
if (stored.direction === 'inbound') {
75
-
direction = 'inbound'
76
-
inboundFlow = createInboundMigrationFlow()
77
-
inboundFlow.resumeFromState(stored)
70
+
if (!hasOAuthCallback) {
71
+
if (hasPendingMigration()) {
72
+
resumeInfo = getResumeInfo()
73
+
if (resumeInfo) {
74
+
if (resumeInfo.step === 'success') {
75
+
clearMigrationState()
76
+
resumeInfo = null
78
77
} else {
79
-
direction = 'outbound'
80
-
outboundFlow = createOutboundMigrationFlow()
78
+
const stored = loadMigrationState()
79
+
if (stored && stored.direction === 'inbound') {
80
+
direction = 'inbound'
81
+
inboundFlow = createInboundMigrationFlow()
82
+
inboundFlow.resumeFromState(stored)
83
+
}
81
84
}
82
85
}
86
+
} else if (hasPendingOfflineMigration()) {
87
+
const offlineInfo = getOfflineResumeInfo()
88
+
if (offlineInfo && offlineInfo.step === 'success') {
89
+
clearOfflineState()
90
+
} else {
91
+
direction = 'offline-inbound'
92
+
offlineFlow = createOfflineInboundMigrationFlow()
93
+
offlineFlow.tryResume()
94
+
}
83
95
}
84
96
}
85
97
···
88
100
inboundFlow = createInboundMigrationFlow()
89
101
}
90
102
91
-
function selectOutbound() {
92
-
if (!auth.session) {
93
-
navigate('/login')
94
-
return
95
-
}
96
-
direction = 'outbound'
97
-
outboundFlow = createOutboundMigrationFlow()
98
-
outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle)
103
+
function selectOfflineInbound() {
104
+
direction = 'offline-inbound'
105
+
offlineFlow = createOfflineInboundMigrationFlow()
99
106
}
100
107
101
108
function handleResume() {
···
108
115
direction = 'inbound'
109
116
inboundFlow = createInboundMigrationFlow()
110
117
inboundFlow.resumeFromState(stored)
111
-
} else {
112
-
if (!auth.session) {
113
-
navigate('/login')
114
-
return
115
-
}
116
-
direction = 'outbound'
117
-
outboundFlow = createOutboundMigrationFlow()
118
-
outboundFlow.initLocalClient(auth.session.accessJwt, auth.session.did, auth.session.handle)
119
118
}
120
119
}
121
120
···
130
129
inboundFlow.reset()
131
130
inboundFlow = null
132
131
}
133
-
if (outboundFlow) {
134
-
outboundFlow.reset()
135
-
outboundFlow = null
132
+
if (offlineFlow) {
133
+
offlineFlow.reset()
134
+
offlineFlow = null
136
135
}
137
136
direction = 'select'
138
137
}
···
150
149
navigate('/dashboard')
151
150
}
152
151
153
-
async function handleOutboundComplete() {
154
-
await logout()
155
-
navigate('/login')
152
+
function handleOfflineComplete() {
153
+
const session = offlineFlow?.getLocalSession()
154
+
if (session) {
155
+
setSession({
156
+
did: session.did,
157
+
handle: session.handle,
158
+
accessJwt: session.accessJwt,
159
+
refreshJwt: '',
160
+
})
161
+
}
162
+
navigate('/dashboard')
156
163
}
157
164
</script>
158
165
···
165
172
<div class="resume-details">
166
173
<div class="detail-row">
167
174
<span class="label">{$_('migration.resume.direction')}:</span>
168
-
<span class="value">{resumeInfo.direction === 'inbound' ? $_('migration.resume.migratingHere') : $_('migration.resume.migratingAway')}</span>
175
+
<span class="value">{$_('migration.resume.migratingHere')}</span>
169
176
</div>
170
177
{#if resumeInfo.sourceHandle}
171
178
<div class="detail-row">
···
212
219
213
220
<div class="direction-cards">
214
221
<button class="direction-card ghost" onclick={selectInbound}>
215
-
<div class="card-icon">↓</div>
216
222
<h2>{$_('migration.migrateHere')}</h2>
217
223
<p>{$_('migration.migrateHereDesc')}</p>
218
224
<ul class="features">
···
222
228
</ul>
223
229
</button>
224
230
225
-
<button class="direction-card ghost" onclick={selectOutbound} disabled>
226
-
<div class="card-icon">↑</div>
227
-
<h2>{$_('migration.migrateAway')}</h2>
228
-
<p>{$_('migration.migrateAwayDesc')}</p>
231
+
<button class="direction-card ghost offline-card" onclick={selectOfflineInbound}>
232
+
<h2>{$_('migration.offlineRestore')}</h2>
233
+
<p>{$_('migration.offlineRestoreDesc')}</p>
229
234
<ul class="features">
230
-
<li>{$_('migration.exportRepo')}</li>
231
-
<li>{$_('migration.transferToPds')}</li>
232
-
<li>{$_('migration.updateIdentity')}</li>
235
+
<li>{$_('migration.offlineFeature1')}</li>
236
+
<li>{$_('migration.offlineFeature2')}</li>
237
+
<li>{$_('migration.offlineFeature3')}</li>
233
238
</ul>
234
-
<p class="login-required">{$_('migration.comingSoon')}</p>
235
239
</button>
236
240
</div>
237
241
···
263
267
onComplete={handleInboundComplete}
264
268
/>
265
269
266
-
{:else if direction === 'outbound' && outboundFlow}
267
-
<OutboundWizard
268
-
flow={outboundFlow}
270
+
{:else if direction === 'offline-inbound' && offlineFlow}
271
+
<OfflineInboundWizard
272
+
flow={offlineFlow}
269
273
onBack={handleBack}
270
-
onComplete={handleOutboundComplete}
274
+
onComplete={handleOfflineComplete}
271
275
/>
272
276
{/if}
273
277
</div>
···
302
306
}
303
307
304
308
.direction-card {
309
+
display: flex;
310
+
flex-direction: column;
311
+
align-items: stretch;
305
312
background: var(--bg-secondary);
306
313
border: 1px solid var(--border);
307
314
border-radius: var(--radius-xl);
···
322
329
cursor: not-allowed;
323
330
}
324
331
325
-
.card-icon {
326
-
font-size: var(--text-3xl);
327
-
margin-bottom: var(--space-4);
328
-
color: var(--accent);
329
-
}
330
-
331
332
.direction-card h2 {
332
333
margin: 0 0 var(--space-3) 0;
333
334
font-size: var(--text-xl);
···
349
350
350
351
.features li {
351
352
margin-bottom: var(--space-2);
352
-
}
353
-
354
-
.login-required {
355
-
color: var(--warning-text);
356
-
font-weight: var(--font-medium);
357
-
margin-top: var(--space-4);
358
353
}
359
354
360
355
.info-section {
···
402
397
}
403
398
404
399
.warning-box a {
405
-
display: block;
406
-
margin-top: var(--space-3);
407
-
color: var(--accent);
400
+
display: inline;
401
+
margin-top: var(--space-2);
408
402
}
409
403
410
404
.modal-overlay {
+1
-1
frontend/src/routes/OAuth2FA.svelte
+1
-1
frontend/src/routes/OAuth2FA.svelte
···
105
105
{$_('common.cancel')}
106
106
</button>
107
107
<button type="submit" class="submit-btn" disabled={submitting || code.trim().length !== 6}>
108
-
{submitting ? $_('oauth.twoFactorCode.verifying') : $_('oauth.twoFactorCode.verify')}
108
+
{submitting ? $_('common.verifying') : $_('common.verify')}
109
109
</button>
110
110
</div>
111
111
</form>
+1
-1
frontend/src/routes/OAuthConsent.svelte
+1
-1
frontend/src/routes/OAuthConsent.svelte
+1
-1
frontend/src/routes/OAuthTotp.svelte
+1
-1
frontend/src/routes/OAuthTotp.svelte
···
121
121
{$_('common.cancel')}
122
122
</button>
123
123
<button type="submit" class="submit-btn" disabled={submitting || !canSubmit}>
124
-
{submitting ? $_('oauth.totp.verifying') : $_('oauth.totp.verify')}
124
+
{submitting ? $_('common.verifying') : $_('common.verify')}
125
125
</button>
126
126
</div>
127
127
</form>
+3
-3
frontend/src/routes/Register.svelte
+3
-3
frontend/src/routes/Register.svelte
···
145
145
case 'info': return $_('register.subtitle')
146
146
case 'key-choice': return $_('register.subtitleKeyChoice')
147
147
case 'initial-did-doc': return $_('register.subtitleInitialDidDoc')
148
-
case 'creating': return $_('register.creating')
148
+
case 'creating': return $_('common.creating')
149
149
case 'verify': return $_('register.subtitleVerify', { values: { channel: channelLabel(flow.info.verificationChannel) } })
150
150
case 'updated-did-doc': return $_('register.subtitleUpdatedDidDoc')
151
151
case 'activating': return $_('register.subtitleActivating')
···
375
375
{/if}
376
376
377
377
<button type="submit" disabled={flow.state.submitting}>
378
-
{flow.state.submitting ? $_('register.creating') : $_('register.createButton')}
378
+
{flow.state.submitting ? $_('common.creating') : $_('register.createButton')}
379
379
</button>
380
380
</form>
381
381
···
413
413
/>
414
414
415
415
{:else if flow.state.step === 'creating'}
416
-
<p class="loading">{$_('register.creating')}</p>
416
+
<p class="loading">{$_('common.creating')}</p>
417
417
418
418
{:else if flow.state.step === 'verify'}
419
419
<VerificationStep {flow} />
+1
-1
frontend/src/routes/RegisterPasskey.svelte
+1
-1
frontend/src/routes/RegisterPasskey.svelte
···
408
408
</div>
409
409
410
410
<button type="submit" disabled={flow.state.submitting}>
411
-
{flow.state.submitting ? $_('registerPasskey.creating') : $_('registerPasskey.continue')}
411
+
{flow.state.submitting ? $_('common.creating') : $_('registerPasskey.continue')}
412
412
</button>
413
413
</form>
414
414
+2
-2
frontend/src/routes/RepoExplorer.svelte
+2
-2
frontend/src/routes/RepoExplorer.svelte
···
417
417
</div>
418
418
<div class="actions">
419
419
<button type="submit" class="primary" disabled={saving || !!jsonError}>
420
-
{saving ? $_('repoExplorer.saving') : $_('repoExplorer.updateRecord')}
420
+
{saving ? $_('common.saving') : $_('repoExplorer.updateRecord')}
421
421
</button>
422
422
<button type="button" class="danger" onclick={handleDelete} disabled={saving}>
423
423
{$_('common.delete')}
···
464
464
</div>
465
465
<div class="actions">
466
466
<button type="submit" class="primary" disabled={saving || !!jsonError || !newCollection.trim()}>
467
-
{saving ? $_('repoExplorer.creating') : $_('repoExplorer.createRecord')}
467
+
{saving ? $_('common.creating') : $_('repoExplorer.createRecord')}
468
468
</button>
469
469
<button type="button" class="secondary" onclick={goBack}>
470
470
{$_('common.cancel')}
+2
-2
frontend/src/routes/RequestPasskeyRecovery.svelte
+2
-2
frontend/src/routes/RequestPasskeyRecovery.svelte
···
36
36
<h1>{$_('requestPasskeyRecovery.successTitle')}</h1>
37
37
<p class="subtitle">{$_('requestPasskeyRecovery.successMessage')}</p>
38
38
<p class="info-text">{$_('requestPasskeyRecovery.successInfo')}</p>
39
-
<button onclick={() => navigate('/login')}>{$_('requestPasskeyRecovery.backToLogin')}</button>
39
+
<button onclick={() => navigate('/login')}>{$_('common.backToLogin')}</button>
40
40
</div>
41
41
{:else}
42
42
<h1>{$_('requestPasskeyRecovery.title')}</h1>
···
71
71
{/if}
72
72
73
73
<p class="link-text">
74
-
<a href="#/login">{$_('requestPasskeyRecovery.backToLogin')}</a>
74
+
<a href="#/login">{$_('common.backToLogin')}</a>
75
75
</p>
76
76
</div>
77
77
+1
-1
frontend/src/routes/ResetPassword.svelte
+1
-1
frontend/src/routes/ResetPassword.svelte
+341
-3
frontend/src/routes/Settings.svelte
+341
-3
frontend/src/routes/Settings.svelte
···
40
40
let deleteToken = $state('')
41
41
let deleteTokenSent = $state(false)
42
42
let exportLoading = $state(false)
43
+
let exportBlobsLoading = $state(false)
43
44
let passwordLoading = $state(false)
44
45
let currentPassword = $state('')
45
46
let newPassword = $state('')
···
173
174
exportLoading = false
174
175
}
175
176
}
177
+
async function handleExportBlobs() {
178
+
if (!auth.session) return
179
+
exportBlobsLoading = true
180
+
message = null
181
+
try {
182
+
const response = await fetch('/xrpc/_backup.exportBlobs', {
183
+
headers: {
184
+
'Authorization': `Bearer ${auth.session.accessJwt}`
185
+
}
186
+
})
187
+
if (!response.ok) {
188
+
const err = await response.json().catch(() => ({ message: 'Export failed' }))
189
+
throw new Error(err.message || 'Export failed')
190
+
}
191
+
const blob = await response.blob()
192
+
if (blob.size === 0) {
193
+
showMessage('success', $_('settings.messages.noBlobsToExport'))
194
+
return
195
+
}
196
+
const url = URL.createObjectURL(blob)
197
+
const a = document.createElement('a')
198
+
a.href = url
199
+
a.download = `${auth.session.handle}-blobs.zip`
200
+
document.body.appendChild(a)
201
+
a.click()
202
+
document.body.removeChild(a)
203
+
URL.revokeObjectURL(url)
204
+
showMessage('success', $_('settings.messages.blobsExported'))
205
+
} catch (e) {
206
+
showMessage('error', e instanceof Error ? e.message : $_('settings.messages.exportFailed'))
207
+
} finally {
208
+
exportBlobsLoading = false
209
+
}
210
+
}
211
+
212
+
interface BackupInfo {
213
+
id: string
214
+
repoRev: string
215
+
repoRootCid: string
216
+
blockCount: number
217
+
sizeBytes: number
218
+
createdAt: string
219
+
}
220
+
let backups = $state<BackupInfo[]>([])
221
+
let backupEnabled = $state(true)
222
+
let backupsLoading = $state(false)
223
+
let createBackupLoading = $state(false)
224
+
let restoreFile = $state<File | null>(null)
225
+
let restoreLoading = $state(false)
226
+
227
+
async function loadBackups() {
228
+
if (!auth.session) return
229
+
backupsLoading = true
230
+
try {
231
+
const result = await api.listBackups(auth.session.accessJwt)
232
+
backups = result.backups
233
+
backupEnabled = result.backupEnabled
234
+
} catch (e) {
235
+
console.error('Failed to load backups:', e)
236
+
} finally {
237
+
backupsLoading = false
238
+
}
239
+
}
240
+
241
+
onMount(() => {
242
+
loadBackups()
243
+
})
244
+
245
+
async function handleToggleBackup() {
246
+
if (!auth.session) return
247
+
const newEnabled = !backupEnabled
248
+
backupsLoading = true
249
+
try {
250
+
await api.setBackupEnabled(auth.session.accessJwt, newEnabled)
251
+
backupEnabled = newEnabled
252
+
showMessage('success', newEnabled ? $_('settings.backups.enabled') : $_('settings.backups.disabled'))
253
+
} catch (e) {
254
+
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.toggleFailed'))
255
+
} finally {
256
+
backupsLoading = false
257
+
}
258
+
}
259
+
260
+
async function handleCreateBackup() {
261
+
if (!auth.session) return
262
+
createBackupLoading = true
263
+
message = null
264
+
try {
265
+
await api.createBackup(auth.session.accessJwt)
266
+
await loadBackups()
267
+
showMessage('success', $_('settings.backups.created'))
268
+
} catch (e) {
269
+
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.createFailed'))
270
+
} finally {
271
+
createBackupLoading = false
272
+
}
273
+
}
274
+
275
+
async function handleDownloadBackup(id: string, rev: string) {
276
+
if (!auth.session) return
277
+
try {
278
+
const blob = await api.getBackup(auth.session.accessJwt, id)
279
+
const url = URL.createObjectURL(blob)
280
+
const a = document.createElement('a')
281
+
a.href = url
282
+
a.download = `${auth.session.handle}-${rev}.car`
283
+
document.body.appendChild(a)
284
+
a.click()
285
+
document.body.removeChild(a)
286
+
URL.revokeObjectURL(url)
287
+
} catch (e) {
288
+
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.downloadFailed'))
289
+
}
290
+
}
291
+
292
+
async function handleDeleteBackup(id: string) {
293
+
if (!auth.session) return
294
+
try {
295
+
await api.deleteBackup(auth.session.accessJwt, id)
296
+
await loadBackups()
297
+
showMessage('success', $_('settings.backups.deleted'))
298
+
} catch (e) {
299
+
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.deleteFailed'))
300
+
}
301
+
}
302
+
303
+
function handleFileSelect(e: Event) {
304
+
const input = e.target as HTMLInputElement
305
+
if (input.files && input.files.length > 0) {
306
+
restoreFile = input.files[0]
307
+
}
308
+
}
309
+
310
+
async function handleRestore() {
311
+
if (!auth.session || !restoreFile) return
312
+
restoreLoading = true
313
+
message = null
314
+
try {
315
+
const buffer = await restoreFile.arrayBuffer()
316
+
const car = new Uint8Array(buffer)
317
+
await api.importRepo(auth.session.accessJwt, car)
318
+
showMessage('success', $_('settings.backups.restored'))
319
+
restoreFile = null
320
+
} catch (e) {
321
+
showMessage('error', e instanceof ApiError ? e.message : $_('settings.backups.restoreFailed'))
322
+
} finally {
323
+
restoreLoading = false
324
+
}
325
+
}
326
+
327
+
function formatBytes(bytes: number): string {
328
+
if (bytes < 1024) return `${bytes} B`
329
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
330
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
331
+
}
332
+
333
+
function formatDate(iso: string): string {
334
+
return new Date(iso).toLocaleDateString(undefined, {
335
+
year: 'numeric',
336
+
month: 'short',
337
+
day: 'numeric',
338
+
hour: '2-digit',
339
+
minute: '2-digit'
340
+
})
341
+
}
342
+
176
343
async function handleChangePassword(e: Event) {
177
344
e.preventDefault()
178
345
if (!auth.session || !currentPassword || !newPassword || !confirmNewPassword) return
···
323
490
/>
324
491
</div>
325
492
<button type="submit" disabled={handleLoading || !newHandle}>
326
-
{handleLoading ? $_('settings.verifying') : $_('settings.verifyAndUpdate')}
493
+
{handleLoading ? $_('common.verifying') : $_('settings.verifyAndUpdate')}
327
494
</button>
328
495
</form>
329
496
</div>
···
394
561
<section>
395
562
<h2>{$_('settings.exportData')}</h2>
396
563
<p class="description">{$_('settings.exportDataDescription')}</p>
397
-
<button onclick={handleExportRepo} disabled={exportLoading}>
398
-
{exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')}
564
+
<div class="export-buttons">
565
+
<button onclick={handleExportRepo} disabled={exportLoading}>
566
+
{exportLoading ? $_('settings.exporting') : $_('settings.downloadRepo')}
567
+
</button>
568
+
<button onclick={handleExportBlobs} disabled={exportBlobsLoading} class="secondary">
569
+
{exportBlobsLoading ? $_('settings.exporting') : $_('settings.downloadBlobs')}
570
+
</button>
571
+
</div>
572
+
</section>
573
+
<section class="backups-section">
574
+
<h2>{$_('settings.backups.title')}</h2>
575
+
<p class="description">{$_('settings.backups.description')}</p>
576
+
577
+
<label class="checkbox-label">
578
+
<input type="checkbox" checked={backupEnabled} onchange={handleToggleBackup} disabled={backupsLoading} />
579
+
<span>{$_('settings.backups.enableAutomatic')}</span>
580
+
</label>
581
+
582
+
{#if backupsLoading}
583
+
<p class="loading">{$_('common.loading')}</p>
584
+
{:else if backups.length > 0}
585
+
<ul class="backup-list">
586
+
{#each backups as backup}
587
+
<li class="backup-item">
588
+
<div class="backup-info">
589
+
<span class="backup-date">{formatDate(backup.createdAt)}</span>
590
+
<span class="backup-size">{formatBytes(backup.sizeBytes)}</span>
591
+
<span class="backup-blocks">{backup.blockCount} {$_('settings.backups.blocks')}</span>
592
+
</div>
593
+
<div class="backup-actions">
594
+
<button class="small" onclick={() => handleDownloadBackup(backup.id, backup.repoRev)}>
595
+
{$_('settings.backups.download')}
596
+
</button>
597
+
<button class="small danger" onclick={() => handleDeleteBackup(backup.id)}>
598
+
{$_('settings.backups.delete')}
599
+
</button>
600
+
</div>
601
+
</li>
602
+
{/each}
603
+
</ul>
604
+
{:else}
605
+
<p class="empty">{$_('settings.backups.noBackups')}</p>
606
+
{/if}
607
+
608
+
<button onclick={handleCreateBackup} disabled={createBackupLoading || !backupEnabled}>
609
+
{createBackupLoading ? $_('common.creating') : $_('settings.backups.createNow')}
399
610
</button>
611
+
</section>
612
+
<section class="restore-section">
613
+
<h2>{$_('settings.backups.restoreTitle')}</h2>
614
+
<p class="description">{$_('settings.backups.restoreDescription')}</p>
615
+
616
+
<div class="field">
617
+
<label for="restore-file">{$_('settings.backups.selectFile')}</label>
618
+
<input
619
+
id="restore-file"
620
+
type="file"
621
+
accept=".car"
622
+
onchange={handleFileSelect}
623
+
disabled={restoreLoading}
624
+
/>
625
+
</div>
626
+
627
+
{#if restoreFile}
628
+
<div class="restore-preview">
629
+
<p>{$_('settings.backups.selectedFile')}: {restoreFile.name} ({formatBytes(restoreFile.size)})</p>
630
+
<button onclick={handleRestore} disabled={restoreLoading} class="danger">
631
+
{restoreLoading ? $_('settings.backups.restoring') : $_('settings.backups.restore')}
632
+
</button>
633
+
</div>
634
+
{/if}
400
635
</section>
401
636
</div>
402
637
<section class="danger-zone">
···
658
893
white-space: nowrap;
659
894
border-left: 1px solid var(--border-color);
660
895
background: var(--bg-card);
896
+
}
897
+
898
+
.checkbox-label {
899
+
display: flex;
900
+
align-items: center;
901
+
gap: var(--space-2);
902
+
cursor: pointer;
903
+
margin-bottom: var(--space-4);
904
+
}
905
+
906
+
.checkbox-label input[type="checkbox"] {
907
+
width: 18px;
908
+
height: 18px;
909
+
cursor: pointer;
910
+
}
911
+
912
+
.backup-list {
913
+
list-style: none;
914
+
padding: 0;
915
+
margin: 0 0 var(--space-4) 0;
916
+
display: flex;
917
+
flex-direction: column;
918
+
gap: var(--space-2);
919
+
}
920
+
921
+
.backup-item {
922
+
display: flex;
923
+
justify-content: space-between;
924
+
align-items: center;
925
+
padding: var(--space-3);
926
+
background: var(--bg-card);
927
+
border: 1px solid var(--border-color);
928
+
border-radius: var(--radius-md);
929
+
gap: var(--space-4);
930
+
}
931
+
932
+
.backup-info {
933
+
display: flex;
934
+
gap: var(--space-4);
935
+
font-size: var(--text-sm);
936
+
flex-wrap: wrap;
937
+
}
938
+
939
+
.backup-date {
940
+
font-weight: 500;
941
+
}
942
+
943
+
.backup-size,
944
+
.backup-blocks {
945
+
color: var(--text-secondary);
946
+
}
947
+
948
+
.backup-actions {
949
+
display: flex;
950
+
gap: var(--space-2);
951
+
flex-shrink: 0;
952
+
}
953
+
954
+
button.small {
955
+
padding: var(--space-1) var(--space-2);
956
+
font-size: var(--text-xs);
957
+
}
958
+
959
+
.empty,
960
+
.loading {
961
+
color: var(--text-secondary);
962
+
font-size: var(--text-sm);
963
+
margin-bottom: var(--space-4);
964
+
}
965
+
966
+
.restore-preview {
967
+
background: var(--bg-card);
968
+
border: 1px solid var(--border-color);
969
+
border-radius: var(--radius-md);
970
+
padding: var(--space-4);
971
+
margin-top: var(--space-3);
972
+
}
973
+
974
+
.restore-preview p {
975
+
margin: 0 0 var(--space-3) 0;
976
+
font-size: var(--text-sm);
977
+
}
978
+
979
+
.export-buttons {
980
+
display: flex;
981
+
gap: var(--space-2);
982
+
flex-wrap: wrap;
983
+
}
984
+
985
+
@media (max-width: 640px) {
986
+
.backup-item {
987
+
flex-direction: column;
988
+
align-items: flex-start;
989
+
}
990
+
991
+
.backup-actions {
992
+
width: 100%;
993
+
margin-top: var(--space-2);
994
+
}
995
+
996
+
.backup-actions button {
997
+
flex: 1;
998
+
}
661
999
}
662
1000
</style>
+8
-8
frontend/src/routes/Verify.svelte
+8
-8
frontend/src/routes/Verify.svelte
···
225
225
<div class="verify-page">
226
226
{#if autoSubmitting}
227
227
<div class="loading-container">
228
-
<h1>{$_('verify.verifying')}</h1>
228
+
<h1>{$_('common.verifying')}</h1>
229
229
<p class="subtitle">{$_('verify.pleaseWait')}</p>
230
230
</div>
231
231
{:else if success}
···
235
235
<p class="subtitle">{$_('verify.emailUpdated')}</p>
236
236
<p class="info-text">{$_('verify.emailUpdatedInfo')}</p>
237
237
<div class="actions">
238
-
<a href="#/settings" class="btn">{$_('verify.backToSettings')}</a>
238
+
<a href="#/settings" class="btn">{$_('common.backToSettings')}</a>
239
239
</div>
240
240
{:else if successPurpose === 'migration' || successPurpose === 'signup'}
241
241
<p class="subtitle">{$_('verify.channelVerified', { values: { channel: channelLabel(successChannel || '') } })}</p>
···
301
301
</form>
302
302
303
303
<p class="link-text">
304
-
<a href="#/settings">{$_('verify.backToSettings')}</a>
304
+
<a href="#/settings">{$_('common.backToSettings')}</a>
305
305
</p>
306
306
{/if}
307
307
{:else if mode === 'token'}
···
347
347
</div>
348
348
349
349
<button type="submit" disabled={submitting || !verificationCode.trim() || !identifier.trim()}>
350
-
{submitting ? $_('verify.verifying') : $_('verify.verify')}
350
+
{submitting ? $_('common.verifying') : $_('common.verify')}
351
351
</button>
352
352
353
353
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode || !identifier.trim()}>
354
-
{resendingCode ? $_('verify.sending') : $_('verify.resendCode')}
354
+
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
355
355
</button>
356
356
</form>
357
357
358
358
<p class="link-text">
359
-
<a href="#/login">{$_('verify.backToLogin')}</a>
359
+
<a href="#/login">{$_('common.backToLogin')}</a>
360
360
</p>
361
361
{:else if pendingVerification}
362
362
<h1>{$_('verify.title')}</h1>
···
390
390
</div>
391
391
392
392
<button type="submit" disabled={submitting || !verificationCode.trim()}>
393
-
{submitting ? $_('verify.verifying') : $_('verify.verifyButton')}
393
+
{submitting ? $_('common.verifying') : $_('common.verify')}
394
394
</button>
395
395
396
396
<button type="button" class="secondary" onclick={handleResendCode} disabled={resendingCode}>
397
-
{resendingCode ? $_('verify.resending') : $_('verify.resendCode')}
397
+
{resendingCode ? $_('common.sending') : $_('common.resendCode')}
398
398
</button>
399
399
</form>
400
400
+5
-5
frontend/src/styles/base.css
+5
-5
frontend/src/styles/base.css
···
54
54
}
55
55
56
56
a {
57
-
color: var(--secondary);
58
-
text-decoration: none;
59
-
transition: color 0.3s ease;
57
+
color: var(--accent);
58
+
text-decoration: underline;
59
+
text-underline-offset: 2px;
60
60
}
61
61
62
62
a:hover {
63
-
color: var(--secondary-hover);
64
-
text-decoration: none;
63
+
color: var(--accent-hover);
65
64
}
66
65
67
66
::selection {
···
372
371
color: var(--text-secondary);
373
372
font-size: var(--text-sm);
374
373
margin-bottom: var(--space-3);
374
+
text-decoration: none;
375
375
}
376
376
377
377
.back-link:hover {
+90
frontend/src/styles/migration.css
+90
frontend/src/styles/migration.css
···
190
190
191
191
.current-info .value {
192
192
font-weight: var(--font-medium);
193
+
word-break: break-all;
194
+
}
195
+
196
+
.current-info .value.mono {
197
+
font-family: var(--font-mono);
198
+
font-size: var(--text-sm);
193
199
}
194
200
195
201
.review-card {
···
268
274
text-align: center;
269
275
color: var(--text-secondary);
270
276
font-size: var(--text-sm);
277
+
}
278
+
279
+
.blob-progress {
280
+
margin: var(--space-4) 0;
281
+
}
282
+
283
+
.blob-progress-bar {
284
+
height: 8px;
285
+
background: var(--bg-primary);
286
+
border-radius: var(--radius-md);
287
+
overflow: hidden;
288
+
margin-bottom: var(--space-2);
289
+
}
290
+
291
+
.blob-progress-fill {
292
+
height: 100%;
293
+
background: var(--accent);
294
+
transition: width var(--transition-slow);
295
+
}
296
+
297
+
.blob-progress-text {
298
+
text-align: center;
299
+
color: var(--text-secondary);
300
+
font-size: var(--text-sm);
301
+
margin: 0;
271
302
}
272
303
273
304
.success-content {
···
567
598
font-size: var(--text-sm);
568
599
font-style: italic;
569
600
}
601
+
602
+
.file-input-container {
603
+
display: flex;
604
+
flex-direction: column;
605
+
gap: var(--space-3);
606
+
}
607
+
608
+
.file-info {
609
+
display: flex;
610
+
gap: var(--space-2);
611
+
align-items: center;
612
+
padding: var(--space-3);
613
+
background: var(--bg-primary);
614
+
border-radius: var(--radius-md);
615
+
}
616
+
617
+
.file-name {
618
+
font-weight: var(--font-medium);
619
+
}
620
+
621
+
.file-size {
622
+
color: var(--text-secondary);
623
+
font-size: var(--text-sm);
624
+
}
625
+
626
+
.step-content textarea {
627
+
width: 100%;
628
+
font-family: var(--font-mono);
629
+
font-size: var(--text-sm);
630
+
padding: var(--space-3);
631
+
border: 1px solid var(--border-color);
632
+
border-radius: var(--radius-md);
633
+
background: var(--bg-input);
634
+
color: var(--text-primary);
635
+
resize: vertical;
636
+
}
637
+
638
+
.step-content textarea:focus {
639
+
outline: none;
640
+
border-color: var(--accent);
641
+
}
642
+
643
+
.message {
644
+
padding: var(--space-4);
645
+
border-radius: var(--radius-lg);
646
+
margin-bottom: var(--space-4);
647
+
}
648
+
649
+
.message.success {
650
+
background: var(--success-bg);
651
+
color: var(--success-text);
652
+
border: 1px solid var(--success-border);
653
+
}
654
+
655
+
.message.error {
656
+
background: var(--error-bg);
657
+
color: var(--error-text);
658
+
border: 1px solid var(--error-border);
659
+
}
+35
-35
frontend/src/tests/Comms.test.ts
+35
-35
frontend/src/tests/Comms.test.ts
···
29
29
beforeEach(() => {
30
30
setupAuthenticatedUser();
31
31
mockEndpoint(
32
-
"com.tranquil.account.getNotificationPrefs",
32
+
"_account.getNotificationPrefs",
33
33
() => jsonResponse(mockData.notificationPrefs()),
34
34
);
35
35
mockEndpoint(
···
37
37
() => jsonResponse(mockData.describeServer()),
38
38
);
39
39
mockEndpoint(
40
-
"com.tranquil.account.getNotificationHistory",
40
+
"_account.getNotificationHistory",
41
41
() => jsonResponse({ notifications: [] }),
42
42
);
43
43
});
···
67
67
() => jsonResponse(mockData.describeServer()),
68
68
);
69
69
mockEndpoint(
70
-
"com.tranquil.account.getNotificationHistory",
70
+
"_account.getNotificationHistory",
71
71
() => jsonResponse({ notifications: [] }),
72
72
);
73
73
});
74
74
it("shows loading text while fetching preferences", async () => {
75
-
mockEndpoint("com.tranquil.account.getNotificationPrefs", async () => {
75
+
mockEndpoint("_account.getNotificationPrefs", async () => {
76
76
await new Promise((resolve) => setTimeout(resolve, 100));
77
77
return jsonResponse(mockData.notificationPrefs());
78
78
});
···
88
88
() => jsonResponse(mockData.describeServer()),
89
89
);
90
90
mockEndpoint(
91
-
"com.tranquil.account.getNotificationHistory",
91
+
"_account.getNotificationHistory",
92
92
() => jsonResponse({ notifications: [] }),
93
93
);
94
94
});
95
95
it("displays all four channel options", async () => {
96
96
mockEndpoint(
97
-
"com.tranquil.account.getNotificationPrefs",
97
+
"_account.getNotificationPrefs",
98
98
() => jsonResponse(mockData.notificationPrefs()),
99
99
);
100
100
render(Comms);
···
111
111
});
112
112
it("email channel is always selectable", async () => {
113
113
mockEndpoint(
114
-
"com.tranquil.account.getNotificationPrefs",
114
+
"_account.getNotificationPrefs",
115
115
() => jsonResponse(mockData.notificationPrefs()),
116
116
);
117
117
render(Comms);
···
122
122
});
123
123
it("discord channel is disabled when not configured", async () => {
124
124
mockEndpoint(
125
-
"com.tranquil.account.getNotificationPrefs",
125
+
"_account.getNotificationPrefs",
126
126
() => jsonResponse(mockData.notificationPrefs({ discordId: null })),
127
127
);
128
128
render(Comms);
···
133
133
});
134
134
it("discord channel is enabled when configured", async () => {
135
135
mockEndpoint(
136
-
"com.tranquil.account.getNotificationPrefs",
136
+
"_account.getNotificationPrefs",
137
137
() =>
138
138
jsonResponse(mockData.notificationPrefs({ discordId: "123456789" })),
139
139
);
···
145
145
});
146
146
it("shows hint for disabled channels", async () => {
147
147
mockEndpoint(
148
-
"com.tranquil.account.getNotificationPrefs",
148
+
"_account.getNotificationPrefs",
149
149
() => jsonResponse(mockData.notificationPrefs()),
150
150
);
151
151
render(Comms);
···
156
156
});
157
157
it("selects current preferred channel", async () => {
158
158
mockEndpoint(
159
-
"com.tranquil.account.getNotificationPrefs",
159
+
"_account.getNotificationPrefs",
160
160
() =>
161
161
jsonResponse(
162
162
mockData.notificationPrefs({ preferredChannel: "email" }),
···
179
179
() => jsonResponse(mockData.describeServer()),
180
180
);
181
181
mockEndpoint(
182
-
"com.tranquil.account.getNotificationHistory",
182
+
"_account.getNotificationHistory",
183
183
() => jsonResponse({ notifications: [] }),
184
184
);
185
185
});
186
186
it("displays email as readonly with current value", async () => {
187
187
mockEndpoint(
188
-
"com.tranquil.account.getNotificationPrefs",
188
+
"_account.getNotificationPrefs",
189
189
() => jsonResponse(mockData.notificationPrefs()),
190
190
);
191
191
render(Comms);
···
199
199
});
200
200
it("displays all channel inputs with current values", async () => {
201
201
mockEndpoint(
202
-
"com.tranquil.account.getNotificationPrefs",
202
+
"_account.getNotificationPrefs",
203
203
() =>
204
204
jsonResponse(mockData.notificationPrefs({
205
205
discordId: "123456789",
···
231
231
() => jsonResponse(mockData.describeServer()),
232
232
);
233
233
mockEndpoint(
234
-
"com.tranquil.account.getNotificationHistory",
234
+
"_account.getNotificationHistory",
235
235
() => jsonResponse({ notifications: [] }),
236
236
);
237
237
});
238
238
it("shows Primary badge for email", async () => {
239
239
mockEndpoint(
240
-
"com.tranquil.account.getNotificationPrefs",
240
+
"_account.getNotificationPrefs",
241
241
() => jsonResponse(mockData.notificationPrefs()),
242
242
);
243
243
render(Comms);
···
247
247
});
248
248
it("shows Verified badge for verified discord", async () => {
249
249
mockEndpoint(
250
-
"com.tranquil.account.getNotificationPrefs",
250
+
"_account.getNotificationPrefs",
251
251
() =>
252
252
jsonResponse(mockData.notificationPrefs({
253
253
discordId: "123456789",
···
262
262
});
263
263
it("shows Not verified badge for unverified discord", async () => {
264
264
mockEndpoint(
265
-
"com.tranquil.account.getNotificationPrefs",
265
+
"_account.getNotificationPrefs",
266
266
() =>
267
267
jsonResponse(mockData.notificationPrefs({
268
268
discordId: "123456789",
···
276
276
});
277
277
it("does not show badge when channel not configured", async () => {
278
278
mockEndpoint(
279
-
"com.tranquil.account.getNotificationPrefs",
279
+
"_account.getNotificationPrefs",
280
280
() => jsonResponse(mockData.notificationPrefs()),
281
281
);
282
282
render(Comms);
···
294
294
() => jsonResponse(mockData.describeServer()),
295
295
);
296
296
mockEndpoint(
297
-
"com.tranquil.account.getNotificationHistory",
297
+
"_account.getNotificationHistory",
298
298
() => jsonResponse({ notifications: [] }),
299
299
);
300
300
});
301
301
it("calls updateNotificationPrefs with correct data", async () => {
302
302
let capturedBody: Record<string, unknown> | null = null;
303
303
mockEndpoint(
304
-
"com.tranquil.account.getNotificationPrefs",
304
+
"_account.getNotificationPrefs",
305
305
() => jsonResponse(mockData.notificationPrefs()),
306
306
);
307
307
mockEndpoint(
308
-
"com.tranquil.account.updateNotificationPrefs",
308
+
"_account.updateNotificationPrefs",
309
309
(_url, options) => {
310
310
capturedBody = JSON.parse((options?.body as string) || "{}");
311
311
return jsonResponse({ success: true });
···
329
329
});
330
330
it("shows loading state while saving", async () => {
331
331
mockEndpoint(
332
-
"com.tranquil.account.getNotificationPrefs",
332
+
"_account.getNotificationPrefs",
333
333
() => jsonResponse(mockData.notificationPrefs()),
334
334
);
335
-
mockEndpoint("com.tranquil.account.updateNotificationPrefs", async () => {
335
+
mockEndpoint("_account.updateNotificationPrefs", async () => {
336
336
await new Promise((resolve) => setTimeout(resolve, 100));
337
337
return jsonResponse({ success: true });
338
338
});
···
350
350
});
351
351
it("shows success message after saving", async () => {
352
352
mockEndpoint(
353
-
"com.tranquil.account.getNotificationPrefs",
353
+
"_account.getNotificationPrefs",
354
354
() => jsonResponse(mockData.notificationPrefs()),
355
355
);
356
356
mockEndpoint(
357
-
"com.tranquil.account.updateNotificationPrefs",
357
+
"_account.updateNotificationPrefs",
358
358
() => jsonResponse({ success: true }),
359
359
);
360
360
render(Comms);
···
372
372
});
373
373
it("shows error when save fails", async () => {
374
374
mockEndpoint(
375
-
"com.tranquil.account.getNotificationPrefs",
375
+
"_account.getNotificationPrefs",
376
376
() => jsonResponse(mockData.notificationPrefs()),
377
377
);
378
378
mockEndpoint(
379
-
"com.tranquil.account.updateNotificationPrefs",
379
+
"_account.updateNotificationPrefs",
380
380
() =>
381
381
errorResponse("InvalidRequest", "Invalid channel configuration", 400),
382
382
);
···
400
400
});
401
401
it("reloads preferences after successful save", async () => {
402
402
let loadCount = 0;
403
-
mockEndpoint("com.tranquil.account.getNotificationPrefs", () => {
403
+
mockEndpoint("_account.getNotificationPrefs", () => {
404
404
loadCount++;
405
405
return jsonResponse(mockData.notificationPrefs());
406
406
});
407
407
mockEndpoint(
408
-
"com.tranquil.account.updateNotificationPrefs",
408
+
"_account.updateNotificationPrefs",
409
409
() => jsonResponse({ success: true }),
410
410
);
411
411
render(Comms);
···
430
430
() => jsonResponse(mockData.describeServer()),
431
431
);
432
432
mockEndpoint(
433
-
"com.tranquil.account.getNotificationHistory",
433
+
"_account.getNotificationHistory",
434
434
() => jsonResponse({ notifications: [] }),
435
435
);
436
436
});
437
437
it("enables discord channel after entering discord ID", async () => {
438
438
mockEndpoint(
439
-
"com.tranquil.account.getNotificationPrefs",
439
+
"_account.getNotificationPrefs",
440
440
() => jsonResponse(mockData.notificationPrefs()),
441
441
);
442
442
render(Comms);
···
453
453
});
454
454
it("allows selecting a configured channel", async () => {
455
455
mockEndpoint(
456
-
"com.tranquil.account.getNotificationPrefs",
456
+
"_account.getNotificationPrefs",
457
457
() =>
458
458
jsonResponse(mockData.notificationPrefs({
459
459
discordId: "123456789",
···
480
480
() => jsonResponse(mockData.describeServer()),
481
481
);
482
482
mockEndpoint(
483
-
"com.tranquil.account.getNotificationHistory",
483
+
"_account.getNotificationHistory",
484
484
() => jsonResponse({ notifications: [] }),
485
485
);
486
486
});
487
487
it("shows error when loading preferences fails", async () => {
488
488
mockEndpoint(
489
-
"com.tranquil.account.getNotificationPrefs",
489
+
"_account.getNotificationPrefs",
490
490
() => errorResponse("InternalError", "Database connection failed", 500),
491
491
);
492
492
render(Comms);
+2
-2
frontend/src/tests/Settings.test.ts
+2
-2
frontend/src/tests/Settings.test.ts
···
8
8
mockData,
9
9
mockEndpoint,
10
10
setupAuthenticatedUser,
11
-
setupFetchMock,
11
+
setupDefaultMocks,
12
12
setupUnauthenticatedUser,
13
13
} from "./mocks";
14
14
describe("Settings", () => {
15
15
beforeEach(() => {
16
16
clearMocks();
17
-
setupFetchMock();
17
+
setupDefaultMocks();
18
18
globalThis.confirm = vi.fn(() => true);
19
19
});
20
20
describe("authentication guard", () => {
+491
frontend/src/tests/migration/offline-flow.test.ts
+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
+333
frontend/src/tests/migration/plc-ops.test.ts
···
1
+
import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
import { PlcOps, plcOps } from "../../lib/migration/plc-ops";
3
+
4
+
describe("migration/plc-ops", () => {
5
+
beforeEach(() => {
6
+
vi.restoreAllMocks();
7
+
});
8
+
9
+
describe("PlcOps class", () => {
10
+
it("uses default PLC directory URL", () => {
11
+
const ops = new PlcOps();
12
+
expect(ops).toBeDefined();
13
+
});
14
+
15
+
it("accepts custom PLC directory URL", () => {
16
+
const ops = new PlcOps("https://custom-plc.example.com");
17
+
expect(ops).toBeDefined();
18
+
});
19
+
});
20
+
21
+
describe("plcOps singleton", () => {
22
+
it("exports a singleton instance", () => {
23
+
expect(plcOps).toBeInstanceOf(PlcOps);
24
+
});
25
+
});
26
+
27
+
describe("getPlcAuditLogs", () => {
28
+
it("throws on HTTP error", async () => {
29
+
globalThis.fetch = vi.fn().mockResolvedValue({
30
+
ok: false,
31
+
status: 404,
32
+
});
33
+
34
+
await expect(plcOps.getPlcAuditLogs("did:plc:notfound")).rejects.toThrow(
35
+
"Failed to fetch PLC audit logs: 404",
36
+
);
37
+
});
38
+
});
39
+
40
+
describe("getLastPlcOpFromPlc", () => {
41
+
it("throws when empty array returned", async () => {
42
+
globalThis.fetch = vi.fn().mockResolvedValue({
43
+
ok: true,
44
+
json: () => Promise.resolve([]),
45
+
});
46
+
47
+
await expect(
48
+
plcOps.getLastPlcOpFromPlc("did:plc:empty"),
49
+
).rejects.toThrow();
50
+
});
51
+
});
52
+
53
+
describe("createNewSecp256k1Keypair", () => {
54
+
it("generates a keypair with private and public keys", async () => {
55
+
const result = await plcOps.createNewSecp256k1Keypair();
56
+
57
+
expect(result.privateKey).toBeDefined();
58
+
expect(result.publicKey).toBeDefined();
59
+
expect(result.publicKey.startsWith("did:key:")).toBe(true);
60
+
});
61
+
62
+
it("generates different keypairs each time", async () => {
63
+
const result1 = await plcOps.createNewSecp256k1Keypair();
64
+
const result2 = await plcOps.createNewSecp256k1Keypair();
65
+
66
+
expect(result1.privateKey).not.toBe(result2.privateKey);
67
+
expect(result1.publicKey).not.toBe(result2.publicKey);
68
+
});
69
+
});
70
+
71
+
describe("getKeyPair", () => {
72
+
it("parses 64-character hex private key", async () => {
73
+
const hexKey = "a".repeat(64);
74
+
75
+
const result = await plcOps.getKeyPair(hexKey);
76
+
77
+
expect(result.type).toBe("private_key");
78
+
expect(result.didPublicKey.startsWith("did:key:")).toBe(true);
79
+
expect(result.keypair).toBeDefined();
80
+
});
81
+
82
+
it("handles whitespace in key input", async () => {
83
+
const hexKey = " " + "b".repeat(64) + " ";
84
+
85
+
const result = await plcOps.getKeyPair(hexKey);
86
+
87
+
expect(result.type).toBe("private_key");
88
+
});
89
+
90
+
it("throws for invalid key format", async () => {
91
+
await expect(plcOps.getKeyPair("not-a-valid-key")).rejects.toThrow(
92
+
"Invalid key format",
93
+
);
94
+
});
95
+
96
+
it("throws for hex key with wrong length", async () => {
97
+
await expect(plcOps.getKeyPair("abc123")).rejects.toThrow(
98
+
"Invalid key format",
99
+
);
100
+
});
101
+
});
102
+
103
+
describe("pushPlcOperation", () => {
104
+
it("posts operation to PLC directory", async () => {
105
+
globalThis.fetch = vi.fn().mockResolvedValue({
106
+
ok: true,
107
+
});
108
+
109
+
const operation = {
110
+
type: "plc_operation" as const,
111
+
prev: "bafyreiabc",
112
+
alsoKnownAs: ["at://alice.example.com"],
113
+
rotationKeys: ["did:key:z123"],
114
+
services: {
115
+
atproto_pds: {
116
+
type: "AtprotoPersonalDataServer",
117
+
endpoint: "https://pds.example.com",
118
+
},
119
+
},
120
+
verificationMethods: {
121
+
atproto: "did:key:z456",
122
+
},
123
+
sig: "test-signature",
124
+
};
125
+
126
+
await plcOps.pushPlcOperation("did:plc:abc123", operation);
127
+
128
+
expect(fetch).toHaveBeenCalledWith(
129
+
"https://plc.directory/did:plc:abc123",
130
+
expect.objectContaining({
131
+
method: "POST",
132
+
headers: { "Content-Type": "application/json" },
133
+
body: JSON.stringify(operation),
134
+
}),
135
+
);
136
+
});
137
+
138
+
it("throws with error message from PLC directory", async () => {
139
+
globalThis.fetch = vi.fn().mockResolvedValue({
140
+
ok: false,
141
+
status: 400,
142
+
headers: new Map([["content-type", "application/json"]]),
143
+
json: () => Promise.resolve({ message: "Invalid signature" }),
144
+
});
145
+
146
+
const operation = {
147
+
type: "plc_operation" as const,
148
+
prev: "bafyreiabc",
149
+
alsoKnownAs: [],
150
+
rotationKeys: ["did:key:z123"],
151
+
services: {},
152
+
verificationMethods: {},
153
+
sig: "bad-sig",
154
+
};
155
+
156
+
await expect(
157
+
plcOps.pushPlcOperation("did:plc:abc123", operation),
158
+
).rejects.toThrow("Invalid signature");
159
+
});
160
+
161
+
it("throws generic error when no message in response", async () => {
162
+
globalThis.fetch = vi.fn().mockResolvedValue({
163
+
ok: false,
164
+
status: 500,
165
+
headers: new Map([["content-type", "text/plain"]]),
166
+
});
167
+
168
+
const operation = {
169
+
type: "plc_operation" as const,
170
+
prev: null,
171
+
alsoKnownAs: [],
172
+
rotationKeys: [],
173
+
services: {},
174
+
verificationMethods: {},
175
+
};
176
+
177
+
await expect(
178
+
plcOps.pushPlcOperation("did:plc:abc123", operation),
179
+
).rejects.toThrow("PLC directory returned HTTP 500");
180
+
});
181
+
});
182
+
183
+
describe("createServiceAuthToken", () => {
184
+
it("creates a valid JWT", async () => {
185
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
186
+
const keypair = await plcOps.getKeyPair(privateKey);
187
+
188
+
const token = await plcOps.createServiceAuthToken(
189
+
"did:plc:issuer",
190
+
"did:web:audience.example.com",
191
+
keypair.keypair,
192
+
"com.atproto.server.createAccount",
193
+
);
194
+
195
+
expect(token).toBeDefined();
196
+
const parts = token.split(".");
197
+
expect(parts).toHaveLength(3);
198
+
});
199
+
200
+
it("includes correct header", async () => {
201
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
202
+
const keypair = await plcOps.getKeyPair(privateKey);
203
+
204
+
const token = await plcOps.createServiceAuthToken(
205
+
"did:plc:issuer",
206
+
"did:web:audience",
207
+
keypair.keypair,
208
+
"com.atproto.server.createAccount",
209
+
);
210
+
211
+
const headerB64 = token.split(".")[0];
212
+
const header = JSON.parse(
213
+
atob(headerB64.replace(/-/g, "+").replace(/_/g, "/")),
214
+
);
215
+
expect(header.typ).toBe("JWT");
216
+
expect(header.alg).toBe("ES256K");
217
+
});
218
+
219
+
it("includes correct payload claims", async () => {
220
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
221
+
const keypair = await plcOps.getKeyPair(privateKey);
222
+
223
+
const before = Math.floor(Date.now() / 1000);
224
+
const token = await plcOps.createServiceAuthToken(
225
+
"did:plc:myissuer",
226
+
"did:web:myaudience.com",
227
+
keypair.keypair,
228
+
"com.atproto.sync.getRepo",
229
+
);
230
+
const after = Math.floor(Date.now() / 1000);
231
+
232
+
const payloadB64 = token.split(".")[1];
233
+
const payload = JSON.parse(
234
+
atob(payloadB64.replace(/-/g, "+").replace(/_/g, "/")),
235
+
);
236
+
237
+
expect(payload.iss).toBe("did:plc:myissuer");
238
+
expect(payload.aud).toBe("did:web:myaudience.com");
239
+
expect(payload.lxm).toBe("com.atproto.sync.getRepo");
240
+
expect(payload.iat).toBeGreaterThanOrEqual(before);
241
+
expect(payload.iat).toBeLessThanOrEqual(after);
242
+
expect(payload.exp).toBe(payload.iat + 60);
243
+
expect(payload.jti).toBeDefined();
244
+
});
245
+
});
246
+
247
+
describe("signAndPublishNewOp", () => {
248
+
it("throws when no rotation keys provided", async () => {
249
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
250
+
const keypair = await plcOps.getKeyPair(privateKey);
251
+
252
+
await expect(
253
+
plcOps.signAndPublishNewOp(
254
+
"did:plc:test",
255
+
keypair.keypair,
256
+
["at://alice.example.com"],
257
+
[],
258
+
"https://pds.example.com",
259
+
"did:key:zVerify",
260
+
"bafyreiprev",
261
+
),
262
+
).rejects.toThrow("No rotation keys provided");
263
+
});
264
+
265
+
it("throws when more than 5 unique rotation keys provided", async () => {
266
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
267
+
const keypair = await plcOps.getKeyPair(privateKey);
268
+
269
+
const tooManyKeys = [
270
+
"did:key:z1",
271
+
"did:key:z2",
272
+
"did:key:z3",
273
+
"did:key:z4",
274
+
"did:key:z5",
275
+
"did:key:z6",
276
+
];
277
+
278
+
await expect(
279
+
plcOps.signAndPublishNewOp(
280
+
"did:plc:test",
281
+
keypair.keypair,
282
+
[],
283
+
tooManyKeys,
284
+
"https://pds.example.com",
285
+
"did:key:zVerify",
286
+
"bafyreiprev",
287
+
),
288
+
).rejects.toThrow("Maximum 5 rotation keys allowed");
289
+
});
290
+
});
291
+
292
+
describe("signPlcOperationWithCredentials", () => {
293
+
it("throws when no rotation keys provided", async () => {
294
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
295
+
const keypair = await plcOps.getKeyPair(privateKey);
296
+
297
+
await expect(
298
+
plcOps.signPlcOperationWithCredentials(
299
+
"did:plc:test",
300
+
keypair.keypair,
301
+
{
302
+
rotationKeys: [],
303
+
alsoKnownAs: [],
304
+
verificationMethods: {},
305
+
services: {},
306
+
},
307
+
[],
308
+
"bafyreiprev",
309
+
),
310
+
).rejects.toThrow("No rotation keys provided");
311
+
});
312
+
313
+
it("throws when more than 5 rotation keys provided", async () => {
314
+
const { privateKey } = await plcOps.createNewSecp256k1Keypair();
315
+
const keypair = await plcOps.getKeyPair(privateKey);
316
+
317
+
await expect(
318
+
plcOps.signPlcOperationWithCredentials(
319
+
"did:plc:test",
320
+
keypair.keypair,
321
+
{
322
+
rotationKeys: ["did:key:z1", "did:key:z2", "did:key:z3"],
323
+
alsoKnownAs: [],
324
+
verificationMethods: {},
325
+
services: {},
326
+
},
327
+
["did:key:z4", "did:key:z5", "did:key:z6"],
328
+
"bafyreiprev",
329
+
),
330
+
).rejects.toThrow("Maximum 5 rotation keys allowed");
331
+
});
332
+
});
333
+
});
+7
-3
frontend/src/tests/mocks.ts
+7
-3
frontend/src/tests/mocks.ts
···
206
206
() => jsonResponse({ code: "new-invite-" + Date.now() }),
207
207
);
208
208
mockEndpoint(
209
-
"com.tranquil.account.getNotificationPrefs",
209
+
"_account.getNotificationPrefs",
210
210
() => jsonResponse(mockData.notificationPrefs()),
211
211
);
212
212
mockEndpoint(
213
-
"com.tranquil.account.updateNotificationPrefs",
213
+
"_account.updateNotificationPrefs",
214
214
() => jsonResponse({ success: true }),
215
215
);
216
216
mockEndpoint(
217
-
"com.tranquil.account.getNotificationHistory",
217
+
"_account.getNotificationHistory",
218
218
() => jsonResponse({ notifications: [] }),
219
219
);
220
220
mockEndpoint(
···
240
240
mockEndpoint(
241
241
"com.atproto.repo.listRecords",
242
242
() => jsonResponse({ records: [] }),
243
+
);
244
+
mockEndpoint(
245
+
"_backup.listBackups",
246
+
() => jsonResponse({ backups: [] }),
243
247
);
244
248
}
245
249
export function setupAuthenticatedUser(
+15
migrations/20260101_account_backups.sql
+15
migrations/20260101_account_backups.sql
···
1
+
ALTER TABLE users ADD COLUMN backup_enabled BOOLEAN NOT NULL DEFAULT TRUE;
2
+
3
+
CREATE TABLE account_backups (
4
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5
+
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6
+
storage_key TEXT NOT NULL,
7
+
repo_root_cid TEXT NOT NULL,
8
+
repo_rev TEXT NOT NULL,
9
+
block_count INT NOT NULL,
10
+
size_bytes BIGINT NOT NULL,
11
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
12
+
);
13
+
14
+
CREATE INDEX idx_account_backups_user_id ON account_backups(user_id);
15
+
CREATE INDEX idx_account_backups_created_at ON account_backups(created_at);
+6
-3
scripts/install-debian.sh
+6
-3
scripts/install-debian.sh
···
44
44
sudo -u postgres psql -c "DROP DATABASE IF EXISTS pds;" 2>/dev/null || true
45
45
sudo -u postgres psql -c "DROP USER IF EXISTS tranquil_pds;" 2>/dev/null || true
46
46
47
-
log_info "Removing minio bucket..."
47
+
log_info "Removing minio buckets..."
48
48
if command -v mc &>/dev/null; then
49
49
mc rb local/pds-blobs --force 2>/dev/null || true
50
+
mc rb local/pds-backups --force 2>/dev/null || true
50
51
mc alias remove local 2>/dev/null || true
51
52
fi
52
53
systemctl stop minio 2>/dev/null || true
···
78
79
echo " - PostgreSQL database 'pds' and all data"
79
80
echo " - All Tranquil PDS configuration and credentials"
80
81
echo " - All source code in /opt/tranquil-pds"
81
-
echo " - MinIO bucket 'pds-blobs' and all blobs"
82
+
echo " - MinIO buckets 'pds-blobs' and 'pds-backups' and all data"
82
83
echo ""
83
84
read -p "Type 'NUKE' to confirm: " CONFIRM_NUKE
84
85
if [[ "$CONFIRM_NUKE" == "NUKE" ]]; then
···
274
275
mc alias remove local 2>/dev/null || true
275
276
mc alias set local http://localhost:9000 minioadmin "${MINIO_PASSWORD}" --api S3v4
276
277
mc mb local/pds-blobs --ignore-existing
277
-
log_success "minio bucket created"
278
+
mc mb local/pds-backups --ignore-existing
279
+
log_success "minio buckets created"
278
280
279
281
log_info "Installing rust..."
280
282
if [[ -f "$HOME/.cargo/env" ]]; then
···
382
384
S3_ENDPOINT=http://localhost:9000
383
385
AWS_REGION=us-east-1
384
386
S3_BUCKET=pds-blobs
387
+
BACKUP_S3_BUCKET=pds-backups
385
388
AWS_ACCESS_KEY_ID=minioadmin
386
389
AWS_SECRET_ACCESS_KEY=${MINIO_PASSWORD}
387
390
VALKEY_URL=redis://localhost:6379
+5
-1
scripts/test-infra.sh
+5
-1
scripts/test-infra.sh
···
83
83
echo "Waiting for Valkey... ($i/30)"
84
84
sleep 1
85
85
done
86
-
echo "Creating MinIO bucket..."
86
+
echo "Creating MinIO buckets..."
87
87
$CONTAINER_CMD run --rm --network host \
88
88
-e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \
89
89
minio/mc:latest mb minio/test-bucket --ignore-existing >/dev/null 2>&1 || true
90
+
$CONTAINER_CMD run --rm --network host \
91
+
-e MC_HOST_minio="http://minioadmin:minioadmin@127.0.0.1:${MINIO_PORT}" \
92
+
minio/mc:latest mb minio/test-backups --ignore-existing >/dev/null 2>&1 || true
90
93
cat > "$INFRA_FILE" << EOF
91
94
export DATABASE_URL="postgres://postgres:postgres@127.0.0.1:${PG_PORT}/postgres"
92
95
export TEST_DB_PORT="${PG_PORT}"
93
96
export S3_ENDPOINT="http://127.0.0.1:${MINIO_PORT}"
94
97
export S3_BUCKET="test-bucket"
98
+
export BACKUP_S3_BUCKET="test-backups"
95
99
export AWS_ACCESS_KEY_ID="minioadmin"
96
100
export AWS_SECRET_ACCESS_KEY="minioadmin"
97
101
export AWS_REGION="us-east-1"
+930
src/api/backup.rs
+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
src/api/mod.rs
+26
-7
src/api/notification_prefs.rs
+26
-7
src/api/notification_prefs.rs
···
182
182
.into_response(),
183
183
};
184
184
185
+
let sensitive_types = [
186
+
"email_verification",
187
+
"password_reset",
188
+
"email_update",
189
+
"two_factor_code",
190
+
"passkey_recovery",
191
+
"migration_verification",
192
+
"plc_operation",
193
+
"channel_verification",
194
+
"signup_verification",
195
+
];
196
+
185
197
let notifications = rows
186
198
.iter()
187
-
.map(|row| NotificationHistoryEntry {
188
-
created_at: row.created_at.to_rfc3339(),
189
-
channel: row.channel.clone(),
190
-
comms_type: row.comms_type.clone(),
191
-
status: row.status.clone(),
192
-
subject: row.subject.clone(),
193
-
body: row.body.clone(),
199
+
.map(|row| {
200
+
let body = if sensitive_types.contains(&row.comms_type.as_str()) {
201
+
"[Code redacted for security]".to_string()
202
+
} else {
203
+
row.body.clone()
204
+
};
205
+
NotificationHistoryEntry {
206
+
created_at: row.created_at.to_rfc3339(),
207
+
channel: row.channel.clone(),
208
+
comms_type: row.comms_type.clone(),
209
+
status: row.status.clone(),
210
+
subject: row.subject.clone(),
211
+
body,
212
+
}
194
213
})
195
214
.collect();
196
215
+1
-1
src/api/repo/blob.rs
+1
-1
src/api/repo/blob.rs
···
312
312
r#"
313
313
SELECT rb.blob_cid, rb.record_uri
314
314
FROM record_blobs rb
315
-
LEFT JOIN blobs b ON rb.blob_cid = b.cid AND b.created_by_user = rb.repo_id
315
+
LEFT JOIN blobs b ON rb.blob_cid = b.cid
316
316
WHERE rb.repo_id = $1 AND b.cid IS NULL AND rb.blob_cid > $2
317
317
ORDER BY rb.blob_cid
318
318
LIMIT $3
+4
-2
src/api/repo/record/batch.rs
+4
-2
src/api/repo/record/batch.rs
···
345
345
let rkey = rkey
346
346
.clone()
347
347
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
348
+
let record_ipld = crate::util::json_to_ipld(value);
348
349
let mut record_bytes = Vec::new();
349
-
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
350
+
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
350
351
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
351
352
}
352
353
let record_cid = match tracking_store.put(&record_bytes).await {
···
409
410
}
410
411
};
411
412
all_blob_cids.extend(extract_blob_cids(value));
413
+
let record_ipld = crate::util::json_to_ipld(value);
412
414
let mut record_bytes = Vec::new();
413
-
if serde_ipld_dagcbor::to_writer(&mut record_bytes, value).is_err() {
415
+
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
414
416
return (StatusCode::BAD_REQUEST, Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"}))).into_response();
415
417
}
416
418
let record_cid = match tracking_store.put(&record_bytes).await {
+2
-1
src/api/repo/record/utils.rs
+2
-1
src/api/repo/record/utils.rs
···
382
382
let commit = jacquard_repo::commit::Commit::from_cbor(&commit_bytes)
383
383
.map_err(|e| format!("Failed to parse commit: {:?}", e))?;
384
384
let mst = Mst::load(Arc::new(tracking_store.clone()), commit.data, None);
385
+
let record_ipld = crate::util::json_to_ipld(record);
385
386
let mut record_bytes = Vec::new();
386
-
serde_ipld_dagcbor::to_writer(&mut record_bytes, record)
387
+
serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld)
387
388
.map_err(|e| format!("Failed to serialize record: {:?}", e))?;
388
389
let record_cid = tracking_store
389
390
.put(&record_bytes)
+4
-2
src/api/repo/record/write.rs
+4
-2
src/api/repo/record/write.rs
···
297
297
let rkey = input
298
298
.rkey
299
299
.unwrap_or_else(|| Tid::now(LimitedU32::MIN).to_string());
300
+
let record_ipld = crate::util::json_to_ipld(&input.record);
300
301
let mut record_bytes = Vec::new();
301
-
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() {
302
+
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
302
303
return (
303
304
StatusCode::BAD_REQUEST,
304
305
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
···
550
551
}
551
552
}
552
553
let existing_cid = mst.get(&key).await.ok().flatten();
554
+
let record_ipld = crate::util::json_to_ipld(&input.record);
553
555
let mut record_bytes = Vec::new();
554
-
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &input.record).is_err() {
556
+
if serde_ipld_dagcbor::to_writer(&mut record_bytes, &record_ipld).is_err() {
555
557
return (
556
558
StatusCode::BAD_REQUEST,
557
559
Json(json!({"error": "InvalidRecord", "message": "Failed to serialize record"})),
+15
-44
src/api/server/account_status.rs
+15
-44
src/api/server/account_status.rs
···
567
567
#[serde(rename_all = "camelCase")]
568
568
pub struct DeactivateAccountInput {
569
569
pub delete_after: Option<String>,
570
-
pub migrating_to: Option<String>,
571
570
}
572
571
573
572
pub async fn deactivate_account(
···
618
617
619
618
let did = auth_user.did;
620
619
621
-
let migrating_to = if let Some(ref url) = input.migrating_to {
622
-
let url = url.trim().trim_end_matches('/');
623
-
if url.is_empty() || !did.starts_with("did:web:") {
624
-
None
625
-
} else {
626
-
if !url.starts_with("https://") {
627
-
return ApiError::InvalidRequest("migratingTo must start with https://".into())
628
-
.into_response();
629
-
}
630
-
Some(url.to_string())
631
-
}
632
-
} else {
633
-
None
634
-
};
635
-
636
620
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
637
621
.fetch_optional(&state.db)
638
622
.await
639
623
.ok()
640
624
.flatten();
641
625
642
-
let result = if let Some(ref pds_url) = migrating_to {
643
-
sqlx::query!(
644
-
"UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1",
645
-
did,
646
-
delete_after,
647
-
pds_url
648
-
)
649
-
.execute(&state.db)
650
-
.await
651
-
} else {
652
-
sqlx::query!(
653
-
"UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
654
-
did,
655
-
delete_after
656
-
)
657
-
.execute(&state.db)
658
-
.await
659
-
};
660
-
661
-
let status = if migrating_to.is_some() {
662
-
"migrated"
663
-
} else {
664
-
"deactivated"
665
-
};
626
+
let result = sqlx::query!(
627
+
"UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
628
+
did,
629
+
delete_after
630
+
)
631
+
.execute(&state.db)
632
+
.await;
666
633
667
634
match result {
668
635
Ok(_) => {
669
636
if let Some(ref h) = handle {
670
637
let _ = state.cache.delete(&format!("handle:{}", h)).await;
671
638
}
672
-
if let Err(e) =
673
-
crate::api::repo::record::sequence_account_event(&state, &did, false, Some(status))
674
-
.await
639
+
if let Err(e) = crate::api::repo::record::sequence_account_event(
640
+
&state,
641
+
&did,
642
+
false,
643
+
Some("deactivated"),
644
+
)
645
+
.await
675
646
{
676
-
warn!("Failed to sequence account {} event: {}", status, e);
647
+
warn!("Failed to sequence account deactivated event: {}", e);
677
648
}
678
649
(StatusCode::OK, Json(json!({}))).into_response()
679
650
}
+54
src/api/server/email.rs
+54
src/api/server/email.rs
···
476
476
info!("Email updated for user {}", user_id);
477
477
(StatusCode::OK, Json(json!({}))).into_response()
478
478
}
479
+
480
+
#[derive(Deserialize)]
481
+
pub struct CheckEmailVerifiedInput {
482
+
pub identifier: String,
483
+
}
484
+
485
+
pub async fn check_email_verified(
486
+
State(state): State<AppState>,
487
+
headers: axum::http::HeaderMap,
488
+
Json(input): Json<CheckEmailVerifiedInput>,
489
+
) -> Response {
490
+
let client_ip = crate::rate_limit::extract_client_ip(&headers, None);
491
+
if !state
492
+
.check_rate_limit(RateLimitKind::VerificationCheck, &client_ip)
493
+
.await
494
+
{
495
+
return (
496
+
StatusCode::TOO_MANY_REQUESTS,
497
+
Json(json!({
498
+
"error": "RateLimitExceeded",
499
+
"message": "Too many requests. Please try again later."
500
+
})),
501
+
)
502
+
.into_response();
503
+
}
504
+
505
+
let user = sqlx::query!(
506
+
"SELECT email_verified FROM users WHERE email = $1 OR handle = $1",
507
+
input.identifier
508
+
)
509
+
.fetch_optional(&state.db)
510
+
.await;
511
+
512
+
match user {
513
+
Ok(Some(row)) => (
514
+
StatusCode::OK,
515
+
Json(json!({ "verified": row.email_verified })),
516
+
)
517
+
.into_response(),
518
+
Ok(None) => (
519
+
StatusCode::NOT_FOUND,
520
+
Json(json!({ "error": "AccountNotFound", "message": "Account not found" })),
521
+
)
522
+
.into_response(),
523
+
Err(e) => {
524
+
error!("DB error checking email verified: {:?}", e);
525
+
(
526
+
StatusCode::INTERNAL_SERVER_ERROR,
527
+
Json(json!({ "error": "InternalError" })),
528
+
)
529
+
.into_response()
530
+
}
531
+
}
532
+
}
+6
-241
src/api/server/migration.rs
+6
-241
src/api/server/migration.rs
···
6
6
http::StatusCode,
7
7
response::{IntoResponse, Response},
8
8
};
9
-
use chrono::{DateTime, Utc};
9
+
use chrono::Utc;
10
10
use serde::{Deserialize, Serialize};
11
11
use serde_json::json;
12
12
13
-
#[derive(Serialize)]
14
-
#[serde(rename_all = "camelCase")]
15
-
pub struct GetMigrationStatusOutput {
16
-
pub did: String,
17
-
pub did_type: String,
18
-
pub migrated: bool,
19
-
#[serde(skip_serializing_if = "Option::is_none")]
20
-
pub migrated_to_pds: Option<String>,
21
-
#[serde(skip_serializing_if = "Option::is_none")]
22
-
pub migrated_at: Option<DateTime<Utc>>,
23
-
}
24
-
25
-
pub async fn get_migration_status(
26
-
State(state): State<AppState>,
27
-
headers: axum::http::HeaderMap,
28
-
) -> Response {
29
-
let extracted = match crate::auth::extract_auth_token_from_header(
30
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
31
-
) {
32
-
Some(t) => t,
33
-
None => return ApiError::AuthenticationRequired.into_response(),
34
-
};
35
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
36
-
let http_uri = format!(
37
-
"https://{}/xrpc/com.tranquil.account.getMigrationStatus",
38
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
39
-
);
40
-
let auth_user = match crate::auth::validate_token_with_dpop(
41
-
&state.db,
42
-
&extracted.token,
43
-
extracted.is_dpop,
44
-
dpop_proof,
45
-
"GET",
46
-
&http_uri,
47
-
true,
48
-
)
49
-
.await
50
-
{
51
-
Ok(user) => user,
52
-
Err(e) => return ApiError::from(e).into_response(),
53
-
};
54
-
let user = match sqlx::query!(
55
-
"SELECT did, migrated_to_pds, migrated_at FROM users WHERE did = $1",
56
-
auth_user.did
57
-
)
58
-
.fetch_optional(&state.db)
59
-
.await
60
-
{
61
-
Ok(Some(row)) => row,
62
-
Ok(None) => return ApiError::AccountNotFound.into_response(),
63
-
Err(e) => {
64
-
tracing::error!("DB error getting migration status: {:?}", e);
65
-
return ApiError::InternalError.into_response();
66
-
}
67
-
};
68
-
let did_type = if user.did.starts_with("did:plc:") {
69
-
"plc"
70
-
} else if user.did.starts_with("did:web:") {
71
-
"web"
72
-
} else {
73
-
"unknown"
74
-
};
75
-
let migrated = user.migrated_to_pds.is_some();
76
-
(
77
-
StatusCode::OK,
78
-
Json(GetMigrationStatusOutput {
79
-
did: user.did,
80
-
did_type: did_type.to_string(),
81
-
migrated,
82
-
migrated_to_pds: user.migrated_to_pds,
83
-
migrated_at: user.migrated_at,
84
-
}),
85
-
)
86
-
.into_response()
87
-
}
88
-
89
-
#[derive(Deserialize)]
90
-
#[serde(rename_all = "camelCase")]
91
-
pub struct UpdateMigrationForwardingInput {
92
-
pub pds_url: String,
93
-
}
94
-
95
-
#[derive(Serialize)]
96
-
#[serde(rename_all = "camelCase")]
97
-
pub struct UpdateMigrationForwardingOutput {
98
-
pub success: bool,
99
-
pub migrated_to_pds: String,
100
-
pub migrated_at: DateTime<Utc>,
101
-
}
102
-
103
-
pub async fn update_migration_forwarding(
104
-
State(state): State<AppState>,
105
-
headers: axum::http::HeaderMap,
106
-
Json(input): Json<UpdateMigrationForwardingInput>,
107
-
) -> Response {
108
-
let extracted = match crate::auth::extract_auth_token_from_header(
109
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
110
-
) {
111
-
Some(t) => t,
112
-
None => return ApiError::AuthenticationRequired.into_response(),
113
-
};
114
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
115
-
let http_uri = format!(
116
-
"https://{}/xrpc/com.tranquil.account.updateMigrationForwarding",
117
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
118
-
);
119
-
let auth_user = match crate::auth::validate_token_with_dpop(
120
-
&state.db,
121
-
&extracted.token,
122
-
extracted.is_dpop,
123
-
dpop_proof,
124
-
"POST",
125
-
&http_uri,
126
-
true,
127
-
)
128
-
.await
129
-
{
130
-
Ok(user) => user,
131
-
Err(e) => return ApiError::from(e).into_response(),
132
-
};
133
-
if !auth_user.did.starts_with("did:web:") {
134
-
return (
135
-
StatusCode::BAD_REQUEST,
136
-
Json(json!({
137
-
"error": "InvalidRequest",
138
-
"message": "Migration forwarding is only available for did:web accounts. did:plc accounts use PLC directory for identity updates."
139
-
})),
140
-
)
141
-
.into_response();
142
-
}
143
-
let pds_url = input.pds_url.trim();
144
-
if pds_url.is_empty() {
145
-
return ApiError::InvalidRequest("pds_url is required".into()).into_response();
146
-
}
147
-
if !pds_url.starts_with("https://") {
148
-
return ApiError::InvalidRequest("pds_url must start with https://".into()).into_response();
149
-
}
150
-
let pds_url_clean = pds_url.trim_end_matches('/');
151
-
let now = Utc::now();
152
-
let result = sqlx::query!(
153
-
"UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
154
-
pds_url_clean,
155
-
now,
156
-
auth_user.did
157
-
)
158
-
.execute(&state.db)
159
-
.await;
160
-
match result {
161
-
Ok(_) => {
162
-
tracing::info!(
163
-
"Updated migration forwarding for {} to {}",
164
-
auth_user.did,
165
-
pds_url_clean
166
-
);
167
-
(
168
-
StatusCode::OK,
169
-
Json(UpdateMigrationForwardingOutput {
170
-
success: true,
171
-
migrated_to_pds: pds_url_clean.to_string(),
172
-
migrated_at: now,
173
-
}),
174
-
)
175
-
.into_response()
176
-
}
177
-
Err(e) => {
178
-
tracing::error!("DB error updating migration forwarding: {:?}", e);
179
-
ApiError::InternalError.into_response()
180
-
}
181
-
}
182
-
}
183
-
184
-
pub async fn clear_migration_forwarding(
185
-
State(state): State<AppState>,
186
-
headers: axum::http::HeaderMap,
187
-
) -> Response {
188
-
let extracted = match crate::auth::extract_auth_token_from_header(
189
-
headers.get("Authorization").and_then(|h| h.to_str().ok()),
190
-
) {
191
-
Some(t) => t,
192
-
None => return ApiError::AuthenticationRequired.into_response(),
193
-
};
194
-
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
195
-
let http_uri = format!(
196
-
"https://{}/xrpc/com.tranquil.account.clearMigrationForwarding",
197
-
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
198
-
);
199
-
let auth_user = match crate::auth::validate_token_with_dpop(
200
-
&state.db,
201
-
&extracted.token,
202
-
extracted.is_dpop,
203
-
dpop_proof,
204
-
"POST",
205
-
&http_uri,
206
-
true,
207
-
)
208
-
.await
209
-
{
210
-
Ok(user) => user,
211
-
Err(e) => return ApiError::from(e).into_response(),
212
-
};
213
-
if !auth_user.did.starts_with("did:web:") {
214
-
return (
215
-
StatusCode::BAD_REQUEST,
216
-
Json(json!({
217
-
"error": "InvalidRequest",
218
-
"message": "Migration forwarding is only available for did:web accounts"
219
-
})),
220
-
)
221
-
.into_response();
222
-
}
223
-
let result = sqlx::query!(
224
-
"UPDATE users SET migrated_to_pds = NULL, migrated_at = NULL WHERE did = $1",
225
-
auth_user.did
226
-
)
227
-
.execute(&state.db)
228
-
.await;
229
-
match result {
230
-
Ok(_) => {
231
-
tracing::info!("Cleared migration forwarding for {}", auth_user.did);
232
-
(StatusCode::OK, Json(json!({ "success": true }))).into_response()
233
-
}
234
-
Err(e) => {
235
-
tracing::error!("DB error clearing migration forwarding: {:?}", e);
236
-
ApiError::InternalError.into_response()
237
-
}
238
-
}
239
-
}
240
-
241
13
#[derive(Debug, Clone, Serialize, Deserialize)]
242
14
#[serde(rename_all = "camelCase")]
243
15
pub struct VerificationMethod {
···
275
47
};
276
48
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
277
49
let http_uri = format!(
278
-
"https://{}/xrpc/com.tranquil.account.updateDidDocument",
50
+
"https://{}/xrpc/_account.updateDidDocument",
279
51
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
280
52
);
281
53
let auth_user = match crate::auth::validate_token_with_dpop(
···
305
77
}
306
78
307
79
let user = match sqlx::query!(
308
-
"SELECT id, migrated_to_pds, handle FROM users WHERE did = $1",
80
+
"SELECT id, handle, deactivated_at FROM users WHERE did = $1",
309
81
auth_user.did
310
82
)
311
83
.fetch_optional(&state.db)
···
319
91
}
320
92
};
321
93
322
-
if user.migrated_to_pds.is_none() {
323
-
return (
324
-
StatusCode::BAD_REQUEST,
325
-
Json(json!({
326
-
"error": "InvalidRequest",
327
-
"message": "DID document updates are only available for migrated accounts. Use the migration flow to migrate first."
328
-
})),
329
-
)
330
-
.into_response();
94
+
if user.deactivated_at.is_some() {
95
+
return ApiError::AccountDeactivated.into_response();
331
96
}
332
97
333
98
if let Some(ref methods) = input.verification_methods {
···
452
217
};
453
218
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
454
219
let http_uri = format!(
455
-
"https://{}/xrpc/com.tranquil.account.getDidDocument",
220
+
"https://{}/xrpc/_account.getDidDocument",
456
221
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
457
222
);
458
223
let auth_user = match crate::auth::validate_token_with_dpop(
+2
-5
src/api/server/mod.rs
+2
-5
src/api/server/mod.rs
···
22
22
request_account_delete,
23
23
};
24
24
pub use app_password::{create_app_password, list_app_passwords, revoke_app_password};
25
-
pub use email::{confirm_email, request_email_update, update_email};
25
+
pub use email::{check_email_verified, confirm_email, request_email_update, update_email};
26
26
pub use invite::{create_invite_code, create_invite_codes, get_account_invite_codes};
27
27
pub use logo::get_logo;
28
28
pub use meta::{describe_server, health, robots_txt};
29
-
pub use migration::{
30
-
clear_migration_forwarding, get_did_document, get_migration_status, update_did_document,
31
-
update_migration_forwarding,
32
-
};
29
+
pub use migration::{get_did_document, update_did_document};
33
30
pub use passkey_account::{
34
31
complete_passkey_setup, create_passkey_account, recover_passkey_account,
35
32
request_passkey_recovery, start_passkey_registration_for_setup,
+59
-55
src/lib.rs
+59
-55
src/lib.rs
···
57
57
get(api::server::get_session),
58
58
)
59
59
.route(
60
-
"/xrpc/com.tranquil.account.listSessions",
60
+
"/xrpc/_account.listSessions",
61
61
get(api::server::list_sessions),
62
62
)
63
63
.route(
64
-
"/xrpc/com.tranquil.account.revokeSession",
64
+
"/xrpc/_account.revokeSession",
65
65
post(api::server::revoke_session),
66
66
)
67
67
.route(
68
-
"/xrpc/com.tranquil.account.revokeAllSessions",
68
+
"/xrpc/_account.revokeAllSessions",
69
69
post(api::server::revoke_all_sessions),
70
70
)
71
71
.route(
···
208
208
post(api::server::reset_password),
209
209
)
210
210
.route(
211
-
"/xrpc/com.tranquil.account.changePassword",
211
+
"/xrpc/_account.changePassword",
212
212
post(api::server::change_password),
213
213
)
214
214
.route(
215
-
"/xrpc/com.tranquil.account.removePassword",
215
+
"/xrpc/_account.removePassword",
216
216
post(api::server::remove_password),
217
217
)
218
218
.route(
219
-
"/xrpc/com.tranquil.account.getPasswordStatus",
219
+
"/xrpc/_account.getPasswordStatus",
220
220
get(api::server::get_password_status),
221
221
)
222
222
.route(
223
-
"/xrpc/com.tranquil.account.getReauthStatus",
223
+
"/xrpc/_account.getReauthStatus",
224
224
get(api::server::get_reauth_status),
225
225
)
226
226
.route(
227
-
"/xrpc/com.tranquil.account.reauthPassword",
227
+
"/xrpc/_account.reauthPassword",
228
228
post(api::server::reauth_password),
229
229
)
230
-
.route(
231
-
"/xrpc/com.tranquil.account.reauthTotp",
232
-
post(api::server::reauth_totp),
233
-
)
230
+
.route("/xrpc/_account.reauthTotp", post(api::server::reauth_totp))
234
231
.route(
235
-
"/xrpc/com.tranquil.account.reauthPasskeyStart",
232
+
"/xrpc/_account.reauthPasskeyStart",
236
233
post(api::server::reauth_passkey_start),
237
234
)
238
235
.route(
239
-
"/xrpc/com.tranquil.account.reauthPasskeyFinish",
236
+
"/xrpc/_account.reauthPasskeyFinish",
240
237
post(api::server::reauth_passkey_finish),
241
238
)
242
239
.route(
243
-
"/xrpc/com.tranquil.account.getLegacyLoginPreference",
240
+
"/xrpc/_account.getLegacyLoginPreference",
244
241
get(api::server::get_legacy_login_preference),
245
242
)
246
243
.route(
247
-
"/xrpc/com.tranquil.account.updateLegacyLoginPreference",
244
+
"/xrpc/_account.updateLegacyLoginPreference",
248
245
post(api::server::update_legacy_login_preference),
249
246
)
250
247
.route(
251
-
"/xrpc/com.tranquil.account.updateLocale",
248
+
"/xrpc/_account.updateLocale",
252
249
post(api::server::update_locale),
253
250
)
254
251
.route(
255
-
"/xrpc/com.tranquil.account.listTrustedDevices",
252
+
"/xrpc/_account.listTrustedDevices",
256
253
get(api::server::list_trusted_devices),
257
254
)
258
255
.route(
259
-
"/xrpc/com.tranquil.account.revokeTrustedDevice",
256
+
"/xrpc/_account.revokeTrustedDevice",
260
257
post(api::server::revoke_trusted_device),
261
258
)
262
259
.route(
263
-
"/xrpc/com.tranquil.account.updateTrustedDevice",
260
+
"/xrpc/_account.updateTrustedDevice",
264
261
post(api::server::update_trusted_device),
265
262
)
266
263
.route(
267
-
"/xrpc/com.tranquil.account.createPasskeyAccount",
264
+
"/xrpc/_account.createPasskeyAccount",
268
265
post(api::server::create_passkey_account),
269
266
)
270
267
.route(
271
-
"/xrpc/com.tranquil.account.startPasskeyRegistrationForSetup",
268
+
"/xrpc/_account.startPasskeyRegistrationForSetup",
272
269
post(api::server::start_passkey_registration_for_setup),
273
270
)
274
271
.route(
275
-
"/xrpc/com.tranquil.account.completePasskeySetup",
272
+
"/xrpc/_account.completePasskeySetup",
276
273
post(api::server::complete_passkey_setup),
277
274
)
278
275
.route(
279
-
"/xrpc/com.tranquil.account.requestPasskeyRecovery",
276
+
"/xrpc/_account.requestPasskeyRecovery",
280
277
post(api::server::request_passkey_recovery),
281
278
)
282
279
.route(
283
-
"/xrpc/com.tranquil.account.recoverPasskeyAccount",
280
+
"/xrpc/_account.recoverPasskeyAccount",
284
281
post(api::server::recover_passkey_account),
285
282
)
286
283
.route(
287
-
"/xrpc/com.tranquil.account.getMigrationStatus",
288
-
get(api::server::get_migration_status),
289
-
)
290
-
.route(
291
-
"/xrpc/com.tranquil.account.updateMigrationForwarding",
292
-
post(api::server::update_migration_forwarding),
293
-
)
294
-
.route(
295
-
"/xrpc/com.tranquil.account.clearMigrationForwarding",
296
-
post(api::server::clear_migration_forwarding),
297
-
)
298
-
.route(
299
-
"/xrpc/com.tranquil.account.updateDidDocument",
284
+
"/xrpc/_account.updateDidDocument",
300
285
post(api::server::update_did_document),
301
286
)
302
287
.route(
303
-
"/xrpc/com.tranquil.account.getDidDocument",
288
+
"/xrpc/_account.getDidDocument",
304
289
get(api::server::get_did_document),
305
290
)
306
291
.route(
307
292
"/xrpc/com.atproto.server.requestEmailUpdate",
308
293
post(api::server::request_email_update),
294
+
)
295
+
.route(
296
+
"/xrpc/_checkEmailVerified",
297
+
post(api::server::check_email_verified),
309
298
)
310
299
.route(
311
300
"/xrpc/com.atproto.server.confirmEmail",
···
432
421
get(api::admin::get_invite_codes),
433
422
)
434
423
.route(
435
-
"/xrpc/com.tranquil.admin.getServerStats",
424
+
"/xrpc/_admin.getServerStats",
436
425
get(api::admin::get_server_stats),
437
426
)
438
427
.route(
439
-
"/xrpc/com.tranquil.server.getConfig",
428
+
"/xrpc/_server.getConfig",
440
429
get(api::admin::get_server_config),
441
430
)
442
431
.route(
443
-
"/xrpc/com.tranquil.admin.updateServerConfig",
432
+
"/xrpc/_admin.updateServerConfig",
444
433
post(api::admin::update_server_config),
445
434
)
446
435
.route(
···
575
564
post(api::temp::dereference_scope),
576
565
)
577
566
.route(
578
-
"/xrpc/com.tranquil.account.getNotificationPrefs",
567
+
"/xrpc/_account.getNotificationPrefs",
579
568
get(api::notification_prefs::get_notification_prefs),
580
569
)
581
570
.route(
582
-
"/xrpc/com.tranquil.account.updateNotificationPrefs",
571
+
"/xrpc/_account.updateNotificationPrefs",
583
572
post(api::notification_prefs::update_notification_prefs),
584
573
)
585
574
.route(
586
-
"/xrpc/com.tranquil.account.getNotificationHistory",
575
+
"/xrpc/_account.getNotificationHistory",
587
576
get(api::notification_prefs::get_notification_history),
588
577
)
589
578
.route(
590
-
"/xrpc/com.tranquil.account.confirmChannelVerification",
579
+
"/xrpc/_account.confirmChannelVerification",
591
580
post(api::verification::confirm_channel_verification),
592
581
)
593
582
.route(
594
-
"/xrpc/com.tranquil.account.verifyToken",
583
+
"/xrpc/_account.verifyToken",
595
584
post(api::server::verify_token),
596
585
)
597
586
.route(
598
-
"/xrpc/com.tranquil.delegation.listControllers",
587
+
"/xrpc/_delegation.listControllers",
599
588
get(api::delegation::list_controllers),
600
589
)
601
590
.route(
602
-
"/xrpc/com.tranquil.delegation.addController",
591
+
"/xrpc/_delegation.addController",
603
592
post(api::delegation::add_controller),
604
593
)
605
594
.route(
606
-
"/xrpc/com.tranquil.delegation.removeController",
595
+
"/xrpc/_delegation.removeController",
607
596
post(api::delegation::remove_controller),
608
597
)
609
598
.route(
610
-
"/xrpc/com.tranquil.delegation.updateControllerScopes",
599
+
"/xrpc/_delegation.updateControllerScopes",
611
600
post(api::delegation::update_controller_scopes),
612
601
)
613
602
.route(
614
-
"/xrpc/com.tranquil.delegation.listControlledAccounts",
603
+
"/xrpc/_delegation.listControlledAccounts",
615
604
get(api::delegation::list_controlled_accounts),
616
605
)
617
606
.route(
618
-
"/xrpc/com.tranquil.delegation.getAuditLog",
607
+
"/xrpc/_delegation.getAuditLog",
619
608
get(api::delegation::get_audit_log),
620
609
)
621
610
.route(
622
-
"/xrpc/com.tranquil.delegation.getScopePresets",
611
+
"/xrpc/_delegation.getScopePresets",
623
612
get(api::delegation::get_scope_presets),
624
613
)
625
614
.route(
626
-
"/xrpc/com.tranquil.delegation.createDelegatedAccount",
615
+
"/xrpc/_delegation.createDelegatedAccount",
627
616
post(api::delegation::create_delegated_account),
628
617
)
618
+
.route("/xrpc/_backup.listBackups", get(api::backup::list_backups))
619
+
.route("/xrpc/_backup.getBackup", get(api::backup::get_backup))
620
+
.route(
621
+
"/xrpc/_backup.createBackup",
622
+
post(api::backup::create_backup),
623
+
)
624
+
.route(
625
+
"/xrpc/_backup.deleteBackup",
626
+
post(api::backup::delete_backup),
627
+
)
628
+
.route(
629
+
"/xrpc/_backup.setEnabled",
630
+
post(api::backup::set_backup_enabled),
631
+
)
632
+
.route("/xrpc/_backup.exportBlobs", get(api::backup::export_blobs))
629
633
.route(
630
634
"/xrpc/app.bsky.ageassurance.getState",
631
635
get(api::age_assurance::get_state),
+18
-1
src/main.rs
+18
-1
src/main.rs
···
7
7
use tranquil_pds::crawlers::{Crawlers, start_crawlers_service};
8
8
use tranquil_pds::scheduled::{
9
9
backfill_genesis_commit_blocks, backfill_record_blobs, backfill_repo_rev, backfill_user_blocks,
10
-
start_scheduled_tasks,
10
+
start_backup_tasks, start_scheduled_tasks,
11
11
};
12
12
use tranquil_pds::state::AppState;
13
13
···
83
83
None
84
84
};
85
85
86
+
let backup_handle = if let Some(backup_storage) = state.backup_storage.clone() {
87
+
info!("Backup service enabled");
88
+
Some(tokio::spawn(start_backup_tasks(
89
+
state.db.clone(),
90
+
state.block_store.clone(),
91
+
backup_storage,
92
+
shutdown_rx.clone(),
93
+
)))
94
+
} else {
95
+
warn!("Backup service disabled (BACKUP_S3_BUCKET not set or BACKUP_ENABLED=false)");
96
+
None
97
+
};
98
+
86
99
let scheduled_handle = tokio::spawn(start_scheduled_tasks(
87
100
state.db.clone(),
88
101
state.blob_store.clone(),
···
114
127
comms_handle.await.ok();
115
128
116
129
if let Some(handle) = crawlers_handle {
130
+
handle.await.ok();
131
+
}
132
+
133
+
if let Some(handle) = backup_handle {
117
134
handle.await.ok();
118
135
}
119
136
+4
src/rate_limit.rs
+4
src/rate_limit.rs
···
32
32
pub totp_verify: Arc<KeyedRateLimiter>,
33
33
pub handle_update: Arc<KeyedRateLimiter>,
34
34
pub handle_update_daily: Arc<KeyedRateLimiter>,
35
+
pub verification_check: Arc<KeyedRateLimiter>,
35
36
}
36
37
37
38
impl Default for RateLimiters {
···
91
92
.unwrap()
92
93
.allow_burst(NonZeroU32::new(50).unwrap()),
93
94
)),
95
+
verification_check: Arc::new(RateLimiter::keyed(Quota::per_minute(
96
+
NonZeroU32::new(60).unwrap(),
97
+
))),
94
98
}
95
99
}
96
100
+311
-1
src/scheduled.rs
+311
-1
src/scheduled.rs
···
11
11
use tracing::{debug, error, info, warn};
12
12
13
13
use crate::repo::PostgresBlockStore;
14
-
use crate::storage::BlobStorage;
14
+
use crate::storage::{BackupStorage, BlobStorage};
15
+
use crate::sync::car::encode_car_header;
15
16
16
17
pub async fn backfill_genesis_commit_blocks(db: &PgPool, block_store: PostgresBlockStore) {
17
18
let broken_genesis_commits = match sqlx::query!(
···
563
564
564
565
Ok(())
565
566
}
567
+
568
+
pub async fn start_backup_tasks(
569
+
db: PgPool,
570
+
block_store: PostgresBlockStore,
571
+
backup_storage: Arc<BackupStorage>,
572
+
mut shutdown_rx: watch::Receiver<bool>,
573
+
) {
574
+
let backup_interval = Duration::from_secs(BackupStorage::interval_secs());
575
+
576
+
info!(
577
+
interval_secs = backup_interval.as_secs(),
578
+
retention_count = BackupStorage::retention_count(),
579
+
"Starting backup service"
580
+
);
581
+
582
+
let mut ticker = interval(backup_interval);
583
+
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
584
+
585
+
loop {
586
+
tokio::select! {
587
+
_ = shutdown_rx.changed() => {
588
+
if *shutdown_rx.borrow() {
589
+
info!("Backup service shutting down");
590
+
break;
591
+
}
592
+
}
593
+
_ = ticker.tick() => {
594
+
if let Err(e) = process_scheduled_backups(&db, &block_store, &backup_storage).await {
595
+
error!("Error processing scheduled backups: {}", e);
596
+
}
597
+
}
598
+
}
599
+
}
600
+
}
601
+
602
+
async fn process_scheduled_backups(
603
+
db: &PgPool,
604
+
block_store: &PostgresBlockStore,
605
+
backup_storage: &BackupStorage,
606
+
) -> Result<(), String> {
607
+
let backup_interval_secs = BackupStorage::interval_secs() as i64;
608
+
let retention_count = BackupStorage::retention_count();
609
+
610
+
let users_needing_backup = sqlx::query!(
611
+
r#"
612
+
SELECT u.id as user_id, u.did, r.repo_root_cid, r.repo_rev
613
+
FROM users u
614
+
JOIN repos r ON r.user_id = u.id
615
+
WHERE u.backup_enabled = true
616
+
AND u.deactivated_at IS NULL
617
+
AND (
618
+
NOT EXISTS (
619
+
SELECT 1 FROM account_backups ab WHERE ab.user_id = u.id
620
+
)
621
+
OR (
622
+
SELECT MAX(ab.created_at) FROM account_backups ab WHERE ab.user_id = u.id
623
+
) < NOW() - make_interval(secs => $1)
624
+
)
625
+
LIMIT 50
626
+
"#,
627
+
backup_interval_secs as f64
628
+
)
629
+
.fetch_all(db)
630
+
.await
631
+
.map_err(|e| format!("DB error fetching users for backup: {}", e))?;
632
+
633
+
if users_needing_backup.is_empty() {
634
+
debug!("No accounts need backup");
635
+
return Ok(());
636
+
}
637
+
638
+
info!(
639
+
count = users_needing_backup.len(),
640
+
"Processing scheduled backups"
641
+
);
642
+
643
+
for user in users_needing_backup {
644
+
let repo_root_cid = user.repo_root_cid.clone();
645
+
646
+
let repo_rev = match &user.repo_rev {
647
+
Some(rev) => rev.clone(),
648
+
None => {
649
+
warn!(did = %user.did, "User has no repo_rev, skipping backup");
650
+
continue;
651
+
}
652
+
};
653
+
654
+
let head_cid = match Cid::from_str(&repo_root_cid) {
655
+
Ok(c) => c,
656
+
Err(e) => {
657
+
warn!(did = %user.did, error = %e, "Invalid repo_root_cid, skipping backup");
658
+
continue;
659
+
}
660
+
};
661
+
662
+
let car_result = generate_full_backup(block_store, &head_cid).await;
663
+
let car_bytes = match car_result {
664
+
Ok(bytes) => bytes,
665
+
Err(e) => {
666
+
warn!(did = %user.did, error = %e, "Failed to generate CAR for backup");
667
+
continue;
668
+
}
669
+
};
670
+
671
+
let block_count = count_car_blocks(&car_bytes);
672
+
let size_bytes = car_bytes.len() as i64;
673
+
674
+
let storage_key = match backup_storage
675
+
.put_backup(&user.did, &repo_rev, &car_bytes)
676
+
.await
677
+
{
678
+
Ok(key) => key,
679
+
Err(e) => {
680
+
warn!(did = %user.did, error = %e, "Failed to upload backup to storage");
681
+
continue;
682
+
}
683
+
};
684
+
685
+
if let Err(e) = sqlx::query!(
686
+
r#"
687
+
INSERT INTO account_backups (user_id, storage_key, repo_root_cid, repo_rev, block_count, size_bytes)
688
+
VALUES ($1, $2, $3, $4, $5, $6)
689
+
"#,
690
+
user.user_id,
691
+
storage_key,
692
+
repo_root_cid,
693
+
repo_rev,
694
+
block_count,
695
+
size_bytes
696
+
)
697
+
.execute(db)
698
+
.await
699
+
{
700
+
warn!(did = %user.did, error = %e, "Failed to insert backup record, rolling back S3 upload");
701
+
if let Err(rollback_err) = backup_storage.delete_backup(&storage_key).await {
702
+
error!(
703
+
did = %user.did,
704
+
storage_key = %storage_key,
705
+
error = %rollback_err,
706
+
"Failed to rollback orphaned backup from S3"
707
+
);
708
+
}
709
+
continue;
710
+
}
711
+
712
+
info!(
713
+
did = %user.did,
714
+
rev = %repo_rev,
715
+
size_bytes,
716
+
block_count,
717
+
"Created backup"
718
+
);
719
+
720
+
if let Err(e) = cleanup_old_backups(db, backup_storage, user.user_id, retention_count).await
721
+
{
722
+
warn!(did = %user.did, error = %e, "Failed to cleanup old backups");
723
+
}
724
+
}
725
+
726
+
Ok(())
727
+
}
728
+
729
+
pub async fn generate_repo_car(
730
+
block_store: &PostgresBlockStore,
731
+
head_cid: &Cid,
732
+
) -> Result<Vec<u8>, String> {
733
+
use jacquard_repo::storage::BlockStore;
734
+
use std::io::Write;
735
+
736
+
let mut car_bytes =
737
+
encode_car_header(head_cid).map_err(|e| format!("Failed to encode CAR header: {}", e))?;
738
+
739
+
let mut stack = vec![*head_cid];
740
+
let mut visited = std::collections::HashSet::new();
741
+
742
+
while let Some(cid) = stack.pop() {
743
+
if visited.contains(&cid) {
744
+
continue;
745
+
}
746
+
visited.insert(cid);
747
+
748
+
if let Ok(Some(block)) = block_store.get(&cid).await {
749
+
let cid_bytes = cid.to_bytes();
750
+
let total_len = cid_bytes.len() + block.len();
751
+
let mut writer = Vec::new();
752
+
crate::sync::car::write_varint(&mut writer, total_len as u64)
753
+
.expect("Writing to Vec<u8> should never fail");
754
+
writer
755
+
.write_all(&cid_bytes)
756
+
.expect("Writing to Vec<u8> should never fail");
757
+
writer
758
+
.write_all(&block)
759
+
.expect("Writing to Vec<u8> should never fail");
760
+
car_bytes.extend_from_slice(&writer);
761
+
762
+
if let Ok(value) = serde_ipld_dagcbor::from_slice::<Ipld>(&block) {
763
+
extract_links(&value, &mut stack);
764
+
}
765
+
}
766
+
}
767
+
768
+
Ok(car_bytes)
769
+
}
770
+
771
+
pub async fn generate_full_backup(
772
+
block_store: &PostgresBlockStore,
773
+
head_cid: &Cid,
774
+
) -> Result<Vec<u8>, String> {
775
+
generate_repo_car(block_store, head_cid).await
776
+
}
777
+
778
+
fn extract_links(value: &Ipld, stack: &mut Vec<Cid>) {
779
+
match value {
780
+
Ipld::Link(cid) => {
781
+
stack.push(*cid);
782
+
}
783
+
Ipld::Map(map) => {
784
+
for v in map.values() {
785
+
extract_links(v, stack);
786
+
}
787
+
}
788
+
Ipld::List(arr) => {
789
+
for v in arr {
790
+
extract_links(v, stack);
791
+
}
792
+
}
793
+
_ => {}
794
+
}
795
+
}
796
+
797
+
pub fn count_car_blocks(car_bytes: &[u8]) -> i32 {
798
+
let mut count = 0;
799
+
let mut pos = 0;
800
+
801
+
if let Some((header_len, header_varint_len)) = read_varint(&car_bytes[pos..]) {
802
+
pos += header_varint_len + header_len as usize;
803
+
} else {
804
+
return 0;
805
+
}
806
+
807
+
while pos < car_bytes.len() {
808
+
if let Some((block_len, varint_len)) = read_varint(&car_bytes[pos..]) {
809
+
pos += varint_len + block_len as usize;
810
+
count += 1;
811
+
} else {
812
+
break;
813
+
}
814
+
}
815
+
816
+
count
817
+
}
818
+
819
+
fn read_varint(data: &[u8]) -> Option<(u64, usize)> {
820
+
let mut value: u64 = 0;
821
+
let mut shift = 0;
822
+
let mut pos = 0;
823
+
824
+
while pos < data.len() && pos < 10 {
825
+
let byte = data[pos];
826
+
value |= ((byte & 0x7f) as u64) << shift;
827
+
pos += 1;
828
+
if byte & 0x80 == 0 {
829
+
return Some((value, pos));
830
+
}
831
+
shift += 7;
832
+
}
833
+
834
+
None
835
+
}
836
+
837
+
async fn cleanup_old_backups(
838
+
db: &PgPool,
839
+
backup_storage: &BackupStorage,
840
+
user_id: uuid::Uuid,
841
+
retention_count: u32,
842
+
) -> Result<(), String> {
843
+
let old_backups = sqlx::query!(
844
+
r#"
845
+
SELECT id, storage_key
846
+
FROM account_backups
847
+
WHERE user_id = $1
848
+
ORDER BY created_at DESC
849
+
OFFSET $2
850
+
"#,
851
+
user_id,
852
+
retention_count as i64
853
+
)
854
+
.fetch_all(db)
855
+
.await
856
+
.map_err(|e| format!("DB error fetching old backups: {}", e))?;
857
+
858
+
for backup in old_backups {
859
+
if let Err(e) = backup_storage.delete_backup(&backup.storage_key).await {
860
+
warn!(
861
+
storage_key = %backup.storage_key,
862
+
error = %e,
863
+
"Failed to delete old backup from storage, skipping DB cleanup to avoid orphan"
864
+
);
865
+
continue;
866
+
}
867
+
868
+
sqlx::query!("DELETE FROM account_backups WHERE id = $1", backup.id)
869
+
.execute(db)
870
+
.await
871
+
.map_err(|e| format!("Failed to delete old backup record: {}", e))?;
872
+
}
873
+
874
+
Ok(())
875
+
}
+8
-1
src/state.rs
+8
-1
src/state.rs
···
4
4
use crate::config::AuthConfig;
5
5
use crate::rate_limit::RateLimiters;
6
6
use crate::repo::PostgresBlockStore;
7
-
use crate::storage::{BlobStorage, S3BlobStorage};
7
+
use crate::storage::{BackupStorage, BlobStorage, S3BlobStorage};
8
8
use crate::sync::firehose::SequencedEvent;
9
9
use sqlx::PgPool;
10
10
use std::error::Error;
···
16
16
pub db: PgPool,
17
17
pub block_store: PostgresBlockStore,
18
18
pub blob_store: Arc<dyn BlobStorage>,
19
+
pub backup_storage: Option<Arc<BackupStorage>>,
19
20
pub firehose_tx: broadcast::Sender<SequencedEvent>,
20
21
pub rate_limiters: Arc<RateLimiters>,
21
22
pub circuit_breakers: Arc<CircuitBreakers>,
···
39
40
TotpVerify,
40
41
HandleUpdate,
41
42
HandleUpdateDaily,
43
+
VerificationCheck,
42
44
}
43
45
44
46
impl RateLimitKind {
···
58
60
Self::TotpVerify => "totp_verify",
59
61
Self::HandleUpdate => "handle_update",
60
62
Self::HandleUpdateDaily => "handle_update_daily",
63
+
Self::VerificationCheck => "verification_check",
61
64
}
62
65
}
63
66
···
77
80
Self::TotpVerify => (5, 300_000),
78
81
Self::HandleUpdate => (10, 300_000),
79
82
Self::HandleUpdateDaily => (50, 86_400_000),
83
+
Self::VerificationCheck => (60, 60_000),
80
84
}
81
85
}
82
86
}
···
131
135
132
136
let block_store = PostgresBlockStore::new(db.clone());
133
137
let blob_store = S3BlobStorage::new().await;
138
+
let backup_storage = BackupStorage::new().await.map(Arc::new);
134
139
135
140
let firehose_buffer_size: usize = std::env::var("FIREHOSE_BUFFER_SIZE")
136
141
.ok()
···
147
152
db,
148
153
block_store,
149
154
blob_store: Arc::new(blob_store),
155
+
backup_storage,
150
156
firehose_tx,
151
157
rate_limiters,
152
158
circuit_breakers,
···
199
205
RateLimitKind::TotpVerify => &self.rate_limiters.totp_verify,
200
206
RateLimitKind::HandleUpdate => &self.rate_limiters.handle_update,
201
207
RateLimitKind::HandleUpdateDaily => &self.rate_limiters.handle_update_daily,
208
+
RateLimitKind::VerificationCheck => &self.rate_limiters.verification_check,
202
209
};
203
210
204
211
let ok = limiter.check_key(&client_ip.to_string()).is_ok();
+119
-16
src/storage/mod.rs
+119
-16
src/storage/mod.rs
···
32
32
33
33
impl S3BlobStorage {
34
34
pub async fn new() -> Self {
35
-
let region_provider = RegionProviderChain::default_provider().or_else("us-east-1");
35
+
let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set");
36
+
let client = create_s3_client().await;
37
+
Self { client, bucket }
38
+
}
39
+
}
40
+
41
+
async fn create_s3_client() -> Client {
42
+
let region_provider = RegionProviderChain::default_provider().or_else("us-east-1");
43
+
44
+
let config = aws_config::defaults(BehaviorVersion::latest())
45
+
.region(region_provider)
46
+
.load()
47
+
.await;
48
+
49
+
if let Ok(endpoint) = std::env::var("S3_ENDPOINT") {
50
+
let s3_config = aws_sdk_s3::config::Builder::from(&config)
51
+
.endpoint_url(endpoint)
52
+
.force_path_style(true)
53
+
.build();
54
+
Client::from_conf(s3_config)
55
+
} else {
56
+
Client::new(&config)
57
+
}
58
+
}
59
+
60
+
pub struct BackupStorage {
61
+
client: Client,
62
+
bucket: String,
63
+
}
64
+
65
+
impl BackupStorage {
66
+
pub async fn new() -> Option<Self> {
67
+
let backup_enabled = std::env::var("BACKUP_ENABLED")
68
+
.map(|v| v != "false" && v != "0")
69
+
.unwrap_or(true);
70
+
71
+
if !backup_enabled {
72
+
return None;
73
+
}
74
+
75
+
let bucket = std::env::var("BACKUP_S3_BUCKET").ok()?;
76
+
let client = create_s3_client().await;
77
+
Some(Self { client, bucket })
78
+
}
79
+
80
+
pub fn retention_count() -> u32 {
81
+
std::env::var("BACKUP_RETENTION_COUNT")
82
+
.ok()
83
+
.and_then(|v| v.parse().ok())
84
+
.unwrap_or(7)
85
+
}
86
+
87
+
pub fn interval_secs() -> u64 {
88
+
std::env::var("BACKUP_INTERVAL_SECS")
89
+
.ok()
90
+
.and_then(|v| v.parse().ok())
91
+
.unwrap_or(86400)
92
+
}
93
+
94
+
pub async fn put_backup(
95
+
&self,
96
+
did: &str,
97
+
rev: &str,
98
+
data: &[u8],
99
+
) -> Result<String, StorageError> {
100
+
let key = format!("{}/{}.car", did, rev);
101
+
self.client
102
+
.put_object()
103
+
.bucket(&self.bucket)
104
+
.key(&key)
105
+
.body(ByteStream::from(Bytes::copy_from_slice(data)))
106
+
.send()
107
+
.await
108
+
.map_err(|e| {
109
+
crate::metrics::record_s3_operation("backup_put", "error");
110
+
StorageError::S3(e.to_string())
111
+
})?;
36
112
37
-
let config = aws_config::defaults(BehaviorVersion::latest())
38
-
.region(region_provider)
39
-
.load()
40
-
.await;
113
+
crate::metrics::record_s3_operation("backup_put", "success");
114
+
Ok(key)
115
+
}
116
+
117
+
pub async fn get_backup(&self, storage_key: &str) -> Result<Bytes, StorageError> {
118
+
let resp = self
119
+
.client
120
+
.get_object()
121
+
.bucket(&self.bucket)
122
+
.key(storage_key)
123
+
.send()
124
+
.await
125
+
.map_err(|e| {
126
+
crate::metrics::record_s3_operation("backup_get", "error");
127
+
StorageError::S3(e.to_string())
128
+
})?;
129
+
130
+
let data = resp
131
+
.body
132
+
.collect()
133
+
.await
134
+
.map_err(|e| {
135
+
crate::metrics::record_s3_operation("backup_get", "error");
136
+
StorageError::S3(e.to_string())
137
+
})?
138
+
.into_bytes();
41
139
42
-
let bucket = std::env::var("S3_BUCKET").expect("S3_BUCKET must be set");
140
+
crate::metrics::record_s3_operation("backup_get", "success");
141
+
Ok(data)
142
+
}
43
143
44
-
let client = if let Ok(endpoint) = std::env::var("S3_ENDPOINT") {
45
-
let s3_config = aws_sdk_s3::config::Builder::from(&config)
46
-
.endpoint_url(endpoint)
47
-
.force_path_style(true)
48
-
.build();
49
-
Client::from_conf(s3_config)
50
-
} else {
51
-
Client::new(&config)
52
-
};
144
+
pub async fn delete_backup(&self, storage_key: &str) -> Result<(), StorageError> {
145
+
self.client
146
+
.delete_object()
147
+
.bucket(&self.bucket)
148
+
.key(storage_key)
149
+
.send()
150
+
.await
151
+
.map_err(|e| {
152
+
crate::metrics::record_s3_operation("backup_delete", "error");
153
+
StorageError::S3(e.to_string())
154
+
})?;
53
155
54
-
Self { client, bucket }
156
+
crate::metrics::record_s3_operation("backup_delete", "success");
157
+
Ok(())
55
158
}
56
159
}
57
160
+23
-12
src/sync/import.rs
+23
-12
src/sync/import.rs
···
77
77
Ipld::Map(obj) => {
78
78
if let Some(Ipld::String(type_str)) = obj.get("$type")
79
79
&& type_str == "blob"
80
-
&& let Some(Ipld::Link(link_cid)) = obj.get("ref")
81
80
{
82
-
let mime = obj.get("mimeType").and_then(|v| {
83
-
if let Ipld::String(s) = v {
84
-
Some(s.clone())
85
-
} else {
86
-
None
87
-
}
88
-
});
89
-
return vec![BlobRef {
90
-
cid: link_cid.to_string(),
91
-
mime_type: mime,
92
-
}];
81
+
let cid_str = if let Some(Ipld::Link(link_cid)) = obj.get("ref") {
82
+
Some(link_cid.to_string())
83
+
} else if let Some(Ipld::Map(ref_obj)) = obj.get("ref")
84
+
&& let Some(Ipld::String(link)) = ref_obj.get("$link")
85
+
{
86
+
Some(link.clone())
87
+
} else {
88
+
None
89
+
};
90
+
91
+
if let Some(cid) = cid_str {
92
+
let mime = obj.get("mimeType").and_then(|v| {
93
+
if let Ipld::String(s) = v {
94
+
Some(s.clone())
95
+
} else {
96
+
None
97
+
}
98
+
});
99
+
return vec![BlobRef {
100
+
cid,
101
+
mime_type: mime,
102
+
}];
103
+
}
93
104
}
94
105
obj.values()
95
106
.flat_map(|v| find_blob_refs_ipld(v, depth + 1))
+129
src/util.rs
+129
src/util.rs
···
1
1
use axum::http::HeaderMap;
2
+
use cid::Cid;
3
+
use ipld_core::ipld::Ipld;
2
4
use rand::Rng;
5
+
use serde_json::Value as JsonValue;
3
6
use sqlx::PgPool;
7
+
use std::collections::BTreeMap;
8
+
use std::str::FromStr;
4
9
use std::sync::OnceLock;
5
10
use uuid::Uuid;
6
11
···
150
155
format!("{}{}", pds_public_url(), path)
151
156
}
152
157
158
+
pub fn json_to_ipld(value: &JsonValue) -> Ipld {
159
+
match value {
160
+
JsonValue::Null => Ipld::Null,
161
+
JsonValue::Bool(b) => Ipld::Bool(*b),
162
+
JsonValue::Number(n) => {
163
+
if let Some(i) = n.as_i64() {
164
+
Ipld::Integer(i as i128)
165
+
} else if let Some(f) = n.as_f64() {
166
+
Ipld::Float(f)
167
+
} else {
168
+
Ipld::Null
169
+
}
170
+
}
171
+
JsonValue::String(s) => Ipld::String(s.clone()),
172
+
JsonValue::Array(arr) => Ipld::List(arr.iter().map(json_to_ipld).collect()),
173
+
JsonValue::Object(obj) => {
174
+
if let Some(JsonValue::String(link)) = obj.get("$link")
175
+
&& obj.len() == 1
176
+
&& let Ok(cid) = Cid::from_str(link)
177
+
{
178
+
return Ipld::Link(cid);
179
+
}
180
+
let map: BTreeMap<String, Ipld> = obj
181
+
.iter()
182
+
.map(|(k, v)| (k.clone(), json_to_ipld(v)))
183
+
.collect();
184
+
Ipld::Map(map)
185
+
}
186
+
}
187
+
}
188
+
153
189
#[cfg(test)]
154
190
mod tests {
155
191
use super::*;
···
223
259
for part in parts {
224
260
assert_eq!(part.len(), 4);
225
261
}
262
+
}
263
+
264
+
#[test]
265
+
fn test_json_to_ipld_cid_link() {
266
+
let json = serde_json::json!({
267
+
"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
268
+
});
269
+
let ipld = json_to_ipld(&json);
270
+
match ipld {
271
+
Ipld::Link(cid) => {
272
+
assert_eq!(
273
+
cid.to_string(),
274
+
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
275
+
);
276
+
}
277
+
_ => panic!("Expected Ipld::Link, got {:?}", ipld),
278
+
}
279
+
}
280
+
281
+
#[test]
282
+
fn test_json_to_ipld_blob_ref() {
283
+
let json = serde_json::json!({
284
+
"$type": "blob",
285
+
"ref": {
286
+
"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
287
+
},
288
+
"mimeType": "image/jpeg",
289
+
"size": 12345
290
+
});
291
+
let ipld = json_to_ipld(&json);
292
+
match ipld {
293
+
Ipld::Map(map) => {
294
+
assert_eq!(map.get("$type"), Some(&Ipld::String("blob".to_string())));
295
+
assert_eq!(
296
+
map.get("mimeType"),
297
+
Some(&Ipld::String("image/jpeg".to_string()))
298
+
);
299
+
assert_eq!(map.get("size"), Some(&Ipld::Integer(12345)));
300
+
match map.get("ref") {
301
+
Some(Ipld::Link(cid)) => {
302
+
assert_eq!(
303
+
cid.to_string(),
304
+
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
305
+
);
306
+
}
307
+
_ => panic!("Expected Ipld::Link in ref field, got {:?}", map.get("ref")),
308
+
}
309
+
}
310
+
_ => panic!("Expected Ipld::Map, got {:?}", ipld),
311
+
}
312
+
}
313
+
314
+
#[test]
315
+
fn test_json_to_ipld_nested_blob_refs_serializes_correctly() {
316
+
let record = serde_json::json!({
317
+
"$type": "app.bsky.feed.post",
318
+
"text": "Hello world",
319
+
"embed": {
320
+
"$type": "app.bsky.embed.images",
321
+
"images": [
322
+
{
323
+
"alt": "Test image",
324
+
"image": {
325
+
"$type": "blob",
326
+
"ref": {
327
+
"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
328
+
},
329
+
"mimeType": "image/jpeg",
330
+
"size": 12345
331
+
}
332
+
}
333
+
]
334
+
}
335
+
});
336
+
let ipld = json_to_ipld(&record);
337
+
let cbor_bytes = serde_ipld_dagcbor::to_vec(&ipld).expect("CBOR serialization failed");
338
+
assert!(!cbor_bytes.is_empty());
339
+
let parsed: Ipld =
340
+
serde_ipld_dagcbor::from_slice(&cbor_bytes).expect("CBOR deserialization failed");
341
+
if let Ipld::Map(map) = &parsed
342
+
&& let Some(Ipld::Map(embed)) = map.get("embed")
343
+
&& let Some(Ipld::List(images)) = embed.get("images")
344
+
&& let Some(Ipld::Map(img)) = images.first()
345
+
&& let Some(Ipld::Map(blob)) = img.get("image")
346
+
&& let Some(Ipld::Link(cid)) = blob.get("ref")
347
+
{
348
+
assert_eq!(
349
+
cid.to_string(),
350
+
"bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"
351
+
);
352
+
return;
353
+
}
354
+
panic!("Failed to find CID link in parsed CBOR");
226
355
}
227
356
}
+10
-40
tests/account_notifications.rs
+10
-40
tests/account_notifications.rs
···
27
27
}
28
28
29
29
let resp = client
30
-
.get(format!(
31
-
"{}/xrpc/com.tranquil.account.getNotificationHistory",
32
-
base
33
-
))
30
+
.get(format!("{}/xrpc/_account.getNotificationHistory", base))
34
31
.header("Authorization", format!("Bearer {}", token))
35
32
.send()
36
33
.await
···
56
53
"discordId": "123456789"
57
54
});
58
55
let resp = client
59
-
.post(format!(
60
-
"{}/xrpc/com.tranquil.account.updateNotificationPrefs",
61
-
base
62
-
))
56
+
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
63
57
.header("Authorization", format!("Bearer {}", token))
64
58
.json(&prefs)
65
59
.send()
···
101
95
"code": code
102
96
});
103
97
let resp = client
104
-
.post(format!(
105
-
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
106
-
base
107
-
))
98
+
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
108
99
.header("Authorization", format!("Bearer {}", token))
109
100
.json(&input)
110
101
.send()
···
113
104
assert_eq!(resp.status(), 200);
114
105
115
106
let resp = client
116
-
.get(format!(
117
-
"{}/xrpc/com.tranquil.account.getNotificationPrefs",
118
-
base
119
-
))
107
+
.get(format!("{}/xrpc/_account.getNotificationPrefs", base))
120
108
.header("Authorization", format!("Bearer {}", token))
121
109
.send()
122
110
.await
···
136
124
"telegramUsername": "testuser"
137
125
});
138
126
let resp = client
139
-
.post(format!(
140
-
"{}/xrpc/com.tranquil.account.updateNotificationPrefs",
141
-
base
142
-
))
127
+
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
143
128
.header("Authorization", format!("Bearer {}", token))
144
129
.json(&prefs)
145
130
.send()
···
153
138
"code": "XXXX-XXXX-XXXX-XXXX"
154
139
});
155
140
let resp = client
156
-
.post(format!(
157
-
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
158
-
base
159
-
))
141
+
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
160
142
.header("Authorization", format!("Bearer {}", token))
161
143
.json(&input)
162
144
.send()
···
181
163
"code": "XXXX-XXXX-XXXX-XXXX"
182
164
});
183
165
let resp = client
184
-
.post(format!(
185
-
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
186
-
base
187
-
))
166
+
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
188
167
.header("Authorization", format!("Bearer {}", token))
189
168
.json(&input)
190
169
.send()
···
209
188
"email": unique_email
210
189
});
211
190
let resp = client
212
-
.post(format!(
213
-
"{}/xrpc/com.tranquil.account.updateNotificationPrefs",
214
-
base
215
-
))
191
+
.post(format!("{}/xrpc/_account.updateNotificationPrefs", base))
216
192
.header("Authorization", format!("Bearer {}", token))
217
193
.json(&prefs)
218
194
.send()
···
263
239
"code": code
264
240
});
265
241
let resp = client
266
-
.post(format!(
267
-
"{}/xrpc/com.tranquil.account.confirmChannelVerification",
268
-
base
269
-
))
242
+
.post(format!("{}/xrpc/_account.confirmChannelVerification", base))
270
243
.header("Authorization", format!("Bearer {}", token))
271
244
.json(&input)
272
245
.send()
···
275
248
assert_eq!(resp.status(), 200);
276
249
277
250
let resp = client
278
-
.get(format!(
279
-
"{}/xrpc/com.tranquil.account.getNotificationPrefs",
280
-
base
281
-
))
251
+
.get(format!("{}/xrpc/_account.getNotificationPrefs", base))
282
252
.header("Authorization", format!("Bearer {}", token))
283
253
.send()
284
254
.await
+2
-2
tests/admin_stats.rs
+2
-2
tests/admin_stats.rs
···
11
11
let (_, _) = create_admin_account_and_login(&client).await;
12
12
13
13
let resp = client
14
-
.get(format!("{}/xrpc/com.tranquil.admin.getServerStats", base))
14
+
.get(format!("{}/xrpc/_admin.getServerStats", base))
15
15
.header("Authorization", format!("Bearer {}", token1))
16
16
.send()
17
17
.await
···
33
33
let client = client();
34
34
let base = base_url().await;
35
35
let resp = client
36
-
.get(format!("{}/xrpc/com.tranquil.admin.getServerStats", base))
36
+
.get(format!("{}/xrpc/_admin.getServerStats", base))
37
37
.send()
38
38
.await
39
39
.unwrap();
+325
tests/backup.rs
+325
tests/backup.rs
···
1
+
mod common;
2
+
mod helpers;
3
+
4
+
use common::*;
5
+
use reqwest::{StatusCode, header};
6
+
use serde_json::{Value, json};
7
+
8
+
#[tokio::test]
9
+
async fn test_list_backups_empty() {
10
+
let client = client();
11
+
let (token, _did) = create_account_and_login(&client).await;
12
+
13
+
let res = client
14
+
.get(format!("{}/xrpc/_backup.listBackups", base_url().await))
15
+
.bearer_auth(&token)
16
+
.send()
17
+
.await
18
+
.expect("listBackups request failed");
19
+
20
+
assert_eq!(res.status(), StatusCode::OK);
21
+
let body: Value = res.json().await.expect("Invalid JSON");
22
+
assert!(body["backups"].is_array());
23
+
assert_eq!(body["backups"].as_array().unwrap().len(), 0);
24
+
assert!(body["backupEnabled"].as_bool().unwrap_or(false));
25
+
}
26
+
27
+
#[tokio::test]
28
+
async fn test_create_and_list_backup() {
29
+
let client = client();
30
+
let (token, _did) = create_account_and_login(&client).await;
31
+
32
+
let create_res = client
33
+
.post(format!("{}/xrpc/_backup.createBackup", base_url().await))
34
+
.bearer_auth(&token)
35
+
.send()
36
+
.await
37
+
.expect("createBackup request failed");
38
+
39
+
assert_eq!(create_res.status(), StatusCode::OK, "createBackup failed");
40
+
let create_body: Value = create_res.json().await.expect("Invalid JSON");
41
+
assert!(create_body["id"].is_string());
42
+
assert!(create_body["repoRev"].is_string());
43
+
assert!(create_body["sizeBytes"].is_i64());
44
+
assert!(create_body["blockCount"].is_i64());
45
+
46
+
let list_res = client
47
+
.get(format!("{}/xrpc/_backup.listBackups", base_url().await))
48
+
.bearer_auth(&token)
49
+
.send()
50
+
.await
51
+
.expect("listBackups request failed");
52
+
53
+
assert_eq!(list_res.status(), StatusCode::OK);
54
+
let list_body: Value = list_res.json().await.expect("Invalid JSON");
55
+
let backups = list_body["backups"].as_array().unwrap();
56
+
assert!(backups.len() >= 1);
57
+
}
58
+
59
+
#[tokio::test]
60
+
async fn test_download_backup() {
61
+
let client = client();
62
+
let (token, _did) = create_account_and_login(&client).await;
63
+
64
+
let create_res = client
65
+
.post(format!("{}/xrpc/_backup.createBackup", base_url().await))
66
+
.bearer_auth(&token)
67
+
.send()
68
+
.await
69
+
.expect("createBackup request failed");
70
+
71
+
assert_eq!(create_res.status(), StatusCode::OK);
72
+
let create_body: Value = create_res.json().await.expect("Invalid JSON");
73
+
let backup_id = create_body["id"].as_str().unwrap();
74
+
75
+
let get_res = client
76
+
.get(format!(
77
+
"{}/xrpc/_backup.getBackup?id={}",
78
+
base_url().await,
79
+
backup_id
80
+
))
81
+
.bearer_auth(&token)
82
+
.send()
83
+
.await
84
+
.expect("getBackup request failed");
85
+
86
+
assert_eq!(get_res.status(), StatusCode::OK);
87
+
let content_type = get_res.headers().get(header::CONTENT_TYPE).unwrap();
88
+
assert_eq!(content_type, "application/vnd.ipld.car");
89
+
90
+
let bytes = get_res.bytes().await.expect("Failed to read body");
91
+
assert!(bytes.len() > 100, "CAR file should have content");
92
+
assert_eq!(
93
+
bytes[1], 0xa2,
94
+
"CAR file should have valid header structure"
95
+
);
96
+
}
97
+
98
+
#[tokio::test]
99
+
async fn test_delete_backup() {
100
+
let client = client();
101
+
let (token, _did) = create_account_and_login(&client).await;
102
+
103
+
let create_res = client
104
+
.post(format!("{}/xrpc/_backup.createBackup", base_url().await))
105
+
.bearer_auth(&token)
106
+
.send()
107
+
.await
108
+
.expect("createBackup request failed");
109
+
110
+
assert_eq!(create_res.status(), StatusCode::OK);
111
+
let create_body: Value = create_res.json().await.expect("Invalid JSON");
112
+
let backup_id = create_body["id"].as_str().unwrap();
113
+
114
+
let delete_res = client
115
+
.post(format!(
116
+
"{}/xrpc/_backup.deleteBackup?id={}",
117
+
base_url().await,
118
+
backup_id
119
+
))
120
+
.bearer_auth(&token)
121
+
.send()
122
+
.await
123
+
.expect("deleteBackup request failed");
124
+
125
+
assert_eq!(delete_res.status(), StatusCode::OK);
126
+
127
+
let get_res = client
128
+
.get(format!(
129
+
"{}/xrpc/_backup.getBackup?id={}",
130
+
base_url().await,
131
+
backup_id
132
+
))
133
+
.bearer_auth(&token)
134
+
.send()
135
+
.await
136
+
.expect("getBackup request failed");
137
+
138
+
assert_eq!(get_res.status(), StatusCode::NOT_FOUND);
139
+
}
140
+
141
+
#[tokio::test]
142
+
async fn test_toggle_backup_enabled() {
143
+
let client = client();
144
+
let (token, _did) = create_account_and_login(&client).await;
145
+
146
+
let list_res = client
147
+
.get(format!("{}/xrpc/_backup.listBackups", base_url().await))
148
+
.bearer_auth(&token)
149
+
.send()
150
+
.await
151
+
.expect("listBackups request failed");
152
+
153
+
assert_eq!(list_res.status(), StatusCode::OK);
154
+
let list_body: Value = list_res.json().await.expect("Invalid JSON");
155
+
assert!(list_body["backupEnabled"].as_bool().unwrap());
156
+
157
+
let disable_res = client
158
+
.post(format!("{}/xrpc/_backup.setEnabled", base_url().await))
159
+
.bearer_auth(&token)
160
+
.json(&json!({"enabled": false}))
161
+
.send()
162
+
.await
163
+
.expect("setEnabled request failed");
164
+
165
+
assert_eq!(disable_res.status(), StatusCode::OK);
166
+
let disable_body: Value = disable_res.json().await.expect("Invalid JSON");
167
+
assert!(!disable_body["enabled"].as_bool().unwrap());
168
+
169
+
let list_res2 = client
170
+
.get(format!("{}/xrpc/_backup.listBackups", base_url().await))
171
+
.bearer_auth(&token)
172
+
.send()
173
+
.await
174
+
.expect("listBackups request failed");
175
+
176
+
let list_body2: Value = list_res2.json().await.expect("Invalid JSON");
177
+
assert!(!list_body2["backupEnabled"].as_bool().unwrap());
178
+
179
+
let enable_res = client
180
+
.post(format!("{}/xrpc/_backup.setEnabled", base_url().await))
181
+
.bearer_auth(&token)
182
+
.json(&json!({"enabled": true}))
183
+
.send()
184
+
.await
185
+
.expect("setEnabled request failed");
186
+
187
+
assert_eq!(enable_res.status(), StatusCode::OK);
188
+
}
189
+
190
+
#[tokio::test]
191
+
async fn test_backup_includes_blobs() {
192
+
let client = client();
193
+
let (token, did) = create_account_and_login(&client).await;
194
+
195
+
let blob_data = b"Hello, this is test blob data for backup testing!";
196
+
let upload_res = client
197
+
.post(format!(
198
+
"{}/xrpc/com.atproto.repo.uploadBlob",
199
+
base_url().await
200
+
))
201
+
.header(header::CONTENT_TYPE, "text/plain")
202
+
.bearer_auth(&token)
203
+
.body(blob_data.to_vec())
204
+
.send()
205
+
.await
206
+
.expect("uploadBlob request failed");
207
+
208
+
assert_eq!(upload_res.status(), StatusCode::OK);
209
+
let upload_body: Value = upload_res.json().await.expect("Invalid JSON");
210
+
let blob = &upload_body["blob"];
211
+
212
+
let record = json!({
213
+
"$type": "app.bsky.feed.post",
214
+
"text": "Test post with blob",
215
+
"createdAt": chrono::Utc::now().to_rfc3339(),
216
+
"embed": {
217
+
"$type": "app.bsky.embed.images",
218
+
"images": [{
219
+
"alt": "test image",
220
+
"image": blob
221
+
}]
222
+
}
223
+
});
224
+
225
+
let create_record_res = client
226
+
.post(format!(
227
+
"{}/xrpc/com.atproto.repo.createRecord",
228
+
base_url().await
229
+
))
230
+
.bearer_auth(&token)
231
+
.json(&json!({
232
+
"repo": did,
233
+
"collection": "app.bsky.feed.post",
234
+
"record": record
235
+
}))
236
+
.send()
237
+
.await
238
+
.expect("createRecord request failed");
239
+
240
+
assert_eq!(create_record_res.status(), StatusCode::OK);
241
+
242
+
let create_backup_res = client
243
+
.post(format!("{}/xrpc/_backup.createBackup", base_url().await))
244
+
.bearer_auth(&token)
245
+
.send()
246
+
.await
247
+
.expect("createBackup request failed");
248
+
249
+
assert_eq!(create_backup_res.status(), StatusCode::OK);
250
+
let backup_body: Value = create_backup_res.json().await.expect("Invalid JSON");
251
+
let backup_id = backup_body["id"].as_str().unwrap();
252
+
253
+
let get_backup_res = client
254
+
.get(format!(
255
+
"{}/xrpc/_backup.getBackup?id={}",
256
+
base_url().await,
257
+
backup_id
258
+
))
259
+
.bearer_auth(&token)
260
+
.send()
261
+
.await
262
+
.expect("getBackup request failed");
263
+
264
+
assert_eq!(get_backup_res.status(), StatusCode::OK);
265
+
let car_bytes = get_backup_res.bytes().await.expect("Failed to read body");
266
+
267
+
let blob_cid = blob["ref"]["$link"].as_str().unwrap();
268
+
let blob_found = String::from_utf8_lossy(&car_bytes).contains("Hello, this is test blob data");
269
+
assert!(
270
+
blob_found || car_bytes.len() > 500,
271
+
"Backup should contain blob data (cid: {})",
272
+
blob_cid
273
+
);
274
+
}
275
+
276
+
#[tokio::test]
277
+
async fn test_backup_unauthorized() {
278
+
let client = client();
279
+
280
+
let res = client
281
+
.get(format!("{}/xrpc/_backup.listBackups", base_url().await))
282
+
.send()
283
+
.await
284
+
.expect("listBackups request failed");
285
+
286
+
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
287
+
}
288
+
289
+
#[tokio::test]
290
+
async fn test_get_nonexistent_backup() {
291
+
let client = client();
292
+
let (token, _did) = create_account_and_login(&client).await;
293
+
294
+
let fake_id = uuid::Uuid::new_v4();
295
+
let res = client
296
+
.get(format!(
297
+
"{}/xrpc/_backup.getBackup?id={}",
298
+
base_url().await,
299
+
fake_id
300
+
))
301
+
.bearer_auth(&token)
302
+
.send()
303
+
.await
304
+
.expect("getBackup request failed");
305
+
306
+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
307
+
}
308
+
309
+
#[tokio::test]
310
+
async fn test_backup_invalid_id() {
311
+
let client = client();
312
+
let (token, _did) = create_account_and_login(&client).await;
313
+
314
+
let res = client
315
+
.get(format!(
316
+
"{}/xrpc/_backup.getBackup?id=not-a-uuid",
317
+
base_url().await
318
+
))
319
+
.bearer_auth(&token)
320
+
.send()
321
+
.await
322
+
.expect("getBackup request failed");
323
+
324
+
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
325
+
}
+6
-24
tests/change_password.rs
+6
-24
tests/change_password.rs
···
32
32
let did = create_body["did"].as_str().unwrap();
33
33
let jwt = verify_new_account(&client, did).await;
34
34
let change_res = client
35
-
.post(format!(
36
-
"{}/xrpc/com.tranquil.account.changePassword",
37
-
base_url().await
38
-
))
35
+
.post(format!("{}/xrpc/_account.changePassword", base_url().await))
39
36
.bearer_auth(&jwt)
40
37
.json(&json!({
41
38
"currentPassword": old_password,
···
86
83
let client = client();
87
84
let (_, jwt) = setup_new_user("change-pw-wrong").await;
88
85
let res = client
89
-
.post(format!(
90
-
"{}/xrpc/com.tranquil.account.changePassword",
91
-
base_url().await
92
-
))
86
+
.post(format!("{}/xrpc/_account.changePassword", base_url().await))
93
87
.bearer_auth(&jwt)
94
88
.json(&json!({
95
89
"currentPassword": "Wrongpass999!",
···
129
123
let did = create_body["did"].as_str().unwrap();
130
124
let jwt = verify_new_account(&client, did).await;
131
125
let res = client
132
-
.post(format!(
133
-
"{}/xrpc/com.tranquil.account.changePassword",
134
-
base_url().await
135
-
))
126
+
.post(format!("{}/xrpc/_account.changePassword", base_url().await))
136
127
.bearer_auth(&jwt)
137
128
.json(&json!({
138
129
"currentPassword": password,
···
151
142
let client = client();
152
143
let (_, jwt) = setup_new_user("change-pw-empty").await;
153
144
let res = client
154
-
.post(format!(
155
-
"{}/xrpc/com.tranquil.account.changePassword",
156
-
base_url().await
157
-
))
145
+
.post(format!("{}/xrpc/_account.changePassword", base_url().await))
158
146
.bearer_auth(&jwt)
159
147
.json(&json!({
160
148
"currentPassword": "",
···
171
159
let client = client();
172
160
let (_, jwt) = setup_new_user("change-pw-emptynew").await;
173
161
let res = client
174
-
.post(format!(
175
-
"{}/xrpc/com.tranquil.account.changePassword",
176
-
base_url().await
177
-
))
162
+
.post(format!("{}/xrpc/_account.changePassword", base_url().await))
178
163
.bearer_auth(&jwt)
179
164
.json(&json!({
180
165
"currentPassword": "E2epass123!",
···
190
175
async fn test_change_password_requires_auth() {
191
176
let client = client();
192
177
let res = client
193
-
.post(format!(
194
-
"{}/xrpc/com.tranquil.account.changePassword",
195
-
base_url().await
196
-
))
178
+
.post(format!("{}/xrpc/_account.changePassword", base_url().await))
197
179
.json(&json!({
198
180
"currentPassword": "Oldpass123!",
199
181
"newPassword": "Newpass123!"
+28
-247
tests/did_web.rs
+28
-247
tests/did_web.rs
···
547
547
}
548
548
549
549
#[tokio::test]
550
-
async fn test_deactivate_with_migrating_to() {
550
+
async fn test_did_web_can_edit_did_document() {
551
551
let client = client();
552
552
let base = base_url().await;
553
-
let handle = format!("mig{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
553
+
let handle = format!("doc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
554
554
let payload = json!({
555
555
"handle": handle,
556
556
"email": format!("{}@example.com", handle),
···
567
567
let body: Value = res.json().await.expect("Response was not JSON");
568
568
let did = body["did"].as_str().expect("No DID").to_string();
569
569
let jwt = verify_new_account(&client, &did).await;
570
-
let target_pds = "https://pds2.example.com";
571
570
let res = client
572
-
.post(format!(
573
-
"{}/xrpc/com.atproto.server.deactivateAccount",
574
-
base
575
-
))
571
+
.get(format!("{}/xrpc/_account.getDidDocument", base))
576
572
.bearer_auth(&jwt)
577
-
.json(&json!({ "migratingTo": target_pds }))
578
-
.send()
579
-
.await
580
-
.expect("Failed to send request");
581
-
assert_eq!(res.status(), StatusCode::OK);
582
-
let pool = get_test_db_pool().await;
583
-
let row = sqlx::query!(
584
-
r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#,
585
-
&did
586
-
)
587
-
.fetch_one(pool)
588
-
.await
589
-
.expect("Failed to query user");
590
-
assert_eq!(
591
-
row.migrated_to_pds.as_deref(),
592
-
Some(target_pds),
593
-
"migrated_to_pds should be set to target PDS"
594
-
);
595
-
assert!(
596
-
row.deactivated_at.is_some(),
597
-
"deactivated_at should be set for migrated account"
598
-
);
599
-
}
600
-
601
-
#[tokio::test]
602
-
async fn test_migrated_account_blocked_from_repo_ops() {
603
-
let client = client();
604
-
let base = base_url().await;
605
-
let handle = format!("blk{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
606
-
let payload = json!({
607
-
"handle": handle,
608
-
"email": format!("{}@example.com", handle),
609
-
"password": "Testpass123!",
610
-
"didType": "web"
611
-
});
612
-
let res = client
613
-
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
614
-
.json(&payload)
615
573
.send()
616
574
.await
617
575
.expect("Failed to send request");
618
576
assert_eq!(res.status(), StatusCode::OK);
619
577
let body: Value = res.json().await.expect("Response was not JSON");
620
-
let did = body["did"].as_str().expect("No DID").to_string();
621
-
let jwt = verify_new_account(&client, &did).await;
622
-
let res = client
623
-
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
624
-
.bearer_auth(&jwt)
625
-
.json(&json!({
626
-
"repo": did,
627
-
"collection": "app.bsky.feed.post",
628
-
"record": {
629
-
"$type": "app.bsky.feed.post",
630
-
"text": "Pre-migration post",
631
-
"createdAt": chrono::Utc::now().to_rfc3339()
632
-
}
633
-
}))
634
-
.send()
635
-
.await
636
-
.expect("Failed to send request");
637
-
assert_eq!(res.status(), StatusCode::OK);
638
-
let res = client
639
-
.post(format!(
640
-
"{}/xrpc/com.atproto.server.deactivateAccount",
641
-
base
642
-
))
643
-
.bearer_auth(&jwt)
644
-
.json(&json!({ "migratingTo": "https://pds2.example.com" }))
645
-
.send()
646
-
.await
647
-
.expect("Failed to send request");
648
-
assert_eq!(res.status(), StatusCode::OK);
649
-
let res = client
650
-
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
651
-
.bearer_auth(&jwt)
652
-
.json(&json!({
653
-
"repo": did,
654
-
"collection": "app.bsky.feed.post",
655
-
"record": {
656
-
"$type": "app.bsky.feed.post",
657
-
"text": "Post-migration post - should fail",
658
-
"createdAt": chrono::Utc::now().to_rfc3339()
659
-
}
660
-
}))
661
-
.send()
662
-
.await
663
-
.expect("Failed to send request");
664
578
assert!(
665
-
res.status().is_client_error(),
666
-
"createRecord should fail for migrated account: {}",
667
-
res.status()
579
+
body["didDocument"].is_object(),
580
+
"Should return DID document"
668
581
);
669
-
let res = client
670
-
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
671
-
.bearer_auth(&jwt)
672
-
.json(&json!({
673
-
"repo": did,
674
-
"collection": "app.bsky.actor.profile",
675
-
"rkey": "self",
676
-
"record": {
677
-
"$type": "app.bsky.actor.profile",
678
-
"displayName": "Test"
679
-
}
680
-
}))
681
-
.send()
682
-
.await
683
-
.expect("Failed to send request");
684
-
assert!(
685
-
res.status().is_client_error(),
686
-
"putRecord should fail for migrated account: {}",
687
-
res.status()
582
+
assert_eq!(
583
+
body["didDocument"]["id"], did,
584
+
"DID document should have correct id"
688
585
);
689
586
let res = client
690
-
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
587
+
.post(format!("{}/xrpc/_account.updateDidDocument", base))
691
588
.bearer_auth(&jwt)
692
589
.json(&json!({
693
-
"repo": did,
694
-
"collection": "app.bsky.feed.post",
695
-
"rkey": "test123"
590
+
"alsoKnownAs": ["at://custom.handle.test"]
696
591
}))
697
592
.send()
698
593
.await
699
594
.expect("Failed to send request");
700
-
assert!(
701
-
res.status().is_client_error(),
702
-
"deleteRecord should fail for migrated account: {}",
703
-
res.status()
595
+
assert_eq!(
596
+
res.status(),
597
+
StatusCode::OK,
598
+
"Non-migrated did:web user should be able to update DID document"
704
599
);
705
-
let res = client
706
-
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base))
707
-
.bearer_auth(&jwt)
708
-
.json(&json!({
709
-
"repo": did,
710
-
"writes": [{
711
-
"$type": "com.atproto.repo.applyWrites#create",
712
-
"collection": "app.bsky.feed.post",
713
-
"value": {
714
-
"$type": "app.bsky.feed.post",
715
-
"text": "Batch post",
716
-
"createdAt": chrono::Utc::now().to_rfc3339()
717
-
}
718
-
}]
719
-
}))
720
-
.send()
721
-
.await
722
-
.expect("Failed to send request");
600
+
let body: Value = res.json().await.expect("Response was not JSON");
601
+
assert!(body["success"].as_bool().unwrap_or(false));
602
+
let also_known_as = body["didDocument"]["alsoKnownAs"]
603
+
.as_array()
604
+
.expect("alsoKnownAs should be array");
723
605
assert!(
724
-
res.status().is_client_error(),
725
-
"applyWrites should fail for migrated account: {}",
726
-
res.status()
727
-
);
728
-
let res = client
729
-
.post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base))
730
-
.bearer_auth(&jwt)
731
-
.header("Content-Type", "text/plain")
732
-
.body("test blob content")
733
-
.send()
734
-
.await
735
-
.expect("Failed to send request");
736
-
assert!(
737
-
res.status().is_client_error(),
738
-
"uploadBlob should fail for migrated account: {}",
739
-
res.status()
606
+
also_known_as
607
+
.iter()
608
+
.any(|v| v.as_str() == Some("at://custom.handle.test")),
609
+
"alsoKnownAs should contain custom entry"
740
610
);
741
611
}
742
612
743
613
#[tokio::test]
744
-
async fn test_migrated_session_status() {
614
+
async fn test_deactivate_account_basic() {
745
615
let client = client();
746
616
let base = base_url().await;
747
-
let handle = format!("ses{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
617
+
let handle = format!("dea{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
748
618
let payload = json!({
749
619
"handle": handle,
750
620
"email": format!("{}@example.com", handle),
···
762
632
let did = body["did"].as_str().expect("No DID").to_string();
763
633
let jwt = verify_new_account(&client, &did).await;
764
634
let res = client
765
-
.get(format!("{}/xrpc/com.atproto.server.getSession", base))
766
-
.bearer_auth(&jwt)
767
-
.send()
768
-
.await
769
-
.expect("Failed to send request");
770
-
assert_eq!(res.status(), StatusCode::OK);
771
-
let body: Value = res.json().await.expect("Response was not JSON");
772
-
assert_eq!(body["active"], true);
773
-
assert!(
774
-
body["status"].is_null() || body["status"] == "active",
775
-
"Status should be null or 'active' for normal accounts"
776
-
);
777
-
let target_pds = "https://pds3.example.com";
778
-
let res = client
779
635
.post(format!(
780
636
"{}/xrpc/com.atproto.server.deactivateAccount",
781
637
base
782
638
))
783
639
.bearer_auth(&jwt)
784
-
.json(&json!({ "migratingTo": target_pds }))
640
+
.json(&json!({}))
785
641
.send()
786
642
.await
787
643
.expect("Failed to send request");
···
794
650
.expect("Failed to send request");
795
651
assert_eq!(res.status(), StatusCode::OK);
796
652
let body: Value = res.json().await.expect("Response was not JSON");
797
-
assert_eq!(
798
-
body["active"], false,
799
-
"Migrated account should not be active"
800
-
);
801
-
assert_eq!(
802
-
body["status"], "migrated",
803
-
"Status should be 'migrated' after migration"
804
-
);
805
-
assert_eq!(
806
-
body["migratedToPds"], target_pds,
807
-
"migratedToPds should be set to target PDS"
808
-
);
809
-
}
810
-
811
-
#[tokio::test]
812
-
async fn test_migrating_to_ignored_for_did_plc() {
813
-
let client = client();
814
-
let base = base_url().await;
815
-
let handle = format!("plc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
816
-
let payload = json!({
817
-
"handle": handle,
818
-
"email": format!("{}@example.com", handle),
819
-
"password": "Testpass123!",
820
-
"didType": "plc"
821
-
});
822
-
let res = client
823
-
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
824
-
.json(&payload)
825
-
.send()
826
-
.await
827
-
.expect("Failed to send request");
828
-
assert_eq!(res.status(), StatusCode::OK);
829
-
let body: Value = res.json().await.expect("Response was not JSON");
830
-
let did = body["did"].as_str().expect("No DID").to_string();
831
-
assert!(did.starts_with("did:plc:"), "Should be did:plc account");
832
-
let jwt = verify_new_account(&client, &did).await;
833
-
let res = client
834
-
.post(format!(
835
-
"{}/xrpc/com.atproto.server.deactivateAccount",
836
-
base
837
-
))
838
-
.bearer_auth(&jwt)
839
-
.json(&json!({ "migratingTo": "https://pds2.example.com" }))
840
-
.send()
841
-
.await
842
-
.expect("Failed to send request");
843
-
assert_eq!(res.status(), StatusCode::OK);
844
-
let pool = get_test_db_pool().await;
845
-
let row = sqlx::query!(
846
-
r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#,
847
-
&did
848
-
)
849
-
.fetch_one(pool)
850
-
.await
851
-
.expect("Failed to query user");
852
-
assert!(
853
-
row.migrated_to_pds.is_none(),
854
-
"migrated_to_pds should NOT be set for did:plc accounts"
855
-
);
856
-
assert!(
857
-
row.deactivated_at.is_some(),
858
-
"deactivated_at should still be set"
859
-
);
860
-
let res = client
861
-
.get(format!("{}/xrpc/com.atproto.server.getSession", base))
862
-
.bearer_auth(&jwt)
863
-
.send()
864
-
.await
865
-
.expect("Failed to send request");
866
-
assert_eq!(res.status(), StatusCode::OK);
867
-
let body: Value = res.json().await.expect("Response was not JSON");
868
-
assert_eq!(body["active"], false);
653
+
assert_eq!(body["active"], false, "Account should be deactivated");
869
654
assert_eq!(
870
655
body["status"], "deactivated",
871
-
"Status should be 'deactivated' not 'migrated' for did:plc"
872
-
);
873
-
assert!(
874
-
body["migratedToPds"].is_null(),
875
-
"migratedToPds should not be set for did:plc accounts"
656
+
"Status should be 'deactivated'"
876
657
);
877
658
}
-1
tests/oauth.rs
-1
tests/oauth.rs
+1
-4
tests/oauth_security.rs
+1
-4
tests/oauth_security.rs
···
1116
1116
1117
1117
let delegated_handle = format!("dg{}", suffix);
1118
1118
let delegated_res = http_client
1119
-
.post(format!(
1120
-
"{}/xrpc/com.tranquil.delegation.createDelegatedAccount",
1121
-
url
1122
-
))
1119
+
.post(format!("{}/xrpc/_delegation.createDelegatedAccount", url))
1123
1120
.bearer_auth(controller_jwt)
1124
1121
.json(&json!({
1125
1122
"handle": delegated_handle,
+9
-36
tests/session_management.rs
+9
-36
tests/session_management.rs
···
10
10
let client = client();
11
11
let (did, jwt) = setup_new_user("list-sessions").await;
12
12
let res = client
13
-
.get(format!(
14
-
"{}/xrpc/com.tranquil.account.listSessions",
15
-
base_url().await
16
-
))
13
+
.get(format!("{}/xrpc/_account.listSessions", base_url().await))
17
14
.bearer_auth(&jwt)
18
15
.send()
19
16
.await
···
83
80
let login_body: Value = login_res.json().await.unwrap();
84
81
let jwt2 = login_body["accessJwt"].as_str().unwrap();
85
82
let list_res = client
86
-
.get(format!(
87
-
"{}/xrpc/com.tranquil.account.listSessions",
88
-
base_url().await
89
-
))
83
+
.get(format!("{}/xrpc/_account.listSessions", base_url().await))
90
84
.bearer_auth(jwt2)
91
85
.send()
92
86
.await
···
106
100
async fn test_list_sessions_requires_auth() {
107
101
let client = client();
108
102
let res = client
109
-
.get(format!(
110
-
"{}/xrpc/com.tranquil.account.listSessions",
111
-
base_url().await
112
-
))
103
+
.get(format!("{}/xrpc/_account.listSessions", base_url().await))
113
104
.send()
114
105
.await
115
106
.expect("Failed to send request");
···
158
149
let login_body: Value = login_res.json().await.unwrap();
159
150
let jwt2 = login_body["accessJwt"].as_str().unwrap();
160
151
let list_res = client
161
-
.get(format!(
162
-
"{}/xrpc/com.tranquil.account.listSessions",
163
-
base_url().await
164
-
))
152
+
.get(format!("{}/xrpc/_account.listSessions", base_url().await))
165
153
.bearer_auth(jwt2)
166
154
.send()
167
155
.await
···
177
165
);
178
166
let session_id = other_session.unwrap()["id"].as_str().unwrap();
179
167
let revoke_res = client
180
-
.post(format!(
181
-
"{}/xrpc/com.tranquil.account.revokeSession",
182
-
base_url().await
183
-
))
168
+
.post(format!("{}/xrpc/_account.revokeSession", base_url().await))
184
169
.bearer_auth(jwt2)
185
170
.json(&json!({"sessionId": session_id}))
186
171
.send()
···
188
173
.expect("Failed to revoke session");
189
174
assert_eq!(revoke_res.status(), StatusCode::OK);
190
175
let list_after_res = client
191
-
.get(format!(
192
-
"{}/xrpc/com.tranquil.account.listSessions",
193
-
base_url().await
194
-
))
176
+
.get(format!("{}/xrpc/_account.listSessions", base_url().await))
195
177
.bearer_auth(jwt2)
196
178
.send()
197
179
.await
···
213
195
let client = client();
214
196
let (_, jwt) = setup_new_user("revoke-invalid").await;
215
197
let res = client
216
-
.post(format!(
217
-
"{}/xrpc/com.tranquil.account.revokeSession",
218
-
base_url().await
219
-
))
198
+
.post(format!("{}/xrpc/_account.revokeSession", base_url().await))
220
199
.bearer_auth(&jwt)
221
200
.json(&json!({"sessionId": "not-a-number"}))
222
201
.send()
···
230
209
let client = client();
231
210
let (_, jwt) = setup_new_user("revoke-notfound").await;
232
211
let res = client
233
-
.post(format!(
234
-
"{}/xrpc/com.tranquil.account.revokeSession",
235
-
base_url().await
236
-
))
212
+
.post(format!("{}/xrpc/_account.revokeSession", base_url().await))
237
213
.bearer_auth(&jwt)
238
214
.json(&json!({"sessionId": "jwt:999999999"}))
239
215
.send()
···
246
222
async fn test_revoke_session_requires_auth() {
247
223
let client = client();
248
224
let res = client
249
-
.post(format!(
250
-
"{}/xrpc/com.tranquil.account.revokeSession",
251
-
base_url().await
252
-
))
225
+
.post(format!("{}/xrpc/_account.revokeSession", base_url().await))
253
226
.json(&json!({"sessionId": "1"}))
254
227
.send()
255
228
.await