+4
.env.example
+4
.env.example
···
106
# INVITE_CODE_REQUIRED=false
107
# Comma-separated list of available user domains
108
# AVAILABLE_USER_DOMAINS=example.com
109
# =============================================================================
110
# Server Metadata (returned by describeServer)
111
# =============================================================================
···
106
# INVITE_CODE_REQUIRED=false
107
# Comma-separated list of available user domains
108
# AVAILABLE_USER_DOMAINS=example.com
109
+
# Enable self-hosted did:web identities (default: true)
110
+
# Hosting did:web requires a long-term commitment to serve DID documents.
111
+
# Set to false if you don't want to offer this option.
112
+
# ENABLE_SELF_HOSTED_DID_WEB=true
113
# =============================================================================
114
# Server Metadata (returned by describeServer)
115
# =============================================================================
+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
+
}
+11
-5
.sqlx/query-1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce.json
.sqlx/query-f06ceae0d1567cab89c48516544879b7ee5a0e9e07afeca837cd49ddd54c129d.json
+11
-5
.sqlx/query-1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce.json
.sqlx/query-f06ceae0d1567cab89c48516544879b7ee5a0e9e07afeca837cd49ddd54c129d.json
···
1
{
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login,\n u.preferred_comms_channel as \"preferred_comms_channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1",
4
"describe": {
5
"columns": [
6
{
···
65
},
66
{
67
"ordinal": 12,
68
"name": "preferred_comms_channel: crate::comms::CommsChannel",
69
"type_info": {
70
"Custom": {
···
81
}
82
},
83
{
84
-
"ordinal": 13,
85
"name": "key_bytes",
86
"type_info": "Bytea"
87
},
88
{
89
-
"ordinal": 14,
90
"name": "encryption_version",
91
"type_info": "Int4"
92
},
93
{
94
-
"ordinal": 15,
95
"name": "totp_enabled",
96
"type_info": "Bool"
97
}
···
114
false,
115
false,
116
false,
117
false,
118
false,
119
true,
120
null
121
]
122
},
123
-
"hash": "1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce"
124
}
···
1
{
2
"db_name": "PostgreSQL",
3
+
"query": "SELECT\n u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,\n u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,\n u.allow_legacy_login, u.migrated_to_pds,\n u.preferred_comms_channel as \"preferred_comms_channel: crate::comms::CommsChannel\",\n k.key_bytes, k.encryption_version,\n (SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled\n FROM users u\n JOIN user_keys k ON u.id = k.user_id\n WHERE u.handle = $1 OR u.email = $1 OR u.did = $1",
4
"describe": {
5
"columns": [
6
{
···
65
},
66
{
67
"ordinal": 12,
68
+
"name": "migrated_to_pds",
69
+
"type_info": "Text"
70
+
},
71
+
{
72
+
"ordinal": 13,
73
"name": "preferred_comms_channel: crate::comms::CommsChannel",
74
"type_info": {
75
"Custom": {
···
86
}
87
},
88
{
89
+
"ordinal": 14,
90
"name": "key_bytes",
91
"type_info": "Bytea"
92
},
93
{
94
+
"ordinal": 15,
95
"name": "encryption_version",
96
"type_info": "Int4"
97
},
98
{
99
+
"ordinal": 16,
100
"name": "totp_enabled",
101
"type_info": "Bool"
102
}
···
119
false,
120
false,
121
false,
122
+
true,
123
false,
124
false,
125
true,
126
null
127
]
128
},
129
+
"hash": "f06ceae0d1567cab89c48516544879b7ee5a0e9e07afeca837cd49ddd54c129d"
130
}
+34
.sqlx/query-36f84d8aa41bb289d09fda5e26a91543b1bfd100c659db47bef97954f4c25580.json
+34
.sqlx/query-36f84d8aa41bb289d09fda5e26a91543b1bfd100c659db47bef97954f4c25580.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, handle, migrated_to_pds FROM users WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "handle",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "migrated_to_pds",
19
+
"type_info": "Text"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Text"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
false,
30
+
true
31
+
]
32
+
},
33
+
"hash": "36f84d8aa41bb289d09fda5e26a91543b1bfd100c659db47bef97954f4c25580"
34
+
}
+34
.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json
+34
.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "id",
9
+
"type_info": "Uuid"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "migrated_to_pds",
14
+
"type_info": "Text"
15
+
},
16
+
{
17
+
"ordinal": 2,
18
+
"name": "handle",
19
+
"type_info": "Text"
20
+
}
21
+
],
22
+
"parameters": {
23
+
"Left": [
24
+
"Text"
25
+
]
26
+
},
27
+
"nullable": [
28
+
false,
29
+
true,
30
+
false
31
+
]
32
+
},
33
+
"hash": "63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073"
34
+
}
+17
.sqlx/query-7f8bc1ef416b851704ccc5232abd65a71e624aab95ae44f57448a88bef78e2a3.json
+17
.sqlx/query-7f8bc1ef416b851704ccc5232abd65a71e624aab95ae44f57448a88bef78e2a3.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "\n INSERT INTO did_web_overrides (user_id, verification_methods, also_known_as, updated_at)\n VALUES ($1, COALESCE($2, '[]'::jsonb), COALESCE($3, '{}'::text[]), $4)\n ON CONFLICT (user_id) DO UPDATE SET\n verification_methods = CASE WHEN $2 IS NOT NULL THEN $2 ELSE did_web_overrides.verification_methods END,\n also_known_as = CASE WHEN $3 IS NOT NULL THEN $3 ELSE did_web_overrides.also_known_as END,\n updated_at = $4\n ",
4
+
"describe": {
5
+
"columns": [],
6
+
"parameters": {
7
+
"Left": [
8
+
"Uuid",
9
+
"Jsonb",
10
+
"TextArray",
11
+
"Timestamptz"
12
+
]
13
+
},
14
+
"nullable": []
15
+
},
16
+
"hash": "7f8bc1ef416b851704ccc5232abd65a71e624aab95ae44f57448a88bef78e2a3"
17
+
}
+15
-3
.sqlx/query-c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782.json
.sqlx/query-0d0622d485361d9f4d4f85e1ba23a331f16155c08208b65b448e1e4659a070b8.json
+15
-3
.sqlx/query-c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782.json
.sqlx/query-0d0622d485361d9f4d4f85e1ba23a331f16155c08208b65b448e1e4659a070b8.json
···
1
{
2
"db_name": "PostgreSQL",
3
-
"query": "SELECT\n handle, email, email_verified, is_admin, deactivated_at, takedown_ref, preferred_locale,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified\n FROM users WHERE did = $1",
4
"describe": {
5
"columns": [
6
{
···
69
"ordinal": 10,
70
"name": "signal_verified",
71
"type_info": "Bool"
72
}
73
],
74
"parameters": {
···
87
false,
88
false,
89
false,
90
-
false
91
]
92
},
93
-
"hash": "c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782"
94
}
···
1
{
2
"db_name": "PostgreSQL",
3
+
"query": "SELECT\n handle, email, email_verified, is_admin, deactivated_at, takedown_ref, preferred_locale,\n preferred_comms_channel as \"preferred_channel: crate::comms::CommsChannel\",\n discord_verified, telegram_verified, signal_verified, migrated_to_pds, migrated_at\n FROM users WHERE did = $1",
4
"describe": {
5
"columns": [
6
{
···
69
"ordinal": 10,
70
"name": "signal_verified",
71
"type_info": "Bool"
72
+
},
73
+
{
74
+
"ordinal": 11,
75
+
"name": "migrated_to_pds",
76
+
"type_info": "Text"
77
+
},
78
+
{
79
+
"ordinal": 12,
80
+
"name": "migrated_at",
81
+
"type_info": "Timestamptz"
82
}
83
],
84
"parameters": {
···
97
false,
98
false,
99
false,
100
+
false,
101
+
true,
102
+
true
103
]
104
},
105
+
"hash": "0d0622d485361d9f4d4f85e1ba23a331f16155c08208b65b448e1e4659a070b8"
106
}
+22
.sqlx/query-d91efadf413a90b907727cffd1ce13eb68c3ef017a34e69629df1255de312317.json
+22
.sqlx/query-d91efadf413a90b907727cffd1ce13eb68c3ef017a34e69629df1255de312317.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT (migrated_to_pds IS NOT NULL AND deactivated_at IS NOT NULL) as \"migrated!: bool\" FROM users WHERE did = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "migrated!: bool",
9
+
"type_info": "Bool"
10
+
}
11
+
],
12
+
"parameters": {
13
+
"Left": [
14
+
"Text"
15
+
]
16
+
},
17
+
"nullable": [
18
+
null
19
+
]
20
+
},
21
+
"hash": "d91efadf413a90b907727cffd1ce13eb68c3ef017a34e69629df1255de312317"
22
+
}
+28
.sqlx/query-e6aa80223281c7d4303122f92a9cf0e8718ca331412fb48c4bd24a5c5acb4492.json
+28
.sqlx/query-e6aa80223281c7d4303122f92a9cf0e8718ca331412fb48c4bd24a5c5acb4492.json
···
···
1
+
{
2
+
"db_name": "PostgreSQL",
3
+
"query": "SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
4
+
"describe": {
5
+
"columns": [
6
+
{
7
+
"ordinal": 0,
8
+
"name": "verification_methods",
9
+
"type_info": "Jsonb"
10
+
},
11
+
{
12
+
"ordinal": 1,
13
+
"name": "also_known_as",
14
+
"type_info": "TextArray"
15
+
}
16
+
],
17
+
"parameters": {
18
+
"Left": [
19
+
"Uuid"
20
+
]
21
+
},
22
+
"nullable": [
23
+
false,
24
+
false
25
+
]
26
+
},
27
+
"hash": "e6aa80223281c7d4303122f92a9cf0e8718ca331412fb48c4bd24a5c5acb4492"
28
+
}
+8
-8
TODO.md
+8
-8
TODO.md
···
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
-
- [ ] Add `migratingTo` parameter to `deactivateAccount` endpoint
9
-
- [ ] For self-hosted did:web users: set `migrated_to_pds`, update DID doc serviceEndpoint
10
-
- [ ] "Migrated" account state for self-hosted did:web: can authenticate but no repo operations
11
-
- [ ] Migrated did:web user UI: minimal dashboard with "update forwarding PDS" setting, or full migration wizard to handle PDS 2 -> PDS 3 moves automatically
12
-
- [ ] Outbound UI wizard: new PDS URL -> export repo -> guide account creation -> complete migration
13
-
- [ ] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow
14
-
- [ ] Support `createAccount` with existing DID + service auth token
15
-
- [ ] Progress tracking with resume capability
16
- [ ] Scheduled automatic backups (CAR export)
17
- [ ] One-click restore from backup
18
···
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] Add `migratingTo` parameter to `deactivateAccount` endpoint
9
+
- [x] For self-hosted did:web users: set `migrated_to_pds`, update DID doc serviceEndpoint
10
+
- [x] "Migrated" account state for self-hosted did:web: can authenticate but no repo operations
11
+
- [x] Migrated did:web user UI: minimal dashboard with "update forwarding PDS" setting, or full migration wizard to handle PDS 2 -> PDS 3 moves automatically
12
+
- [x] Outbound UI wizard: new PDS URL -> export repo -> guide account creation -> complete migration
13
+
- [x] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow
14
+
- [x] Support `createAccount` with existing DID + service auth token
15
+
- [x] Progress tracking with resume capability
16
- [ ] Scheduled automatic backups (CAR export)
17
- [ ] One-click restore from backup
18
+3
frontend/src/App.svelte
+3
frontend/src/App.svelte
···
33
import DelegationAudit from './routes/DelegationAudit.svelte'
34
import ActAs from './routes/ActAs.svelte'
35
import Migration from './routes/Migration.svelte'
36
import Home from './routes/Home.svelte'
37
38
initI18n()
···
116
return ActAs
117
case '/migrate':
118
return Migration
119
default:
120
return Home
121
}
···
33
import DelegationAudit from './routes/DelegationAudit.svelte'
34
import ActAs from './routes/ActAs.svelte'
35
import Migration from './routes/Migration.svelte'
36
+
import DidDocumentEditor from './routes/DidDocumentEditor.svelte'
37
import Home from './routes/Home.svelte'
38
39
initI18n()
···
117
return ActAs
118
case '/migrate':
119
return Migration
120
+
case '/did-document':
121
+
return DidDocumentEditor
122
default:
123
return Home
124
}
+8
frontend/src/components/migration/InboundWizard.svelte
+8
frontend/src/components/migration/InboundWizard.svelte
···
620
621
<div class="code-block">
622
<pre>{`{
623
+
"@context": [
624
+
"https://www.w3.org/ns/did/v1",
625
+
"https://w3id.org/security/multikey/v1",
626
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
627
+
],
628
"id": "${flow.state.sourceDid}",
629
+
"alsoKnownAs": [
630
+
"at://${flow.state.targetHandle || '...'}"
631
+
],
632
"verificationMethod": [
633
{
634
"id": "${flow.state.sourceDid}#atproto",
+84
-1
frontend/src/lib/api.ts
+84
-1
frontend/src/lib/api.ts
···
86
preferredChannelVerified?: boolean;
87
isAdmin?: boolean;
88
active?: boolean;
89
-
status?: "active" | "deactivated";
90
accessJwt: string;
91
refreshJwt: string;
92
}
93
94
export interface AppPassword {
···
330
links?: { privacyPolicy?: string; termsOfService?: string };
331
version?: string;
332
availableCommsChannels?: string[];
333
}> {
334
return xrpc("com.atproto.server.describeServer");
335
},
···
1055
method: "POST",
1056
body: { token, identifier },
1057
token: accessToken,
1058
});
1059
},
1060
};
···
86
preferredChannelVerified?: boolean;
87
isAdmin?: boolean;
88
active?: boolean;
89
+
status?: "active" | "deactivated" | "migrated";
90
+
migratedToPds?: string;
91
+
migratedAt?: string;
92
accessJwt: string;
93
refreshJwt: string;
94
+
}
95
+
96
+
export interface VerificationMethod {
97
+
id: string;
98
+
type: string;
99
+
publicKeyMultibase: string;
100
+
}
101
+
102
+
export interface DidDocument {
103
+
"@context": string[];
104
+
id: string;
105
+
alsoKnownAs: string[];
106
+
verificationMethod: Array<{
107
+
id: string;
108
+
type: string;
109
+
controller: string;
110
+
publicKeyMultibase: string;
111
+
}>;
112
+
service: Array<{
113
+
id: string;
114
+
type: string;
115
+
serviceEndpoint: string;
116
+
}>;
117
}
118
119
export interface AppPassword {
···
355
links?: { privacyPolicy?: string; termsOfService?: string };
356
version?: string;
357
availableCommsChannels?: string[];
358
+
selfHostedDidWebEnabled?: boolean;
359
}> {
360
return xrpc("com.atproto.server.describeServer");
361
},
···
1081
method: "POST",
1082
body: { token, identifier },
1083
token: accessToken,
1084
+
});
1085
+
},
1086
+
1087
+
async getDidDocument(token: string): Promise<DidDocument> {
1088
+
return xrpc("com.tranquil.account.getDidDocument", { token });
1089
+
},
1090
+
1091
+
async updateDidDocument(
1092
+
token: string,
1093
+
params: {
1094
+
verificationMethods?: VerificationMethod[];
1095
+
alsoKnownAs?: string[];
1096
+
serviceEndpoint?: string;
1097
+
},
1098
+
): Promise<{ success: boolean }> {
1099
+
return xrpc("com.tranquil.account.updateDidDocument", {
1100
+
method: "POST",
1101
+
token,
1102
+
body: params,
1103
+
});
1104
+
},
1105
+
1106
+
async deactivateAccount(
1107
+
token: string,
1108
+
deleteAfter?: string,
1109
+
migratingTo?: string,
1110
+
): Promise<void> {
1111
+
await xrpc("com.atproto.server.deactivateAccount", {
1112
+
method: "POST",
1113
+
token,
1114
+
body: { deleteAfter, migratingTo },
1115
+
});
1116
+
},
1117
+
1118
+
async getMigrationStatus(token: string): Promise<{
1119
+
migratedToPds?: string;
1120
+
migratedAt?: string;
1121
+
forwardingEnabled: boolean;
1122
+
}> {
1123
+
return xrpc("com.tranquil.account.getMigrationStatus", { token });
1124
+
},
1125
+
1126
+
async updateMigrationForwarding(
1127
+
token: string,
1128
+
forwardingPds?: string,
1129
+
): Promise<{ success: boolean }> {
1130
+
return xrpc("com.tranquil.account.updateMigrationForwarding", {
1131
+
method: "POST",
1132
+
token,
1133
+
body: { forwardingPds },
1134
+
});
1135
+
},
1136
+
1137
+
async clearMigrationForwarding(token: string): Promise<{ success: boolean }> {
1138
+
return xrpc("com.tranquil.account.clearMigrationForwarding", {
1139
+
method: "POST",
1140
+
token,
1141
});
1142
},
1143
};
+11
-2
frontend/src/lib/migration/atproto-client.ts
+11
-2
frontend/src/lib/migration/atproto-client.ts
···
327
);
328
}
329
330
-
async deactivateAccount(): Promise<void> {
331
-
apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`);
332
const start = Date.now();
333
try {
334
await this.xrpc("com.atproto.server.deactivateAccount", {
335
httpMethod: "POST",
336
});
337
apiLog(
338
"POST",
···
340
{
341
durationMs: Date.now() - start,
342
success: true,
343
},
344
);
345
} catch (e) {
···
352
error: err.message,
353
errorCode: err.error,
354
status: err.status,
355
},
356
);
357
throw e;
···
327
);
328
}
329
330
+
async deactivateAccount(migratingTo?: string): Promise<void> {
331
+
apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, {
332
+
migratingTo,
333
+
});
334
const start = Date.now();
335
try {
336
+
const body: { migratingTo?: string } = {};
337
+
if (migratingTo) {
338
+
body.migratingTo = migratingTo;
339
+
}
340
await this.xrpc("com.atproto.server.deactivateAccount", {
341
httpMethod: "POST",
342
+
body,
343
});
344
apiLog(
345
"POST",
···
347
{
348
durationMs: Date.now() - start,
349
success: true,
350
+
migratingTo,
351
},
352
);
353
} catch (e) {
···
360
error: err.message,
361
errorCode: err.error,
362
status: err.status,
363
+
migratingTo,
364
},
365
);
366
throw e;
+1
-12
frontend/src/lib/migration/flow.svelte.ts
+1
-12
frontend/src/lib/migration/flow.svelte.ts
···
906
907
setProgress({ currentOperation: "Deactivating old account..." });
908
try {
909
-
await localClient.deactivateAccount();
910
setProgress({ deactivated: true });
911
} catch {
912
-
}
913
-
914
-
if (state.localDid.startsWith("did:web:")) {
915
-
setProgress({
916
-
currentOperation: "Updating DID document forwarding...",
917
-
});
918
-
try {
919
-
await localClient.updateMigrationForwarding(state.targetPdsUrl);
920
-
} catch (e) {
921
-
console.warn("Failed to update migration forwarding:", e);
922
-
}
923
}
924
925
setStep("success");
+42
-1
frontend/src/locales/en.json
+42
-1
frontend/src/locales/en.json
···
87
"didPlcHint": "Portable identity managed by PLC Directory",
88
"didWeb": "did:web",
89
"didWebHint": "Identity hosted on this PDS (read warning below)",
90
"didWebBYOD": "did:web (BYOD)",
91
"didWebBYODHint": "Bring your own domain",
92
"didWebWarningTitle": "Important: Understand the trade-offs",
···
175
"navDelegation": "Delegation",
176
"navDelegationDesc": "Manage account controllers and delegated accounts",
177
"navAdmin": "Admin Panel",
178
-
"navAdminDesc": "Server stats and admin operations"
179
},
180
"settings": {
181
"title": "Account Settings",
···
792
"didPlcHint": "Portable identity managed by PLC Directory",
793
"didWeb": "did:web",
794
"didWebHint": "Identity hosted on this PDS (read warning below)",
795
"didWebBYOD": "did:web (BYOD)",
796
"didWebBYODHint": "Bring your own domain",
797
"didWebWarningTitle": "Important: Understand the trade-offs",
···
87
"didPlcHint": "Portable identity managed by PLC Directory",
88
"didWeb": "did:web",
89
"didWebHint": "Identity hosted on this PDS (read warning below)",
90
+
"didWebDisabledHint": "Not available on this PDS - use did:plc or bring your own did:web",
91
"didWebBYOD": "did:web (BYOD)",
92
"didWebBYODHint": "Bring your own domain",
93
"didWebWarningTitle": "Important: Understand the trade-offs",
···
176
"navDelegation": "Delegation",
177
"navDelegationDesc": "Manage account controllers and delegated accounts",
178
"navAdmin": "Admin Panel",
179
+
"navAdminDesc": "Server stats and admin operations",
180
+
"navDidDocument": "DID Document",
181
+
"navDidDocumentDesc": "Manage your DID document for external migrations",
182
+
"migrated": "Migrated",
183
+
"migratedTitle": "Account Migrated",
184
+
"migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.",
185
+
"navMigrateAgain": "Migrate Again",
186
+
"navMigrateAgainDesc": "Move to another PDS and update your DID document"
187
+
},
188
+
"didEditor": {
189
+
"title": "DID Document Editor",
190
+
"preview": "Current DID Document",
191
+
"verificationMethods": "Verification Methods",
192
+
"verificationMethodsDesc": "Signing keys that can act on behalf of your DID. When you migrate to a new PDS, add their signing key here.",
193
+
"addKey": "Add Key",
194
+
"removeKey": "Remove",
195
+
"keyId": "Key ID",
196
+
"keyIdPlaceholder": "#atproto",
197
+
"publicKey": "Public Key (Multibase)",
198
+
"publicKeyPlaceholder": "zQ3sh...",
199
+
"noKeys": "No verification methods configured. Using the local PDS key.",
200
+
"alsoKnownAs": "Also Known As",
201
+
"alsoKnownAsDesc": "Handles that point to your DID. Update this when your handle changes on a new PDS.",
202
+
"addHandle": "Add Handle",
203
+
"removeHandle": "Remove",
204
+
"handle": "Handle",
205
+
"handlePlaceholder": "at://handle.newpds.com",
206
+
"noHandles": "No handles configured. Using the local handle.",
207
+
"serviceEndpoint": "Service Endpoint",
208
+
"serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.",
209
+
"currentPds": "Current PDS URL",
210
+
"save": "Save Changes",
211
+
"saving": "Saving...",
212
+
"success": "DID document updated successfully",
213
+
"saveFailed": "Failed to save DID document",
214
+
"loadFailed": "Failed to load DID document",
215
+
"invalidMultibase": "Public key must be a valid multibase string starting with 'z'",
216
+
"invalidHandle": "Handle must be an at:// URI (e.g., at://handle.example.com)",
217
+
"helpTitle": "What is this?",
218
+
"helpText": "When you migrate to another PDS, that PDS generates new signing keys. Update your DID document here so it points to your new keys and location. This enables multi-hop migrations (PDS 1 → PDS 2 → PDS 3)."
219
},
220
"settings": {
221
"title": "Account Settings",
···
832
"didPlcHint": "Portable identity managed by PLC Directory",
833
"didWeb": "did:web",
834
"didWebHint": "Identity hosted on this PDS (read warning below)",
835
+
"didWebDisabledHint": "Not available on this PDS - use did:plc or bring your own did:web",
836
"didWebBYOD": "did:web (BYOD)",
837
"didWebBYODHint": "Bring your own domain",
838
"didWebWarningTitle": "Important: Understand the trade-offs",
+42
-1
frontend/src/locales/fi.json
+42
-1
frontend/src/locales/fi.json
···
87
"didPlcHint": "Siirrettävä identiteetti, jota hallinnoi PLC Directory",
88
"didWeb": "did:web",
89
"didWebHint": "Identiteetti isännöidään tällä PDS:llä (lue alla oleva varoitus)",
90
"didWebBYOD": "did:web (oma verkkotunnus)",
91
"didWebBYODHint": "Käytä omaa verkkotunnustasi",
92
"didWebWarningTitle": "Tärkeää: Ymmärrä kompromissit",
···
175
"navDelegation": "Delegointi",
176
"navDelegationDesc": "Hallitse tilin ohjaajia ja delegoituja tilejä",
177
"navAdmin": "Ylläpitopaneeli",
178
-
"navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot"
179
},
180
"settings": {
181
"title": "Tilin asetukset",
···
817
"didPlcHint": "Siirrettävä identiteetti, jota hallinnoi PLC Directory",
818
"didWeb": "did:web",
819
"didWebHint": "Tällä PDS:llä isännöity identiteetti (lue varoitus alla)",
820
"didWebBYOD": "did:web (BYOD)",
821
"didWebBYODHint": "Tuo oma verkkotunnuksesi",
822
"didWebWarningTitle": "Tärkeää: Ymmärrä kompromissit",
···
87
"didPlcHint": "Siirrettävä identiteetti, jota hallinnoi PLC Directory",
88
"didWeb": "did:web",
89
"didWebHint": "Identiteetti isännöidään tällä PDS:llä (lue alla oleva varoitus)",
90
+
"didWebDisabledHint": "Ei saatavilla tällä PDS:llä - käytä did:plc:tä tai tuo oma did:web",
91
"didWebBYOD": "did:web (oma verkkotunnus)",
92
"didWebBYODHint": "Käytä omaa verkkotunnustasi",
93
"didWebWarningTitle": "Tärkeää: Ymmärrä kompromissit",
···
176
"navDelegation": "Delegointi",
177
"navDelegationDesc": "Hallitse tilin ohjaajia ja delegoituja tilejä",
178
"navAdmin": "Ylläpitopaneeli",
179
+
"navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot",
180
+
"navDidDocument": "DID-dokumentti",
181
+
"navDidDocumentDesc": "Hallitse DID-dokumenttiasi ulkoisia siirtoja varten",
182
+
"migrated": "Siirretty",
183
+
"migratedTitle": "Tili siirretty",
184
+
"migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isännöidään edelleen täällä, ja voit päivittää sen tulevia siirtoja varten.",
185
+
"navMigrateAgain": "Siirrä uudelleen",
186
+
"navMigrateAgainDesc": "Siirrä toiseen PDS:ään ja päivitä DID-dokumenttisi"
187
+
},
188
+
"didEditor": {
189
+
"title": "DID-dokumentin muokkain",
190
+
"preview": "Nykyinen DID-dokumentti",
191
+
"verificationMethods": "Vahvistusmenetelmät",
192
+
"verificationMethodsDesc": "Allekirjoitusavaimet, jotka voivat toimia DID:si puolesta. Kun siirryt uuteen PDS:ään, lisää niiden allekirjoitusavain tähän.",
193
+
"addKey": "Lisää avain",
194
+
"removeKey": "Poista",
195
+
"keyId": "Avaimen tunnus",
196
+
"keyIdPlaceholder": "#atproto",
197
+
"publicKey": "Julkinen avain (Multibase)",
198
+
"publicKeyPlaceholder": "zQ3sh...",
199
+
"noKeys": "Ei vahvistusmenetelmiä määritetty. Käytetään paikallista PDS-avainta.",
200
+
"alsoKnownAs": "Tunnetaan myös nimellä",
201
+
"alsoKnownAsDesc": "Kahvat, jotka osoittavat DID:iisi. Päivitä tämä, kun kahvasi muuttuu uudessa PDS:ssä.",
202
+
"addHandle": "Lisää kahva",
203
+
"removeHandle": "Poista",
204
+
"handle": "Kahva",
205
+
"handlePlaceholder": "at://kahva.uusipds.com",
206
+
"noHandles": "Ei kahvoja määritetty. Käytetään paikallista kahvaa.",
207
+
"serviceEndpoint": "Palvelupäätepiste",
208
+
"serviceEndpointDesc": "PDS, joka tällä hetkellä isännöi tilitietojasi. Päivitä tämä siirron yhteydessä.",
209
+
"currentPds": "Nykyinen PDS-URL",
210
+
"save": "Tallenna muutokset",
211
+
"saving": "Tallennetaan...",
212
+
"success": "DID-dokumentti päivitetty onnistuneesti",
213
+
"saveFailed": "DID-dokumentin tallennus epäonnistui",
214
+
"loadFailed": "DID-dokumentin lataus epäonnistui",
215
+
"invalidMultibase": "Julkisen avaimen on oltava kelvollinen multibase-merkkijono, joka alkaa 'z':llä",
216
+
"invalidHandle": "Kahvan on oltava at://-URI (esim. at://kahva.esimerkki.com)",
217
+
"helpTitle": "Mikä tämä on?",
218
+
"helpText": "Kun siirryt toiseen PDS:ään, se luo uudet allekirjoitusavaimet. Päivitä DID-dokumenttisi tässä osoittamaan uusiin avaimiin ja sijaintiin. Tämä mahdollistaa monivaiheiset siirrot (PDS 1 → PDS 2 → PDS 3)."
219
},
220
"settings": {
221
"title": "Tilin asetukset",
···
857
"didPlcHint": "Siirrettävä identiteetti, jota hallinnoi PLC Directory",
858
"didWeb": "did:web",
859
"didWebHint": "Tällä PDS:llä isännöity identiteetti (lue varoitus alla)",
860
+
"didWebDisabledHint": "Ei saatavilla tällä PDS:llä - käytä did:plc:tä tai tuo oma did:web",
861
"didWebBYOD": "did:web (BYOD)",
862
"didWebBYODHint": "Tuo oma verkkotunnuksesi",
863
"didWebWarningTitle": "Tärkeää: Ymmärrä kompromissit",
+30
-1
frontend/src/locales/ja.json
+30
-1
frontend/src/locales/ja.json
···
87
"didPlcHint": "PLC ディレクトリで管理されるポータブルアイデンティティ",
88
"didWeb": "did:web",
89
"didWebHint": "この PDS でホストされるアイデンティティ(下記の警告をお読みください)",
90
"didWebBYOD": "did:web (自前ドメイン)",
91
"didWebBYODHint": "独自ドメインを持ち込む",
92
"didWebWarningTitle": "重要: トレードオフをご理解ください",
···
175
"navDelegation": "委任",
176
"navDelegationDesc": "アカウントコントローラーと委任アカウントを管理",
177
"navAdmin": "管理パネル",
178
-
"navAdminDesc": "サーバー統計と管理操作"
179
},
180
"settings": {
181
"title": "アカウント設定",
···
817
"didPlcHint": "PLC Directoryで管理されるポータブルなアイデンティティ",
818
"didWeb": "did:web",
819
"didWebHint": "このPDSでホストされるアイデンティティ(以下の警告を参照)",
820
"didWebBYOD": "did:web(BYOD)",
821
"didWebBYODHint": "独自ドメインを持ち込む",
822
"didWebWarningTitle": "重要:トレードオフを理解する",
···
87
"didPlcHint": "PLC ディレクトリで管理されるポータブルアイデンティティ",
88
"didWeb": "did:web",
89
"didWebHint": "この PDS でホストされるアイデンティティ(下記の警告をお読みください)",
90
+
"didWebDisabledHint": "この PDS では利用できません - did:plc を使用するか、独自の did:web を持ち込んでください",
91
"didWebBYOD": "did:web (自前ドメイン)",
92
"didWebBYODHint": "独自ドメインを持ち込む",
93
"didWebWarningTitle": "重要: トレードオフをご理解ください",
···
176
"navDelegation": "委任",
177
"navDelegationDesc": "アカウントコントローラーと委任アカウントを管理",
178
"navAdmin": "管理パネル",
179
+
"navAdminDesc": "サーバー統計と管理操作",
180
+
"navDidDocument": "DID ドキュメント",
181
+
"navDidDocumentDesc": "DID ドキュメントとキーを管理",
182
+
"migrated": "移行済み",
183
+
"migratedTitle": "アカウント移行済み",
184
+
"migratedMessage": "アカウントは {pds} に移行されました。DID ドキュメントは引き続きここでホストされています。",
185
+
"navMigrateAgain": "再移行",
186
+
"navMigrateAgainDesc": "別の PDS に移行して DID ドキュメントを更新"
187
+
},
188
+
"didEditor": {
189
+
"title": "DID ドキュメントエディター",
190
+
"preview": "現在の DID ドキュメント",
191
+
"verificationMethods": "検証方法(署名キー)",
192
+
"addKey": "キーを追加",
193
+
"removeKey": "削除",
194
+
"keyId": "キー ID",
195
+
"keyIdPlaceholder": "#atproto",
196
+
"publicKey": "公開キー(Multibase)",
197
+
"publicKeyPlaceholder": "zQ3sh...",
198
+
"alsoKnownAs": "別名(ハンドル)",
199
+
"addHandle": "ハンドルを追加",
200
+
"handlePlaceholder": "at://handle.pds.com",
201
+
"serviceEndpoint": "サービスエンドポイント(現在の PDS)",
202
+
"save": "変更を保存",
203
+
"saving": "保存中...",
204
+
"success": "DID ドキュメントを更新しました",
205
+
"helpTitle": "これは何ですか?",
206
+
"helpText": "別の PDS に移行すると、その PDS が新しい署名キーを生成します。ここで DID ドキュメントを更新して、新しいキーと場所を指すようにしてください。"
207
},
208
"settings": {
209
"title": "アカウント設定",
···
845
"didPlcHint": "PLC Directoryで管理されるポータブルなアイデンティティ",
846
"didWeb": "did:web",
847
"didWebHint": "このPDSでホストされるアイデンティティ(以下の警告を参照)",
848
+
"didWebDisabledHint": "この PDS では利用できません - did:plc を使用するか、独自の did:web を持ち込んでください",
849
"didWebBYOD": "did:web(BYOD)",
850
"didWebBYODHint": "独自ドメインを持ち込む",
851
"didWebWarningTitle": "重要:トレードオフを理解する",
+30
-1
frontend/src/locales/ko.json
+30
-1
frontend/src/locales/ko.json
···
87
"didPlcHint": "PLC 디렉토리에서 관리하는 이동 가능한 ID",
88
"didWeb": "did:web",
89
"didWebHint": "이 PDS에서 호스팅되는 ID (아래 경고 참조)",
90
"didWebBYOD": "did:web (자체 도메인)",
91
"didWebBYODHint": "자체 도메인 사용",
92
"didWebWarningTitle": "중요: 장단점을 이해하세요",
···
175
"navDelegation": "위임",
176
"navDelegationDesc": "계정 컨트롤러 및 위임된 계정 관리",
177
"navAdmin": "관리 패널",
178
-
"navAdminDesc": "서버 통계 및 관리 작업"
179
},
180
"settings": {
181
"title": "계정 설정",
···
817
"didPlcHint": "PLC Directory에서 관리하는 이동 가능한 아이덴티티",
818
"didWeb": "did:web",
819
"didWebHint": "이 PDS에서 호스팅되는 아이덴티티 (아래 경고 읽기)",
820
"didWebBYOD": "did:web (BYOD)",
821
"didWebBYODHint": "자체 도메인 사용",
822
"didWebWarningTitle": "중요: 장단점 이해하기",
···
87
"didPlcHint": "PLC 디렉토리에서 관리하는 이동 가능한 ID",
88
"didWeb": "did:web",
89
"didWebHint": "이 PDS에서 호스팅되는 ID (아래 경고 참조)",
90
+
"didWebDisabledHint": "이 PDS에서 사용할 수 없음 - did:plc를 사용하거나 자체 did:web을 가져오세요",
91
"didWebBYOD": "did:web (자체 도메인)",
92
"didWebBYODHint": "자체 도메인 사용",
93
"didWebWarningTitle": "중요: 장단점을 이해하세요",
···
176
"navDelegation": "위임",
177
"navDelegationDesc": "계정 컨트롤러 및 위임된 계정 관리",
178
"navAdmin": "관리 패널",
179
+
"navAdminDesc": "서버 통계 및 관리 작업",
180
+
"navDidDocument": "DID 문서",
181
+
"navDidDocumentDesc": "DID 문서 및 키 관리",
182
+
"migrated": "마이그레이션됨",
183
+
"migratedTitle": "계정 마이그레이션됨",
184
+
"migratedMessage": "계정이 {pds}로 마이그레이션되었습니다. DID 문서는 여전히 여기에서 호스팅됩니다.",
185
+
"navMigrateAgain": "다시 마이그레이션",
186
+
"navMigrateAgainDesc": "다른 PDS로 이동하고 DID 문서 업데이트"
187
+
},
188
+
"didEditor": {
189
+
"title": "DID 문서 편집기",
190
+
"preview": "현재 DID 문서",
191
+
"verificationMethods": "검증 방법 (서명 키)",
192
+
"addKey": "키 추가",
193
+
"removeKey": "삭제",
194
+
"keyId": "키 ID",
195
+
"keyIdPlaceholder": "#atproto",
196
+
"publicKey": "공개 키 (Multibase)",
197
+
"publicKeyPlaceholder": "zQ3sh...",
198
+
"alsoKnownAs": "다른 이름 (핸들)",
199
+
"addHandle": "핸들 추가",
200
+
"handlePlaceholder": "at://handle.pds.com",
201
+
"serviceEndpoint": "서비스 엔드포인트 (현재 PDS)",
202
+
"save": "변경사항 저장",
203
+
"saving": "저장 중...",
204
+
"success": "DID 문서가 업데이트되었습니다",
205
+
"helpTitle": "이것은 무엇인가요?",
206
+
"helpText": "다른 PDS로 마이그레이션하면 해당 PDS가 새 서명 키를 생성합니다. 여기에서 DID 문서를 업데이트하여 새 키와 위치를 가리키도록 하세요."
207
},
208
"settings": {
209
"title": "계정 설정",
···
845
"didPlcHint": "PLC Directory에서 관리하는 이동 가능한 아이덴티티",
846
"didWeb": "did:web",
847
"didWebHint": "이 PDS에서 호스팅되는 아이덴티티 (아래 경고 읽기)",
848
+
"didWebDisabledHint": "이 PDS에서 사용할 수 없음 - did:plc를 사용하거나 자체 did:web을 가져오세요",
849
"didWebBYOD": "did:web (BYOD)",
850
"didWebBYODHint": "자체 도메인 사용",
851
"didWebWarningTitle": "중요: 장단점 이해하기",
+30
-1
frontend/src/locales/sv.json
+30
-1
frontend/src/locales/sv.json
···
87
"didPlcHint": "Portabel identitet hanterad av PLC Directory",
88
"didWeb": "did:web",
89
"didWebHint": "Identitet lagrad på denna PDS (läs varningen nedan)",
90
"didWebBYOD": "did:web (egen domän)",
91
"didWebBYODHint": "Använd din egen domän",
92
"didWebWarningTitle": "Viktigt: Förstå avvägningarna",
···
175
"navDelegation": "Delegering",
176
"navDelegationDesc": "Hantera kontokontrollanter och delegerade konton",
177
"navAdmin": "Adminpanel",
178
-
"navAdminDesc": "Serverstatistik och administratörsoperationer"
179
},
180
"settings": {
181
"title": "Kontoinställningar",
···
817
"didPlcHint": "Portabel identitet som hanteras av PLC Directory",
818
"didWeb": "did:web",
819
"didWebHint": "Identitet som lagras på denna PDS (läs varningen nedan)",
820
"didWebBYOD": "did:web (BYOD)",
821
"didWebBYODHint": "Ta med din egen domän",
822
"didWebWarningTitle": "Viktigt: Förstå kompromisserna",
···
87
"didPlcHint": "Portabel identitet hanterad av PLC Directory",
88
"didWeb": "did:web",
89
"didWebHint": "Identitet lagrad på denna PDS (läs varningen nedan)",
90
+
"didWebDisabledHint": "Inte tillgänglig på denna PDS - använd did:plc eller ta med din egen did:web",
91
"didWebBYOD": "did:web (egen domän)",
92
"didWebBYODHint": "Använd din egen domän",
93
"didWebWarningTitle": "Viktigt: Förstå avvägningarna",
···
176
"navDelegation": "Delegering",
177
"navDelegationDesc": "Hantera kontokontrollanter och delegerade konton",
178
"navAdmin": "Adminpanel",
179
+
"navAdminDesc": "Serverstatistik och administratörsoperationer",
180
+
"navDidDocument": "DID-dokument",
181
+
"navDidDocumentDesc": "Hantera ditt DID-dokument och nycklar",
182
+
"migrated": "Flyttad",
183
+
"migratedTitle": "Konto flyttat",
184
+
"migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande här.",
185
+
"navMigrateAgain": "Flytta igen",
186
+
"navMigrateAgainDesc": "Flytta till en annan PDS och uppdatera ditt DID-dokument"
187
+
},
188
+
"didEditor": {
189
+
"title": "DID-dokumentredigerare",
190
+
"preview": "Nuvarande DID-dokument",
191
+
"verificationMethods": "Verifieringsmetoder (signeringsnycklar)",
192
+
"addKey": "Lägg till nyckel",
193
+
"removeKey": "Ta bort",
194
+
"keyId": "Nyckel-ID",
195
+
"keyIdPlaceholder": "#atproto",
196
+
"publicKey": "Publik nyckel (Multibase)",
197
+
"publicKeyPlaceholder": "zQ3sh...",
198
+
"alsoKnownAs": "Även känd som (användarnamn)",
199
+
"addHandle": "Lägg till användarnamn",
200
+
"handlePlaceholder": "at://handle.pds.com",
201
+
"serviceEndpoint": "Tjänstslutpunkt (nuvarande PDS)",
202
+
"save": "Spara ändringar",
203
+
"saving": "Sparar...",
204
+
"success": "DID-dokumentet har uppdaterats",
205
+
"helpTitle": "Vad är detta?",
206
+
"helpText": "När du flyttar till en annan PDS genererar den PDS nya signeringsnycklar. Uppdatera ditt DID-dokument här så att det pekar på dina nya nycklar och plats."
207
},
208
"settings": {
209
"title": "Kontoinställningar",
···
845
"didPlcHint": "Portabel identitet som hanteras av PLC Directory",
846
"didWeb": "did:web",
847
"didWebHint": "Identitet som lagras på denna PDS (läs varningen nedan)",
848
+
"didWebDisabledHint": "Inte tillgänglig på denna PDS - använd did:plc eller ta med din egen did:web",
849
"didWebBYOD": "did:web (BYOD)",
850
"didWebBYODHint": "Ta med din egen domän",
851
"didWebWarningTitle": "Viktigt: Förstå kompromisserna",
+30
-1
frontend/src/locales/zh.json
+30
-1
frontend/src/locales/zh.json
···
87
"didPlcHint": "由 PLC 目录管理的可迁移身份",
88
"didWeb": "did:web",
89
"didWebHint": "托管在此 PDS 上的身份(请阅读下方警告)",
90
"didWebBYOD": "did:web(自带域名)",
91
"didWebBYODHint": "使用您自己的域名",
92
"didWebWarningTitle": "重要提示:了解利弊",
···
175
"navDelegation": "账户委托",
176
"navDelegationDesc": "管理控制者和委托账户",
177
"navAdmin": "管理后台",
178
-
"navAdminDesc": "服务器统计和管理操作"
179
},
180
"settings": {
181
"title": "账户设置",
···
792
"didPlcHint": "由 PLC 目录管理的可迁移身份",
793
"didWeb": "did:web",
794
"didWebHint": "托管在此 PDS 上的身份(请阅读下方警告)",
795
"didWebBYOD": "did:web(自带域名)",
796
"didWebBYODHint": "使用您自己的域名",
797
"didWebWarningTitle": "重要:了解利弊",
···
87
"didPlcHint": "由 PLC 目录管理的可迁移身份",
88
"didWeb": "did:web",
89
"didWebHint": "托管在此 PDS 上的身份(请阅读下方警告)",
90
+
"didWebDisabledHint": "此 PDS 不可用 - 请使用 did:plc 或携带自己的 did:web",
91
"didWebBYOD": "did:web(自带域名)",
92
"didWebBYODHint": "使用您自己的域名",
93
"didWebWarningTitle": "重要提示:了解利弊",
···
176
"navDelegation": "账户委托",
177
"navDelegationDesc": "管理控制者和委托账户",
178
"navAdmin": "管理后台",
179
+
"navAdminDesc": "服务器统计和管理操作",
180
+
"navDidDocument": "DID 文档",
181
+
"navDidDocumentDesc": "管理您的 DID 文档和密钥",
182
+
"migrated": "已迁移",
183
+
"migratedTitle": "账户已迁移",
184
+
"migratedMessage": "您的账户已迁移到 {pds}。您的 DID 文档仍在此处托管。",
185
+
"navMigrateAgain": "再次迁移",
186
+
"navMigrateAgainDesc": "迁移到另一个 PDS 并更新您的 DID 文档"
187
+
},
188
+
"didEditor": {
189
+
"title": "DID 文档编辑器",
190
+
"preview": "当前 DID 文档",
191
+
"verificationMethods": "验证方法(签名密钥)",
192
+
"addKey": "添加密钥",
193
+
"removeKey": "删除",
194
+
"keyId": "密钥 ID",
195
+
"keyIdPlaceholder": "#atproto",
196
+
"publicKey": "公钥(Multibase)",
197
+
"publicKeyPlaceholder": "zQ3sh...",
198
+
"alsoKnownAs": "别名(用户名)",
199
+
"addHandle": "添加用户名",
200
+
"handlePlaceholder": "at://handle.pds.com",
201
+
"serviceEndpoint": "服务端点(当前 PDS)",
202
+
"save": "保存更改",
203
+
"saving": "保存中...",
204
+
"success": "DID 文档已更新",
205
+
"helpTitle": "这是什么?",
206
+
"helpText": "当您迁移到另一个 PDS 时,该 PDS 会生成新的签名密钥。在此处更新您的 DID 文档,使其指向您的新密钥和位置。"
207
},
208
"settings": {
209
"title": "账户设置",
···
820
"didPlcHint": "由 PLC 目录管理的可迁移身份",
821
"didWeb": "did:web",
822
"didWebHint": "托管在此 PDS 上的身份(请阅读下方警告)",
823
+
"didWebDisabledHint": "此 PDS 不可用 - 请使用 did:plc 或携带自己的 did:web",
824
"didWebBYOD": "did:web(自带域名)",
825
"didWebBYODHint": "使用您自己的域名",
826
"didWebWarningTitle": "重要:了解利弊",
+107
-43
frontend/src/routes/Dashboard.svelte
+107
-43
frontend/src/routes/Dashboard.svelte
···
99
</div>
100
</header>
101
102
-
{#if auth.session.status === 'deactivated' || auth.session.active === false}
103
<div class="deactivated-banner">
104
<strong>{$_('dashboard.deactivatedTitle')}</strong>
105
<p>{$_('dashboard.deactivatedMessage')}</p>
···
115
{#if auth.session.isAdmin}
116
<span class="badge admin">{$_('dashboard.admin')}</span>
117
{/if}
118
-
{#if auth.session.status === 'deactivated' || auth.session.active === false}
119
<span class="badge deactivated">{$_('dashboard.deactivated')}</span>
120
{/if}
121
</dd>
···
156
</section>
157
158
<nav class="nav-grid">
159
-
<a href="#/app-passwords" class="nav-card">
160
-
<h3>{$_('dashboard.navAppPasswords')}</h3>
161
-
<p>{$_('dashboard.navAppPasswordsDesc')}</p>
162
-
</a>
163
-
<a href="#/sessions" class="nav-card">
164
-
<h3>{$_('dashboard.navSessions')}</h3>
165
-
<p>{$_('dashboard.navSessionsDesc')}</p>
166
-
</a>
167
-
{#if inviteCodesEnabled && auth.session.isAdmin}
168
-
<a href="#/invite-codes" class="nav-card">
169
-
<h3>{$_('dashboard.navInviteCodes')}</h3>
170
-
<p>{$_('dashboard.navInviteCodesDesc')}</p>
171
</a>
172
-
{/if}
173
-
<a href="#/settings" class="nav-card">
174
-
<h3>{$_('dashboard.navSettings')}</h3>
175
-
<p>{$_('dashboard.navSettingsDesc')}</p>
176
-
</a>
177
-
<a href="#/security" class="nav-card">
178
-
<h3>{$_('dashboard.navSecurity')}</h3>
179
-
<p>{$_('dashboard.navSecurityDesc')}</p>
180
-
</a>
181
-
<a href="#/comms" class="nav-card">
182
-
<h3>{$_('dashboard.navComms')}</h3>
183
-
<p>{$_('dashboard.navCommsDesc')}</p>
184
-
</a>
185
-
<a href="#/repo" class="nav-card">
186
-
<h3>{$_('dashboard.navRepo')}</h3>
187
-
<p>{$_('dashboard.navRepoDesc')}</p>
188
-
</a>
189
-
<a href="#/controllers" class="nav-card">
190
-
<h3>{$_('dashboard.navDelegation')}</h3>
191
-
<p>{$_('dashboard.navDelegationDesc')}</p>
192
-
</a>
193
-
<a href="#/migrate" class="nav-card">
194
-
<h3>{$_('migration.navTitle')}</h3>
195
-
<p>{$_('migration.navDesc')}</p>
196
-
</a>
197
-
{#if auth.session.isAdmin}
198
-
<a href="#/admin" class="nav-card admin-card">
199
-
<h3>{$_('dashboard.navAdmin')}</h3>
200
-
<p>{$_('dashboard.navAdminDesc')}</p>
201
</a>
202
{/if}
203
</nav>
204
</div>
···
374
border: 1px solid var(--warning-border);
375
}
376
377
.nav-grid {
378
display: grid;
379
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
···
439
margin: var(--space-3) 0 0 0;
440
color: var(--warning-text);
441
font-size: var(--text-sm);
442
}
443
</style>
···
99
</div>
100
</header>
101
102
+
{#if auth.session.status === 'migrated'}
103
+
<div class="migrated-banner">
104
+
<strong>{$_('dashboard.migratedTitle')}</strong>
105
+
<p>{$_('dashboard.migratedMessage', { values: { pds: auth.session.migratedToPds || 'another PDS' } })}</p>
106
+
</div>
107
+
{:else if auth.session.status === 'deactivated' || auth.session.active === false}
108
<div class="deactivated-banner">
109
<strong>{$_('dashboard.deactivatedTitle')}</strong>
110
<p>{$_('dashboard.deactivatedMessage')}</p>
···
120
{#if auth.session.isAdmin}
121
<span class="badge admin">{$_('dashboard.admin')}</span>
122
{/if}
123
+
{#if auth.session.status === 'migrated'}
124
+
<span class="badge migrated">{$_('dashboard.migrated')}</span>
125
+
{:else if auth.session.status === 'deactivated' || auth.session.active === false}
126
<span class="badge deactivated">{$_('dashboard.deactivated')}</span>
127
{/if}
128
</dd>
···
163
</section>
164
165
<nav class="nav-grid">
166
+
{#if auth.session.status === 'migrated'}
167
+
<a href="#/did-document" class="nav-card migrated-card">
168
+
<h3>{$_('dashboard.navDidDocument')}</h3>
169
+
<p>{$_('dashboard.navDidDocumentDesc')}</p>
170
+
</a>
171
+
<a href="#/sessions" class="nav-card">
172
+
<h3>{$_('dashboard.navSessions')}</h3>
173
+
<p>{$_('dashboard.navSessionsDesc')}</p>
174
+
</a>
175
+
<a href="#/security" class="nav-card">
176
+
<h3>{$_('dashboard.navSecurity')}</h3>
177
+
<p>{$_('dashboard.navSecurityDesc')}</p>
178
+
</a>
179
+
<a href="#/migrate" class="nav-card">
180
+
<h3>{$_('dashboard.navMigrateAgain')}</h3>
181
+
<p>{$_('dashboard.navMigrateAgainDesc')}</p>
182
</a>
183
+
{:else}
184
+
<a href="#/app-passwords" class="nav-card">
185
+
<h3>{$_('dashboard.navAppPasswords')}</h3>
186
+
<p>{$_('dashboard.navAppPasswordsDesc')}</p>
187
</a>
188
+
<a href="#/sessions" class="nav-card">
189
+
<h3>{$_('dashboard.navSessions')}</h3>
190
+
<p>{$_('dashboard.navSessionsDesc')}</p>
191
+
</a>
192
+
{#if inviteCodesEnabled && auth.session.isAdmin}
193
+
<a href="#/invite-codes" class="nav-card">
194
+
<h3>{$_('dashboard.navInviteCodes')}</h3>
195
+
<p>{$_('dashboard.navInviteCodesDesc')}</p>
196
+
</a>
197
+
{/if}
198
+
<a href="#/settings" class="nav-card">
199
+
<h3>{$_('dashboard.navSettings')}</h3>
200
+
<p>{$_('dashboard.navSettingsDesc')}</p>
201
+
</a>
202
+
<a href="#/security" class="nav-card">
203
+
<h3>{$_('dashboard.navSecurity')}</h3>
204
+
<p>{$_('dashboard.navSecurityDesc')}</p>
205
+
</a>
206
+
<a href="#/comms" class="nav-card">
207
+
<h3>{$_('dashboard.navComms')}</h3>
208
+
<p>{$_('dashboard.navCommsDesc')}</p>
209
+
</a>
210
+
<a href="#/repo" class="nav-card">
211
+
<h3>{$_('dashboard.navRepo')}</h3>
212
+
<p>{$_('dashboard.navRepoDesc')}</p>
213
+
</a>
214
+
<a href="#/controllers" class="nav-card">
215
+
<h3>{$_('dashboard.navDelegation')}</h3>
216
+
<p>{$_('dashboard.navDelegationDesc')}</p>
217
+
</a>
218
+
<a href="#/migrate" class="nav-card">
219
+
<h3>{$_('migration.navTitle')}</h3>
220
+
<p>{$_('migration.navDesc')}</p>
221
+
</a>
222
+
{#if auth.session.isAdmin}
223
+
<a href="#/admin" class="nav-card admin-card">
224
+
<h3>{$_('dashboard.navAdmin')}</h3>
225
+
<p>{$_('dashboard.navAdminDesc')}</p>
226
+
</a>
227
+
{/if}
228
{/if}
229
</nav>
230
</div>
···
400
border: 1px solid var(--warning-border);
401
}
402
403
+
.badge.migrated {
404
+
background: var(--info-bg, #e0f2fe);
405
+
color: var(--info-text, #0369a1);
406
+
border: 1px solid var(--info-border, #7dd3fc);
407
+
}
408
+
409
.nav-grid {
410
display: grid;
411
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
···
471
margin: var(--space-3) 0 0 0;
472
color: var(--warning-text);
473
font-size: var(--text-sm);
474
+
}
475
+
476
+
.migrated-banner {
477
+
background: var(--info-bg, #e0f2fe);
478
+
border: 1px solid var(--info-border, #7dd3fc);
479
+
border-radius: var(--radius-xl);
480
+
padding: var(--space-5) var(--space-6);
481
+
margin-bottom: var(--space-7);
482
+
}
483
+
484
+
.migrated-banner strong {
485
+
color: var(--info-text, #0369a1);
486
+
font-size: var(--text-base);
487
+
}
488
+
489
+
.migrated-banner p {
490
+
margin: var(--space-3) 0 0 0;
491
+
color: var(--info-text, #0369a1);
492
+
font-size: var(--text-sm);
493
+
}
494
+
495
+
.nav-card.migrated-card {
496
+
border-color: var(--info-border, #7dd3fc);
497
+
background: linear-gradient(135deg, var(--bg-card) 0%, var(--info-bg, #e0f2fe) 100%);
498
+
}
499
+
500
+
.nav-card.migrated-card:hover {
501
+
box-shadow: 0 2px 12px var(--info-bg, #e0f2fe);
502
+
}
503
+
504
+
.nav-card.migrated-card h3 {
505
+
color: var(--info-text, #0369a1);
506
}
507
</style>
+457
frontend/src/routes/DidDocumentEditor.svelte
+457
frontend/src/routes/DidDocumentEditor.svelte
···
···
1
+
<script lang="ts">
2
+
import { onMount } from 'svelte'
3
+
import { getAuthState } from '../lib/auth.svelte'
4
+
import { navigate } from '../lib/router.svelte'
5
+
import { api, ApiError, type VerificationMethod, type DidDocument } from '../lib/api'
6
+
import { _ } from '../lib/i18n'
7
+
8
+
const auth = getAuthState()
9
+
10
+
let loading = $state(true)
11
+
let saving = $state(false)
12
+
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
13
+
let didDocument = $state<DidDocument | null>(null)
14
+
let verificationMethods = $state<VerificationMethod[]>([])
15
+
let alsoKnownAs = $state<string[]>([])
16
+
let serviceEndpoint = $state('')
17
+
let newKeyId = $state('#atproto')
18
+
let newKeyPublic = $state('')
19
+
let newHandle = $state('')
20
+
21
+
$effect(() => {
22
+
if (!auth.loading && !auth.session) {
23
+
navigate('/login')
24
+
}
25
+
})
26
+
27
+
onMount(async () => {
28
+
if (!auth.session) return
29
+
try {
30
+
didDocument = await api.getDidDocument(auth.session.accessJwt)
31
+
verificationMethods = didDocument.verificationMethod.map(vm => ({
32
+
id: vm.id.replace(didDocument!.id, ''),
33
+
type: vm.type,
34
+
publicKeyMultibase: vm.publicKeyMultibase
35
+
}))
36
+
alsoKnownAs = [...didDocument.alsoKnownAs]
37
+
const pdsService = didDocument.service.find(s => s.id === '#atproto_pds')
38
+
serviceEndpoint = pdsService?.serviceEndpoint || ''
39
+
} catch (e) {
40
+
showMessage('error', e instanceof ApiError ? e.message : $_('didEditor.loadFailed'))
41
+
} finally {
42
+
loading = false
43
+
}
44
+
})
45
+
46
+
function showMessage(type: 'success' | 'error', text: string) {
47
+
message = { type, text }
48
+
setTimeout(() => {
49
+
if (message?.text === text) message = null
50
+
}, 5000)
51
+
}
52
+
53
+
function addVerificationMethod() {
54
+
if (!newKeyId || !newKeyPublic) return
55
+
if (!newKeyPublic.startsWith('z')) {
56
+
showMessage('error', $_('didEditor.invalidMultibase'))
57
+
return
58
+
}
59
+
verificationMethods = [...verificationMethods, {
60
+
id: newKeyId.startsWith('#') ? newKeyId : `#${newKeyId}`,
61
+
type: 'Multikey',
62
+
publicKeyMultibase: newKeyPublic
63
+
}]
64
+
newKeyId = '#atproto'
65
+
newKeyPublic = ''
66
+
}
67
+
68
+
function removeVerificationMethod(index: number) {
69
+
verificationMethods = verificationMethods.filter((_, i) => i !== index)
70
+
}
71
+
72
+
function addHandle() {
73
+
if (!newHandle) return
74
+
if (!newHandle.startsWith('at://')) {
75
+
showMessage('error', $_('didEditor.invalidHandle'))
76
+
return
77
+
}
78
+
alsoKnownAs = [...alsoKnownAs, newHandle]
79
+
newHandle = ''
80
+
}
81
+
82
+
function removeHandle(index: number) {
83
+
alsoKnownAs = alsoKnownAs.filter((_, i) => i !== index)
84
+
}
85
+
86
+
async function handleSave() {
87
+
if (!auth.session) return
88
+
saving = true
89
+
message = null
90
+
try {
91
+
await api.updateDidDocument(auth.session.accessJwt, {
92
+
verificationMethods: verificationMethods.length > 0 ? verificationMethods : undefined,
93
+
alsoKnownAs: alsoKnownAs.length > 0 ? alsoKnownAs : undefined,
94
+
serviceEndpoint: serviceEndpoint || undefined
95
+
})
96
+
showMessage('success', $_('didEditor.success'))
97
+
didDocument = await api.getDidDocument(auth.session.accessJwt)
98
+
} catch (e) {
99
+
showMessage('error', e instanceof ApiError ? e.message : $_('didEditor.saveFailed'))
100
+
} finally {
101
+
saving = false
102
+
}
103
+
}
104
+
</script>
105
+
106
+
<div class="page">
107
+
<header>
108
+
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
109
+
<h1>{$_('didEditor.title')}</h1>
110
+
</header>
111
+
112
+
{#if message}
113
+
<div class="message {message.type}">{message.text}</div>
114
+
{/if}
115
+
116
+
{#if loading}
117
+
<div class="loading">{$_('common.loading')}</div>
118
+
{:else}
119
+
<div class="help-section">
120
+
<h3>{$_('didEditor.helpTitle')}</h3>
121
+
<p>{$_('didEditor.helpText')}</p>
122
+
</div>
123
+
124
+
<section>
125
+
<h2>{$_('didEditor.preview')}</h2>
126
+
<pre class="did-preview">{JSON.stringify(didDocument, null, 2)}</pre>
127
+
</section>
128
+
129
+
<section>
130
+
<h2>{$_('didEditor.verificationMethods')}</h2>
131
+
<p class="description">{$_('didEditor.verificationMethodsDesc')}</p>
132
+
133
+
{#if verificationMethods.length > 0}
134
+
<ul class="key-list">
135
+
{#each verificationMethods as method, index}
136
+
<li class="key-item">
137
+
<div class="key-info">
138
+
<span class="key-id">{method.id}</span>
139
+
<span class="key-type">{method.type}</span>
140
+
<code class="key-value">{method.publicKeyMultibase}</code>
141
+
</div>
142
+
<button type="button" class="danger-link" onclick={() => removeVerificationMethod(index)}>
143
+
{$_('didEditor.removeKey')}
144
+
</button>
145
+
</li>
146
+
{/each}
147
+
</ul>
148
+
{:else}
149
+
<p class="empty-state">{$_('didEditor.noKeys')}</p>
150
+
{/if}
151
+
152
+
<div class="add-form">
153
+
<h4>{$_('didEditor.addKey')}</h4>
154
+
<div class="field-row">
155
+
<div class="field small">
156
+
<label for="key-id">{$_('didEditor.keyId')}</label>
157
+
<input
158
+
id="key-id"
159
+
type="text"
160
+
bind:value={newKeyId}
161
+
placeholder={$_('didEditor.keyIdPlaceholder')}
162
+
/>
163
+
</div>
164
+
<div class="field large">
165
+
<label for="key-public">{$_('didEditor.publicKey')}</label>
166
+
<input
167
+
id="key-public"
168
+
type="text"
169
+
bind:value={newKeyPublic}
170
+
placeholder={$_('didEditor.publicKeyPlaceholder')}
171
+
/>
172
+
</div>
173
+
<button type="button" class="add-btn" onclick={addVerificationMethod} disabled={!newKeyId || !newKeyPublic}>
174
+
{$_('didEditor.addKey')}
175
+
</button>
176
+
</div>
177
+
</div>
178
+
</section>
179
+
180
+
<section>
181
+
<h2>{$_('didEditor.alsoKnownAs')}</h2>
182
+
<p class="description">{$_('didEditor.alsoKnownAsDesc')}</p>
183
+
184
+
{#if alsoKnownAs.length > 0}
185
+
<ul class="handle-list">
186
+
{#each alsoKnownAs as handle, index}
187
+
<li class="handle-item">
188
+
<span>{handle}</span>
189
+
<button type="button" class="danger-link" onclick={() => removeHandle(index)}>
190
+
{$_('didEditor.removeHandle')}
191
+
</button>
192
+
</li>
193
+
{/each}
194
+
</ul>
195
+
{:else}
196
+
<p class="empty-state">{$_('didEditor.noHandles')}</p>
197
+
{/if}
198
+
199
+
<div class="add-form">
200
+
<div class="field-row">
201
+
<div class="field large">
202
+
<label for="new-handle">{$_('didEditor.handle')}</label>
203
+
<input
204
+
id="new-handle"
205
+
type="text"
206
+
bind:value={newHandle}
207
+
placeholder={$_('didEditor.handlePlaceholder')}
208
+
/>
209
+
</div>
210
+
<button type="button" class="add-btn" onclick={addHandle} disabled={!newHandle}>
211
+
{$_('didEditor.addHandle')}
212
+
</button>
213
+
</div>
214
+
</div>
215
+
</section>
216
+
217
+
<section>
218
+
<h2>{$_('didEditor.serviceEndpoint')}</h2>
219
+
<p class="description">{$_('didEditor.serviceEndpointDesc')}</p>
220
+
<div class="field">
221
+
<label for="service-endpoint">{$_('didEditor.currentPds')}</label>
222
+
<input
223
+
id="service-endpoint"
224
+
type="url"
225
+
bind:value={serviceEndpoint}
226
+
placeholder="https://pds.example.com"
227
+
/>
228
+
</div>
229
+
</section>
230
+
231
+
<div class="actions">
232
+
<button onclick={handleSave} disabled={saving}>
233
+
{saving ? $_('didEditor.saving') : $_('didEditor.save')}
234
+
</button>
235
+
</div>
236
+
{/if}
237
+
</div>
238
+
239
+
<style>
240
+
.page {
241
+
max-width: var(--width-lg);
242
+
margin: 0 auto;
243
+
padding: var(--space-7);
244
+
}
245
+
246
+
header {
247
+
margin-bottom: var(--space-7);
248
+
}
249
+
250
+
.back {
251
+
color: var(--text-secondary);
252
+
text-decoration: none;
253
+
font-size: var(--text-sm);
254
+
}
255
+
256
+
.back:hover {
257
+
color: var(--accent);
258
+
}
259
+
260
+
h1 {
261
+
margin: var(--space-2) 0 0 0;
262
+
}
263
+
264
+
.help-section {
265
+
background: var(--info-bg, #e0f2fe);
266
+
border: 1px solid var(--info-border, #7dd3fc);
267
+
border-radius: var(--radius-xl);
268
+
padding: var(--space-5) var(--space-6);
269
+
margin-bottom: var(--space-6);
270
+
}
271
+
272
+
.help-section h3 {
273
+
margin: 0 0 var(--space-2) 0;
274
+
color: var(--info-text, #0369a1);
275
+
font-size: var(--text-base);
276
+
}
277
+
278
+
.help-section p {
279
+
margin: 0;
280
+
color: var(--info-text, #0369a1);
281
+
font-size: var(--text-sm);
282
+
}
283
+
284
+
section {
285
+
padding: var(--space-6);
286
+
background: var(--bg-secondary);
287
+
border-radius: var(--radius-xl);
288
+
margin-bottom: var(--space-6);
289
+
}
290
+
291
+
section h2 {
292
+
margin: 0 0 var(--space-2) 0;
293
+
font-size: var(--text-lg);
294
+
}
295
+
296
+
.description {
297
+
color: var(--text-secondary);
298
+
font-size: var(--text-sm);
299
+
margin-bottom: var(--space-4);
300
+
}
301
+
302
+
.did-preview {
303
+
background: var(--bg-input);
304
+
padding: var(--space-4);
305
+
border-radius: var(--radius-md);
306
+
font-size: var(--text-xs);
307
+
overflow-x: auto;
308
+
white-space: pre-wrap;
309
+
word-break: break-all;
310
+
max-height: 300px;
311
+
overflow-y: auto;
312
+
}
313
+
314
+
.key-list, .handle-list {
315
+
list-style: none;
316
+
padding: 0;
317
+
margin: 0 0 var(--space-4) 0;
318
+
}
319
+
320
+
.key-item, .handle-item {
321
+
display: flex;
322
+
justify-content: space-between;
323
+
align-items: flex-start;
324
+
padding: var(--space-3) var(--space-4);
325
+
background: var(--bg-card);
326
+
border: 1px solid var(--border-color);
327
+
border-radius: var(--radius-md);
328
+
margin-bottom: var(--space-2);
329
+
gap: var(--space-4);
330
+
}
331
+
332
+
.key-info {
333
+
display: flex;
334
+
flex-direction: column;
335
+
gap: var(--space-1);
336
+
flex: 1;
337
+
min-width: 0;
338
+
}
339
+
340
+
.key-id {
341
+
font-weight: var(--font-medium);
342
+
font-size: var(--text-sm);
343
+
}
344
+
345
+
.key-type {
346
+
color: var(--text-secondary);
347
+
font-size: var(--text-xs);
348
+
}
349
+
350
+
.key-value {
351
+
font-size: var(--text-xs);
352
+
background: var(--bg-input);
353
+
padding: var(--space-1) var(--space-2);
354
+
border-radius: var(--radius-sm);
355
+
word-break: break-all;
356
+
}
357
+
358
+
.handle-item span {
359
+
font-family: ui-monospace, monospace;
360
+
font-size: var(--text-sm);
361
+
}
362
+
363
+
.danger-link {
364
+
background: none;
365
+
border: none;
366
+
color: var(--error-text);
367
+
cursor: pointer;
368
+
font-size: var(--text-xs);
369
+
padding: var(--space-1) var(--space-2);
370
+
white-space: nowrap;
371
+
}
372
+
373
+
.danger-link:hover {
374
+
text-decoration: underline;
375
+
}
376
+
377
+
.empty-state {
378
+
color: var(--text-muted);
379
+
font-size: var(--text-sm);
380
+
font-style: italic;
381
+
padding: var(--space-4);
382
+
text-align: center;
383
+
background: var(--bg-card);
384
+
border-radius: var(--radius-md);
385
+
margin-bottom: var(--space-4);
386
+
}
387
+
388
+
.add-form {
389
+
background: var(--bg-card);
390
+
border: 1px solid var(--border-color);
391
+
border-radius: var(--radius-lg);
392
+
padding: var(--space-4);
393
+
}
394
+
395
+
.add-form h4 {
396
+
margin: 0 0 var(--space-3) 0;
397
+
font-size: var(--text-sm);
398
+
color: var(--text-secondary);
399
+
}
400
+
401
+
.field-row {
402
+
display: flex;
403
+
gap: var(--space-3);
404
+
align-items: flex-end;
405
+
}
406
+
407
+
.field {
408
+
display: flex;
409
+
flex-direction: column;
410
+
gap: var(--space-1);
411
+
}
412
+
413
+
.field.small {
414
+
flex: 0 0 120px;
415
+
}
416
+
417
+
.field.large {
418
+
flex: 1;
419
+
}
420
+
421
+
.field label {
422
+
font-size: var(--text-xs);
423
+
color: var(--text-secondary);
424
+
}
425
+
426
+
.add-btn {
427
+
white-space: nowrap;
428
+
}
429
+
430
+
.actions {
431
+
display: flex;
432
+
gap: var(--space-3);
433
+
justify-content: flex-end;
434
+
margin-top: var(--space-6);
435
+
}
436
+
437
+
.loading {
438
+
text-align: center;
439
+
padding: var(--space-9);
440
+
color: var(--text-secondary);
441
+
}
442
+
443
+
@media (max-width: 600px) {
444
+
.field-row {
445
+
flex-direction: column;
446
+
align-items: stretch;
447
+
}
448
+
449
+
.field.small, .field.large {
450
+
flex: none;
451
+
}
452
+
453
+
.add-btn {
454
+
width: 100%;
455
+
}
456
+
}
457
+
</style>
+17
-3
frontend/src/routes/Register.svelte
+17
-3
frontend/src/routes/Register.svelte
···
13
availableUserDomains: string[]
14
inviteCodeRequired: boolean
15
availableCommsChannels?: string[]
16
} | null>(null)
17
let loadingServerInfo = $state(true)
18
let serverInfoLoaded = false
···
237
</span>
238
</label>
239
240
-
<label class="radio-label">
241
-
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
242
<span class="radio-content">
243
<strong>{$_('register.didWeb')}</strong>
244
-
<span class="radio-hint">{$_('register.didWebHint')}</span>
245
</span>
246
</label>
247
···
548
.radio-hint {
549
font-size: var(--text-xs);
550
color: var(--text-secondary);
551
}
552
553
.warning-box {
···
13
availableUserDomains: string[]
14
inviteCodeRequired: boolean
15
availableCommsChannels?: string[]
16
+
selfHostedDidWebEnabled?: boolean
17
} | null>(null)
18
let loadingServerInfo = $state(true)
19
let serverInfoLoaded = false
···
238
</span>
239
</label>
240
241
+
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
242
+
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
243
<span class="radio-content">
244
<strong>{$_('register.didWeb')}</strong>
245
+
{#if serverInfo?.selfHostedDidWebEnabled === false}
246
+
<span class="radio-hint disabled-hint">{$_('register.didWebDisabledHint')}</span>
247
+
{:else}
248
+
<span class="radio-hint">{$_('register.didWebHint')}</span>
249
+
{/if}
250
</span>
251
</label>
252
···
553
.radio-hint {
554
font-size: var(--text-xs);
555
color: var(--text-secondary);
556
+
}
557
+
558
+
.radio-label.disabled {
559
+
opacity: 0.5;
560
+
cursor: not-allowed;
561
+
}
562
+
563
+
.radio-hint.disabled-hint {
564
+
color: var(--warning-text);
565
}
566
567
.warning-box {
+17
-3
frontend/src/routes/RegisterPasskey.svelte
+17
-3
frontend/src/routes/RegisterPasskey.svelte
···
14
availableUserDomains: string[]
15
inviteCodeRequired: boolean
16
availableCommsChannels?: string[]
17
} | null>(null)
18
let loadingServerInfo = $state(true)
19
let serverInfoLoaded = false
···
350
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
351
</span>
352
</label>
353
-
<label class="radio-label">
354
-
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
355
<span class="radio-content">
356
<strong>{$_('registerPasskey.didWeb')}</strong>
357
-
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
358
</span>
359
</label>
360
<label class="radio-label">
···
580
.radio-hint {
581
font-size: var(--text-xs);
582
color: var(--text-secondary);
583
}
584
585
.warning-box {
···
14
availableUserDomains: string[]
15
inviteCodeRequired: boolean
16
availableCommsChannels?: string[]
17
+
selfHostedDidWebEnabled?: boolean
18
} | null>(null)
19
let loadingServerInfo = $state(true)
20
let serverInfoLoaded = false
···
351
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
352
</span>
353
</label>
354
+
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
355
+
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
356
<span class="radio-content">
357
<strong>{$_('registerPasskey.didWeb')}</strong>
358
+
{#if serverInfo?.selfHostedDidWebEnabled === false}
359
+
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
360
+
{:else}
361
+
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
362
+
{/if}
363
</span>
364
</label>
365
<label class="radio-label">
···
585
.radio-hint {
586
font-size: var(--text-xs);
587
color: var(--text-secondary);
588
+
}
589
+
590
+
.radio-label.disabled {
591
+
opacity: 0.5;
592
+
cursor: not-allowed;
593
+
}
594
+
595
+
.radio-hint.disabled-hint {
596
+
color: var(--warning-text);
597
}
598
599
.warning-box {
+1
frontend/src/tests/mocks.ts
+1
frontend/src/tests/mocks.ts
+7
migrations/20251244_did_web_overrides.sql
+7
migrations/20251244_did_web_overrides.sql
···
···
1
+
CREATE TABLE IF NOT EXISTS did_web_overrides (
2
+
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
3
+
verification_methods JSONB NOT NULL DEFAULT '[]',
4
+
also_known_as TEXT[] NOT NULL DEFAULT '{}',
5
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
6
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
7
+
);
+10
src/api/identity/account.rs
+10
src/api/identity/account.rs
···
355
let did_type = input.did_type.as_deref().unwrap_or("plc");
356
let did = match did_type {
357
"web" => {
358
let subdomain_host = format!("{}.{}", input.handle, hostname);
359
let encoded_subdomain = subdomain_host.replace(':', "%3A");
360
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
···
355
let did_type = input.did_type.as_deref().unwrap_or("plc");
356
let did = match did_type {
357
"web" => {
358
+
if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
359
+
return (
360
+
StatusCode::BAD_REQUEST,
361
+
Json(json!({
362
+
"error": "SelfHostedDidWebDisabled",
363
+
"message": "This PDS does not offer self-hosted did:web identities. Please use did:plc or bring your own did:web."
364
+
})),
365
+
)
366
+
.into_response();
367
+
}
368
let subdomain_host = format!("{}.{}", input.handle, hostname);
369
let encoded_subdomain = subdomain_host.replace(':', "%3A");
370
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
+130
-5
src/api/identity/did.rs
+130
-5
src/api/identity/did.rs
···
11
use k256::SecretKey;
12
use k256::elliptic_curve::sec1::ToEncodedPoint;
13
use reqwest;
14
-
use serde::Deserialize;
15
use serde_json::json;
16
use tracing::{error, warn};
17
18
#[derive(Deserialize)]
19
pub struct ResolveHandleParams {
···
170
)
171
.into_response();
172
}
173
let key_row = sqlx::query!(
174
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
175
user_id
···
206
.into_response();
207
}
208
};
209
-
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
210
Json(json!({
211
"@context": [
212
"https://www.w3.org/ns/did/v1",
···
214
"https://w3id.org/security/suites/secp256k1-2019/v1"
215
],
216
"id": did,
217
-
"alsoKnownAs": [format!("at://{}", handle)],
218
"verificationMethod": [{
219
"id": format!("{}#atproto", did),
220
"type": "Multikey",
···
272
)
273
.into_response();
274
}
275
let key_row = sqlx::query!(
276
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
277
user_id
···
308
.into_response();
309
}
310
};
311
-
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
312
Json(json!({
313
"@context": [
314
"https://www.w3.org/ns/did/v1",
···
316
"https://w3id.org/security/suites/secp256k1-2019/v1"
317
],
318
"id": did,
319
-
"alsoKnownAs": [format!("at://{}", handle)],
320
"verificationMethod": [{
321
"id": format!("{}#atproto", did),
322
"type": "Multikey",
···
11
use k256::SecretKey;
12
use k256::elliptic_curve::sec1::ToEncodedPoint;
13
use reqwest;
14
+
use serde::{Deserialize, Serialize};
15
use serde_json::json;
16
use tracing::{error, warn};
17
+
18
+
#[derive(Debug, Clone, Serialize, Deserialize)]
19
+
#[serde(rename_all = "camelCase")]
20
+
pub struct DidWebVerificationMethod {
21
+
pub id: String,
22
+
#[serde(rename = "type")]
23
+
pub method_type: String,
24
+
pub public_key_multibase: String,
25
+
}
26
27
#[derive(Deserialize)]
28
pub struct ResolveHandleParams {
···
179
)
180
.into_response();
181
}
182
+
183
+
let overrides = sqlx::query!(
184
+
"SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
185
+
user_id
186
+
)
187
+
.fetch_optional(&state.db)
188
+
.await
189
+
.ok()
190
+
.flatten();
191
+
192
+
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
193
+
194
+
if let Some(ref ovr) = overrides {
195
+
if let Ok(parsed) =
196
+
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
197
+
{
198
+
if !parsed.is_empty() {
199
+
let also_known_as = if !ovr.also_known_as.is_empty() {
200
+
ovr.also_known_as.clone()
201
+
} else {
202
+
vec![format!("at://{}", full_handle)]
203
+
};
204
+
205
+
return Json(json!({
206
+
"@context": [
207
+
"https://www.w3.org/ns/did/v1",
208
+
"https://w3id.org/security/multikey/v1",
209
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
210
+
],
211
+
"id": did,
212
+
"alsoKnownAs": also_known_as,
213
+
"verificationMethod": parsed.iter().map(|m| json!({
214
+
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
215
+
"type": m.method_type,
216
+
"controller": did,
217
+
"publicKeyMultibase": m.public_key_multibase
218
+
})).collect::<Vec<_>>(),
219
+
"service": [{
220
+
"id": "#atproto_pds",
221
+
"type": "AtprotoPersonalDataServer",
222
+
"serviceEndpoint": service_endpoint
223
+
}]
224
+
}))
225
+
.into_response();
226
+
}
227
+
}
228
+
}
229
+
230
let key_row = sqlx::query!(
231
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
232
user_id
···
263
.into_response();
264
}
265
};
266
+
267
+
let also_known_as = if let Some(ref ovr) = overrides {
268
+
if !ovr.also_known_as.is_empty() {
269
+
ovr.also_known_as.clone()
270
+
} else {
271
+
vec![format!("at://{}", full_handle)]
272
+
}
273
+
} else {
274
+
vec![format!("at://{}", full_handle)]
275
+
};
276
+
277
Json(json!({
278
"@context": [
279
"https://www.w3.org/ns/did/v1",
···
281
"https://w3id.org/security/suites/secp256k1-2019/v1"
282
],
283
"id": did,
284
+
"alsoKnownAs": also_known_as,
285
"verificationMethod": [{
286
"id": format!("{}#atproto", did),
287
"type": "Multikey",
···
339
)
340
.into_response();
341
}
342
+
343
+
let overrides = sqlx::query!(
344
+
"SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
345
+
user_id
346
+
)
347
+
.fetch_optional(&state.db)
348
+
.await
349
+
.ok()
350
+
.flatten();
351
+
352
+
let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
353
+
354
+
if let Some(ref ovr) = overrides {
355
+
if let Ok(parsed) =
356
+
serde_json::from_value::<Vec<DidWebVerificationMethod>>(ovr.verification_methods.clone())
357
+
{
358
+
if !parsed.is_empty() {
359
+
let also_known_as = if !ovr.also_known_as.is_empty() {
360
+
ovr.also_known_as.clone()
361
+
} else {
362
+
vec![format!("at://{}", full_handle)]
363
+
};
364
+
365
+
return Json(json!({
366
+
"@context": [
367
+
"https://www.w3.org/ns/did/v1",
368
+
"https://w3id.org/security/multikey/v1",
369
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
370
+
],
371
+
"id": did,
372
+
"alsoKnownAs": also_known_as,
373
+
"verificationMethod": parsed.iter().map(|m| json!({
374
+
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
375
+
"type": m.method_type,
376
+
"controller": did,
377
+
"publicKeyMultibase": m.public_key_multibase
378
+
})).collect::<Vec<_>>(),
379
+
"service": [{
380
+
"id": "#atproto_pds",
381
+
"type": "AtprotoPersonalDataServer",
382
+
"serviceEndpoint": service_endpoint
383
+
}]
384
+
}))
385
+
.into_response();
386
+
}
387
+
}
388
+
}
389
+
390
let key_row = sqlx::query!(
391
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
392
user_id
···
423
.into_response();
424
}
425
};
426
+
427
+
let also_known_as = if let Some(ref ovr) = overrides {
428
+
if !ovr.also_known_as.is_empty() {
429
+
ovr.also_known_as.clone()
430
+
} else {
431
+
vec![format!("at://{}", full_handle)]
432
+
}
433
+
} else {
434
+
vec![format!("at://{}", full_handle)]
435
+
};
436
+
437
Json(json!({
438
"@context": [
439
"https://www.w3.org/ns/did/v1",
···
441
"https://w3id.org/security/suites/secp256k1-2019/v1"
442
],
443
"id": did,
444
+
"alsoKnownAs": also_known_as,
445
"verificationMethod": [{
446
"id": format!("{}#atproto", did),
447
"type": "Multikey",
+14
src/api/repo/blob.rs
+14
src/api/repo/blob.rs
···
92
}
93
};
94
95
+
if crate::util::is_account_migrated(&state.db, &did)
96
+
.await
97
+
.unwrap_or(false)
98
+
{
99
+
return (
100
+
StatusCode::FORBIDDEN,
101
+
Json(json!({
102
+
"error": "AccountMigrated",
103
+
"message": "Account has been migrated to another PDS. Blob operations are not allowed."
104
+
})),
105
+
)
106
+
.into_response();
107
+
}
108
+
109
let max_size = get_max_blob_size();
110
111
if body.len() > max_size {
+13
src/api/repo/record/batch.rs
+13
src/api/repo/record/batch.rs
···
129
)
130
.into_response();
131
}
132
+
if crate::util::is_account_migrated(&state.db, &did)
133
+
.await
134
+
.unwrap_or(false)
135
+
{
136
+
return (
137
+
StatusCode::FORBIDDEN,
138
+
Json(json!({
139
+
"error": "AccountMigrated",
140
+
"message": "Account has been migrated to another PDS. Repo operations are not allowed."
141
+
})),
142
+
)
143
+
.into_response();
144
+
}
145
let is_verified = has_verified_comms_channel(&state.db, &did)
146
.await
147
.unwrap_or(false);
+14
src/api/repo/record/delete.rs
+14
src/api/repo/record/delete.rs
···
57
return e;
58
}
59
60
+
if crate::util::is_account_migrated(&state.db, &auth.did)
61
+
.await
62
+
.unwrap_or(false)
63
+
{
64
+
return (
65
+
StatusCode::BAD_REQUEST,
66
+
Json(json!({
67
+
"error": "AccountMigrated",
68
+
"message": "Account has been migrated. Repo operations are not allowed."
69
+
})),
70
+
)
71
+
.into_response();
72
+
}
73
+
74
let did = auth.did;
75
let user_id = auth.user_id;
76
let current_root_cid = auth.current_root_cid;
+13
src/api/repo/record/write.rs
+13
src/api/repo/record/write.rs
···
102
)
103
.into_response());
104
}
105
+
if crate::util::is_account_migrated(&state.db, &auth_user.did)
106
+
.await
107
+
.unwrap_or(false)
108
+
{
109
+
return Err((
110
+
StatusCode::FORBIDDEN,
111
+
Json(json!({
112
+
"error": "AccountMigrated",
113
+
"message": "Account has been migrated to another PDS. Repo operations are not allowed."
114
+
})),
115
+
)
116
+
.into_response());
117
+
}
118
let is_verified = has_verified_comms_channel(&state.db, &auth_user.did)
119
.await
120
.unwrap_or(false);
+47
-15
src/api/server/account_status.rs
+47
-15
src/api/server/account_status.rs
···
568
#[serde(rename_all = "camelCase")]
569
pub struct DeactivateAccountInput {
570
pub delete_after: Option<String>,
571
}
572
573
pub async fn deactivate_account(
···
617
.map(|dt| dt.with_timezone(&chrono::Utc));
618
619
let did = auth_user.did;
620
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
621
.fetch_optional(&state.db)
622
.await
623
.ok()
624
.flatten();
625
-
let result = sqlx::query!(
626
-
"UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
627
-
did,
628
-
delete_after
629
-
)
630
-
.execute(&state.db)
631
-
.await;
632
match result {
633
Ok(_) => {
634
if let Some(ref h) = handle {
635
let _ = state.cache.delete(&format!("handle:{}", h)).await;
636
}
637
-
if let Err(e) = crate::api::repo::record::sequence_account_event(
638
-
&state,
639
-
&did,
640
-
false,
641
-
Some("deactivated"),
642
-
)
643
-
.await
644
{
645
-
warn!("Failed to sequence account deactivation event: {}", e);
646
}
647
(StatusCode::OK, Json(json!({}))).into_response()
648
}
···
568
#[serde(rename_all = "camelCase")]
569
pub struct DeactivateAccountInput {
570
pub delete_after: Option<String>,
571
+
pub migrating_to: Option<String>,
572
}
573
574
pub async fn deactivate_account(
···
618
.map(|dt| dt.with_timezone(&chrono::Utc));
619
620
let did = auth_user.did;
621
+
622
+
let migrating_to = if let Some(ref url) = input.migrating_to {
623
+
let url = url.trim().trim_end_matches('/');
624
+
if url.is_empty() || !did.starts_with("did:web:") {
625
+
None
626
+
} else {
627
+
if !url.starts_with("https://") {
628
+
return ApiError::InvalidRequest("migratingTo must start with https://".into())
629
+
.into_response();
630
+
}
631
+
Some(url.to_string())
632
+
}
633
+
} else {
634
+
None
635
+
};
636
+
637
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
638
.fetch_optional(&state.db)
639
.await
640
.ok()
641
.flatten();
642
+
643
+
let result = if let Some(ref pds_url) = migrating_to {
644
+
sqlx::query!(
645
+
"UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1",
646
+
did,
647
+
delete_after,
648
+
pds_url
649
+
)
650
+
.execute(&state.db)
651
+
.await
652
+
} else {
653
+
sqlx::query!(
654
+
"UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
655
+
did,
656
+
delete_after
657
+
)
658
+
.execute(&state.db)
659
+
.await
660
+
};
661
+
662
+
let status = if migrating_to.is_some() {
663
+
"migrated"
664
+
} else {
665
+
"deactivated"
666
+
};
667
+
668
match result {
669
Ok(_) => {
670
if let Some(ref h) = handle {
671
let _ = state.cache.delete(&format!("handle:{}", h)).await;
672
}
673
+
if let Err(e) =
674
+
crate::api::repo::record::sequence_account_event(&state, &did, false, Some(status))
675
+
.await
676
{
677
+
warn!("Failed to sequence account {} event: {}", status, e);
678
}
679
(StatusCode::OK, Json(json!({}))).into_response()
680
}
+8
-1
src/api/server/meta.rs
+8
-1
src/api/server/meta.rs
···
24
"# Hello!\n\n# Crawling the public API is allowed\nUser-agent: *\nAllow: /\n",
25
)
26
}
27
pub async fn describe_server() -> impl IntoResponse {
28
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
29
let domains_str =
···
53
"links": links,
54
"contact": contact,
55
"version": env!("CARGO_PKG_VERSION"),
56
-
"availableCommsChannels": get_available_comms_channels()
57
}))
58
}
59
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
···
24
"# Hello!\n\n# Crawling the public API is allowed\nUser-agent: *\nAllow: /\n",
25
)
26
}
27
+
pub fn is_self_hosted_did_web_enabled() -> bool {
28
+
std::env::var("ENABLE_SELF_HOSTED_DID_WEB")
29
+
.map(|v| v != "false" && v != "0")
30
+
.unwrap_or(true)
31
+
}
32
+
33
pub async fn describe_server() -> impl IntoResponse {
34
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
35
let domains_str =
···
59
"links": links,
60
"contact": contact,
61
"version": env!("CARGO_PKG_VERSION"),
62
+
"availableCommsChannels": get_available_comms_channels(),
63
+
"selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
64
}))
65
}
66
pub async fn health(State(state): State<AppState>) -> impl IntoResponse {
+367
src/api/server/migration.rs
+367
src/api/server/migration.rs
···
237
}
238
}
239
}
240
+
241
+
#[derive(Debug, Clone, Serialize, Deserialize)]
242
+
#[serde(rename_all = "camelCase")]
243
+
pub struct VerificationMethod {
244
+
pub id: String,
245
+
#[serde(rename = "type")]
246
+
pub method_type: String,
247
+
pub public_key_multibase: String,
248
+
}
249
+
250
+
#[derive(Deserialize)]
251
+
#[serde(rename_all = "camelCase")]
252
+
pub struct UpdateDidDocumentInput {
253
+
pub verification_methods: Option<Vec<VerificationMethod>>,
254
+
pub also_known_as: Option<Vec<String>>,
255
+
pub service_endpoint: Option<String>,
256
+
}
257
+
258
+
#[derive(Serialize)]
259
+
#[serde(rename_all = "camelCase")]
260
+
pub struct UpdateDidDocumentOutput {
261
+
pub success: bool,
262
+
pub did_document: serde_json::Value,
263
+
}
264
+
265
+
pub async fn update_did_document(
266
+
State(state): State<AppState>,
267
+
headers: axum::http::HeaderMap,
268
+
Json(input): Json<UpdateDidDocumentInput>,
269
+
) -> Response {
270
+
let extracted = match crate::auth::extract_auth_token_from_header(
271
+
headers.get("Authorization").and_then(|h| h.to_str().ok()),
272
+
) {
273
+
Some(t) => t,
274
+
None => return ApiError::AuthenticationRequired.into_response(),
275
+
};
276
+
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
277
+
let http_uri = format!(
278
+
"https://{}/xrpc/com.tranquil.account.updateDidDocument",
279
+
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
280
+
);
281
+
let auth_user = match crate::auth::validate_token_with_dpop(
282
+
&state.db,
283
+
&extracted.token,
284
+
extracted.is_dpop,
285
+
dpop_proof,
286
+
"POST",
287
+
&http_uri,
288
+
true,
289
+
)
290
+
.await
291
+
{
292
+
Ok(user) => user,
293
+
Err(e) => return ApiError::from(e).into_response(),
294
+
};
295
+
296
+
if !auth_user.did.starts_with("did:web:") {
297
+
return (
298
+
StatusCode::BAD_REQUEST,
299
+
Json(json!({
300
+
"error": "InvalidRequest",
301
+
"message": "DID document updates are only available for did:web accounts"
302
+
})),
303
+
)
304
+
.into_response();
305
+
}
306
+
307
+
let user = match sqlx::query!(
308
+
"SELECT id, migrated_to_pds, handle FROM users WHERE did = $1",
309
+
auth_user.did
310
+
)
311
+
.fetch_optional(&state.db)
312
+
.await
313
+
{
314
+
Ok(Some(row)) => row,
315
+
Ok(None) => return ApiError::AccountNotFound.into_response(),
316
+
Err(e) => {
317
+
tracing::error!("DB error getting user: {:?}", e);
318
+
return ApiError::InternalError.into_response();
319
+
}
320
+
};
321
+
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();
331
+
}
332
+
333
+
if let Some(ref methods) = input.verification_methods {
334
+
if methods.is_empty() {
335
+
return ApiError::InvalidRequest(
336
+
"verification_methods cannot be empty".into(),
337
+
)
338
+
.into_response();
339
+
}
340
+
for method in methods {
341
+
if method.id.is_empty() {
342
+
return ApiError::InvalidRequest("verification method id is required".into())
343
+
.into_response();
344
+
}
345
+
if method.method_type != "Multikey" {
346
+
return ApiError::InvalidRequest(
347
+
"verification method type must be 'Multikey'".into(),
348
+
)
349
+
.into_response();
350
+
}
351
+
if !method.public_key_multibase.starts_with('z') {
352
+
return ApiError::InvalidRequest(
353
+
"publicKeyMultibase must start with 'z' (base58btc)".into(),
354
+
)
355
+
.into_response();
356
+
}
357
+
if method.public_key_multibase.len() < 40 {
358
+
return ApiError::InvalidRequest(
359
+
"publicKeyMultibase appears too short for a valid key".into(),
360
+
)
361
+
.into_response();
362
+
}
363
+
}
364
+
}
365
+
366
+
if let Some(ref handles) = input.also_known_as {
367
+
for handle in handles {
368
+
if !handle.starts_with("at://") {
369
+
return ApiError::InvalidRequest(
370
+
"alsoKnownAs entries must be at:// URIs".into(),
371
+
)
372
+
.into_response();
373
+
}
374
+
}
375
+
}
376
+
377
+
if let Some(ref endpoint) = input.service_endpoint {
378
+
let endpoint = endpoint.trim();
379
+
if !endpoint.starts_with("https://") {
380
+
return ApiError::InvalidRequest(
381
+
"serviceEndpoint must start with https://".into(),
382
+
)
383
+
.into_response();
384
+
}
385
+
}
386
+
387
+
let verification_methods_json = input
388
+
.verification_methods
389
+
.as_ref()
390
+
.map(|v| serde_json::to_value(v).unwrap_or_default());
391
+
392
+
let also_known_as: Option<Vec<String>> = input.also_known_as.clone();
393
+
394
+
let now = Utc::now();
395
+
396
+
let upsert_result = sqlx::query!(
397
+
r#"
398
+
INSERT INTO did_web_overrides (user_id, verification_methods, also_known_as, updated_at)
399
+
VALUES ($1, COALESCE($2, '[]'::jsonb), COALESCE($3, '{}'::text[]), $4)
400
+
ON CONFLICT (user_id) DO UPDATE SET
401
+
verification_methods = CASE WHEN $2 IS NOT NULL THEN $2 ELSE did_web_overrides.verification_methods END,
402
+
also_known_as = CASE WHEN $3 IS NOT NULL THEN $3 ELSE did_web_overrides.also_known_as END,
403
+
updated_at = $4
404
+
"#,
405
+
user.id,
406
+
verification_methods_json,
407
+
also_known_as.as_deref(),
408
+
now
409
+
)
410
+
.execute(&state.db)
411
+
.await;
412
+
413
+
if let Err(e) = upsert_result {
414
+
tracing::error!("DB error upserting did_web_overrides: {:?}", e);
415
+
return ApiError::InternalError.into_response();
416
+
}
417
+
418
+
if let Some(ref endpoint) = input.service_endpoint {
419
+
let endpoint_clean = endpoint.trim().trim_end_matches('/');
420
+
let update_result = sqlx::query!(
421
+
"UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
422
+
endpoint_clean,
423
+
now,
424
+
auth_user.did
425
+
)
426
+
.execute(&state.db)
427
+
.await;
428
+
429
+
if let Err(e) = update_result {
430
+
tracing::error!("DB error updating service endpoint: {:?}", e);
431
+
return ApiError::InternalError.into_response();
432
+
}
433
+
}
434
+
435
+
let did_doc = build_did_document(&state.db, &auth_user.did).await;
436
+
437
+
tracing::info!("Updated DID document for {}", auth_user.did);
438
+
439
+
(
440
+
StatusCode::OK,
441
+
Json(UpdateDidDocumentOutput {
442
+
success: true,
443
+
did_document: did_doc,
444
+
}),
445
+
)
446
+
.into_response()
447
+
}
448
+
449
+
pub async fn get_did_document(
450
+
State(state): State<AppState>,
451
+
headers: axum::http::HeaderMap,
452
+
) -> Response {
453
+
let extracted = match crate::auth::extract_auth_token_from_header(
454
+
headers.get("Authorization").and_then(|h| h.to_str().ok()),
455
+
) {
456
+
Some(t) => t,
457
+
None => return ApiError::AuthenticationRequired.into_response(),
458
+
};
459
+
let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
460
+
let http_uri = format!(
461
+
"https://{}/xrpc/com.tranquil.account.getDidDocument",
462
+
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
463
+
);
464
+
let auth_user = match crate::auth::validate_token_with_dpop(
465
+
&state.db,
466
+
&extracted.token,
467
+
extracted.is_dpop,
468
+
dpop_proof,
469
+
"GET",
470
+
&http_uri,
471
+
true,
472
+
)
473
+
.await
474
+
{
475
+
Ok(user) => user,
476
+
Err(e) => return ApiError::from(e).into_response(),
477
+
};
478
+
479
+
if !auth_user.did.starts_with("did:web:") {
480
+
return (
481
+
StatusCode::BAD_REQUEST,
482
+
Json(json!({
483
+
"error": "InvalidRequest",
484
+
"message": "This endpoint is only available for did:web accounts"
485
+
})),
486
+
)
487
+
.into_response();
488
+
}
489
+
490
+
let did_doc = build_did_document(&state.db, &auth_user.did).await;
491
+
492
+
(StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response()
493
+
}
494
+
495
+
async fn build_did_document(db: &sqlx::PgPool, did: &str) -> serde_json::Value {
496
+
let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
497
+
498
+
let user = match sqlx::query!(
499
+
"SELECT id, handle, migrated_to_pds FROM users WHERE did = $1",
500
+
did
501
+
)
502
+
.fetch_optional(db)
503
+
.await
504
+
{
505
+
Ok(Some(row)) => row,
506
+
_ => {
507
+
return json!({
508
+
"error": "User not found"
509
+
});
510
+
}
511
+
};
512
+
513
+
let overrides = sqlx::query!(
514
+
"SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
515
+
user.id
516
+
)
517
+
.fetch_optional(db)
518
+
.await
519
+
.ok()
520
+
.flatten();
521
+
522
+
let service_endpoint = user
523
+
.migrated_to_pds
524
+
.unwrap_or_else(|| format!("https://{}", hostname));
525
+
526
+
if let Some(ref ovr) = overrides {
527
+
if let Ok(parsed) = serde_json::from_value::<Vec<VerificationMethod>>(ovr.verification_methods.clone()) {
528
+
if !parsed.is_empty() {
529
+
let also_known_as = if !ovr.also_known_as.is_empty() {
530
+
ovr.also_known_as.clone()
531
+
} else {
532
+
vec![format!("at://{}", user.handle)]
533
+
};
534
+
return json!({
535
+
"@context": [
536
+
"https://www.w3.org/ns/did/v1",
537
+
"https://w3id.org/security/multikey/v1",
538
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
539
+
],
540
+
"id": did,
541
+
"alsoKnownAs": also_known_as,
542
+
"verificationMethod": parsed.iter().map(|m| json!({
543
+
"id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
544
+
"type": m.method_type,
545
+
"controller": did,
546
+
"publicKeyMultibase": m.public_key_multibase
547
+
})).collect::<Vec<_>>(),
548
+
"service": [{
549
+
"id": "#atproto_pds",
550
+
"type": "AtprotoPersonalDataServer",
551
+
"serviceEndpoint": service_endpoint
552
+
}]
553
+
});
554
+
}
555
+
}
556
+
}
557
+
558
+
let key_row = sqlx::query!(
559
+
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
560
+
user.id
561
+
)
562
+
.fetch_optional(db)
563
+
.await;
564
+
565
+
let public_key_multibase = match key_row {
566
+
Ok(Some(row)) => {
567
+
match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
568
+
Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes)
569
+
.unwrap_or_else(|_| "error".to_string()),
570
+
Err(_) => "error".to_string(),
571
+
}
572
+
}
573
+
_ => "error".to_string(),
574
+
};
575
+
576
+
let also_known_as = if let Some(ref ovr) = overrides {
577
+
if !ovr.also_known_as.is_empty() {
578
+
ovr.also_known_as.clone()
579
+
} else {
580
+
vec![format!("at://{}", user.handle)]
581
+
}
582
+
} else {
583
+
vec![format!("at://{}", user.handle)]
584
+
};
585
+
586
+
json!({
587
+
"@context": [
588
+
"https://www.w3.org/ns/did/v1",
589
+
"https://w3id.org/security/multikey/v1",
590
+
"https://w3id.org/security/suites/secp256k1-2019/v1"
591
+
],
592
+
"id": did,
593
+
"alsoKnownAs": also_known_as,
594
+
"verificationMethod": [{
595
+
"id": format!("{}#atproto", did),
596
+
"type": "Multikey",
597
+
"controller": did,
598
+
"publicKeyMultibase": public_key_multibase
599
+
}],
600
+
"service": [{
601
+
"id": "#atproto_pds",
602
+
"type": "AtprotoPersonalDataServer",
603
+
"serviceEndpoint": service_endpoint
604
+
}]
605
+
})
606
+
}
+2
-1
src/api/server/mod.rs
+2
-1
src/api/server/mod.rs
···
27
pub use logo::get_logo;
28
pub use meta::{describe_server, health, robots_txt};
29
pub use migration::{
30
-
clear_migration_forwarding, get_migration_status, update_migration_forwarding,
31
};
32
pub use passkey_account::{
33
complete_passkey_setup, create_passkey_account, recover_passkey_account,
···
27
pub use logo::get_logo;
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
};
33
pub use passkey_account::{
34
complete_passkey_setup, create_passkey_account, recover_passkey_account,
+11
-2
src/api/server/session.rs
+11
-2
src/api/server/session.rs
···
104
r#"SELECT
105
u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,
106
u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,
107
-
u.allow_legacy_login,
108
u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel",
109
k.key_bytes, k.encryption_version,
110
(SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled
···
276
}
277
}
278
let handle = full_handle(&row.handle, &pds_hostname);
279
let is_active = row.deactivated_at.is_none() && !is_takendown;
280
let status = if is_takendown {
281
Some("takendown".to_string())
282
} else if row.deactivated_at.is_some() {
283
Some("deactivated".to_string())
284
} else {
···
312
r#"SELECT
313
handle, email, email_verified, is_admin, deactivated_at, takedown_ref, preferred_locale,
314
preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel",
315
-
discord_verified, telegram_verified, signal_verified
316
FROM users WHERE did = $1"#,
317
auth_user.did
318
)
···
331
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
332
let handle = full_handle(&row.handle, &pds_hostname);
333
let is_takendown = row.takedown_ref.is_some();
334
let is_active = row.deactivated_at.is_none() && !is_takendown;
335
let email_value = if can_read_email {
336
row.email.clone()
···
353
}
354
if is_takendown {
355
response["status"] = json!("takendown");
356
} else if row.deactivated_at.is_some() {
357
response["status"] = json!("deactivated");
358
}
···
104
r#"SELECT
105
u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,
106
u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,
107
+
u.allow_legacy_login, u.migrated_to_pds,
108
u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel",
109
k.key_bytes, k.encryption_version,
110
(SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled
···
276
}
277
}
278
let handle = full_handle(&row.handle, &pds_hostname);
279
+
let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
280
let is_active = row.deactivated_at.is_none() && !is_takendown;
281
let status = if is_takendown {
282
Some("takendown".to_string())
283
+
} else if is_migrated {
284
+
Some("migrated".to_string())
285
} else if row.deactivated_at.is_some() {
286
Some("deactivated".to_string())
287
} else {
···
315
r#"SELECT
316
handle, email, email_verified, is_admin, deactivated_at, takedown_ref, preferred_locale,
317
preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel",
318
+
discord_verified, telegram_verified, signal_verified, migrated_to_pds, migrated_at
319
FROM users WHERE did = $1"#,
320
auth_user.did
321
)
···
334
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
335
let handle = full_handle(&row.handle, &pds_hostname);
336
let is_takendown = row.takedown_ref.is_some();
337
+
let is_migrated =
338
+
row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
339
let is_active = row.deactivated_at.is_none() && !is_takendown;
340
let email_value = if can_read_email {
341
row.email.clone()
···
358
}
359
if is_takendown {
360
response["status"] = json!("takendown");
361
+
} else if is_migrated {
362
+
response["status"] = json!("migrated");
363
+
response["migratedToPds"] = json!(row.migrated_to_pds);
364
+
response["migratedAt"] = json!(row.migrated_at);
365
} else if row.deactivated_at.is_some() {
366
response["status"] = json!("deactivated");
367
}
+4
-3
src/auth/extractor.rs
+4
-3
src/auth/extractor.rs
···
11
validate_bearer_token_cached_allow_deactivated, validate_token_with_dpop,
12
};
13
use crate::state::AppState;
14
15
pub struct BearerAuth(pub AuthenticatedUser);
16
···
164
if extracted.is_dpop {
165
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
166
let method = parts.method.as_str();
167
-
let uri = parts.uri.to_string();
168
169
match validate_token_with_dpop(
170
&state.db,
···
217
if extracted.is_dpop {
218
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
219
let method = parts.method.as_str();
220
-
let uri = parts.uri.to_string();
221
222
match validate_token_with_dpop(
223
&state.db,
···
274
let user = if extracted.is_dpop {
275
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
276
let method = parts.method.as_str();
277
-
let uri = parts.uri.to_string();
278
279
match validate_token_with_dpop(
280
&state.db,
···
11
validate_bearer_token_cached_allow_deactivated, validate_token_with_dpop,
12
};
13
use crate::state::AppState;
14
+
use crate::util::build_full_url;
15
16
pub struct BearerAuth(pub AuthenticatedUser);
17
···
165
if extracted.is_dpop {
166
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
167
let method = parts.method.as_str();
168
+
let uri = build_full_url(&parts.uri.to_string());
169
170
match validate_token_with_dpop(
171
&state.db,
···
218
if extracted.is_dpop {
219
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
220
let method = parts.method.as_str();
221
+
let uri = build_full_url(&parts.uri.to_string());
222
223
match validate_token_with_dpop(
224
&state.db,
···
275
let user = if extracted.is_dpop {
276
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
277
let method = parts.method.as_str();
278
+
let uri = build_full_url(&parts.uri.to_string());
279
280
match validate_token_with_dpop(
281
&state.db,
+8
src/lib.rs
+8
src/lib.rs
···
296
post(api::server::clear_migration_forwarding),
297
)
298
.route(
299
+
"/xrpc/com.tranquil.account.updateDidDocument",
300
+
post(api::server::update_did_document),
301
+
)
302
+
.route(
303
+
"/xrpc/com.tranquil.account.getDidDocument",
304
+
get(api::server::get_did_document),
305
+
)
306
+
.route(
307
"/xrpc/com.atproto.server.requestEmailUpdate",
308
post(api::server::request_email_update),
309
)
+1
-1
src/oauth/verify.rs
+1
-1
src/oauth/verify.rs
+22
src/util.rs
+22
src/util.rs
···
86
.ok_or(DbLookupError::NotFound)
87
}
88
89
+
pub async fn is_account_migrated(db: &PgPool, did: &str) -> Result<bool, sqlx::Error> {
90
+
let row = sqlx::query!(
91
+
r#"SELECT (migrated_to_pds IS NOT NULL AND deactivated_at IS NOT NULL) as "migrated!: bool" FROM users WHERE did = $1"#,
92
+
did
93
+
)
94
+
.fetch_optional(db)
95
+
.await?;
96
+
Ok(row.map(|r| r.migrated).unwrap_or(false))
97
+
}
98
+
99
pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> {
100
query
101
.map(|q| {
···
136
return value.trim().to_string();
137
}
138
"unknown".to_string()
139
+
}
140
+
141
+
pub fn pds_hostname() -> String {
142
+
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
143
+
}
144
+
145
+
pub fn pds_public_url() -> String {
146
+
format!("https://{}", pds_hostname())
147
+
}
148
+
149
+
pub fn build_full_url(path: &str) -> String {
150
+
format!("{}{}", pds_public_url(), path)
151
}
152
153
#[cfg(test)]
+315
tests/did_web.rs
+315
tests/did_web.rs
···
545
"Activated BYOD account should be able to create records"
546
);
547
}
548
+
549
+
#[tokio::test]
550
+
async fn test_deactivate_with_migrating_to() {
551
+
let client = client();
552
+
let base = base_url().await;
553
+
let handle = format!("mig{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
554
+
let payload = json!({
555
+
"handle": handle,
556
+
"email": format!("{}@example.com", handle),
557
+
"password": "Testpass123!",
558
+
"didType": "web"
559
+
});
560
+
let res = client
561
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
562
+
.json(&payload)
563
+
.send()
564
+
.await
565
+
.expect("Failed to send request");
566
+
assert_eq!(res.status(), StatusCode::OK);
567
+
let body: Value = res.json().await.expect("Response was not JSON");
568
+
let did = body["did"].as_str().expect("No DID").to_string();
569
+
let jwt = verify_new_account(&client, &did).await;
570
+
let target_pds = "https://pds2.example.com";
571
+
let res = client
572
+
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
573
+
.bearer_auth(&jwt)
574
+
.json(&json!({ "migratingTo": target_pds }))
575
+
.send()
576
+
.await
577
+
.expect("Failed to send request");
578
+
assert_eq!(res.status(), StatusCode::OK);
579
+
let pool = get_test_db_pool().await;
580
+
let row = sqlx::query!(
581
+
r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#,
582
+
&did
583
+
)
584
+
.fetch_one(pool)
585
+
.await
586
+
.expect("Failed to query user");
587
+
assert_eq!(
588
+
row.migrated_to_pds.as_deref(),
589
+
Some(target_pds),
590
+
"migrated_to_pds should be set to target PDS"
591
+
);
592
+
assert!(
593
+
row.deactivated_at.is_some(),
594
+
"deactivated_at should be set for migrated account"
595
+
);
596
+
}
597
+
598
+
#[tokio::test]
599
+
async fn test_migrated_account_blocked_from_repo_ops() {
600
+
let client = client();
601
+
let base = base_url().await;
602
+
let handle = format!("blk{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
603
+
let payload = json!({
604
+
"handle": handle,
605
+
"email": format!("{}@example.com", handle),
606
+
"password": "Testpass123!",
607
+
"didType": "web"
608
+
});
609
+
let res = client
610
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
611
+
.json(&payload)
612
+
.send()
613
+
.await
614
+
.expect("Failed to send request");
615
+
assert_eq!(res.status(), StatusCode::OK);
616
+
let body: Value = res.json().await.expect("Response was not JSON");
617
+
let did = body["did"].as_str().expect("No DID").to_string();
618
+
let jwt = verify_new_account(&client, &did).await;
619
+
let res = client
620
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
621
+
.bearer_auth(&jwt)
622
+
.json(&json!({
623
+
"repo": did,
624
+
"collection": "app.bsky.feed.post",
625
+
"record": {
626
+
"$type": "app.bsky.feed.post",
627
+
"text": "Pre-migration post",
628
+
"createdAt": chrono::Utc::now().to_rfc3339()
629
+
}
630
+
}))
631
+
.send()
632
+
.await
633
+
.expect("Failed to send request");
634
+
assert_eq!(res.status(), StatusCode::OK);
635
+
let res = client
636
+
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
637
+
.bearer_auth(&jwt)
638
+
.json(&json!({ "migratingTo": "https://pds2.example.com" }))
639
+
.send()
640
+
.await
641
+
.expect("Failed to send request");
642
+
assert_eq!(res.status(), StatusCode::OK);
643
+
let res = client
644
+
.post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
645
+
.bearer_auth(&jwt)
646
+
.json(&json!({
647
+
"repo": did,
648
+
"collection": "app.bsky.feed.post",
649
+
"record": {
650
+
"$type": "app.bsky.feed.post",
651
+
"text": "Post-migration post - should fail",
652
+
"createdAt": chrono::Utc::now().to_rfc3339()
653
+
}
654
+
}))
655
+
.send()
656
+
.await
657
+
.expect("Failed to send request");
658
+
assert!(
659
+
res.status().is_client_error(),
660
+
"createRecord should fail for migrated account: {}",
661
+
res.status()
662
+
);
663
+
let res = client
664
+
.post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
665
+
.bearer_auth(&jwt)
666
+
.json(&json!({
667
+
"repo": did,
668
+
"collection": "app.bsky.actor.profile",
669
+
"rkey": "self",
670
+
"record": {
671
+
"$type": "app.bsky.actor.profile",
672
+
"displayName": "Test"
673
+
}
674
+
}))
675
+
.send()
676
+
.await
677
+
.expect("Failed to send request");
678
+
assert!(
679
+
res.status().is_client_error(),
680
+
"putRecord should fail for migrated account: {}",
681
+
res.status()
682
+
);
683
+
let res = client
684
+
.post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
685
+
.bearer_auth(&jwt)
686
+
.json(&json!({
687
+
"repo": did,
688
+
"collection": "app.bsky.feed.post",
689
+
"rkey": "test123"
690
+
}))
691
+
.send()
692
+
.await
693
+
.expect("Failed to send request");
694
+
assert!(
695
+
res.status().is_client_error(),
696
+
"deleteRecord should fail for migrated account: {}",
697
+
res.status()
698
+
);
699
+
let res = client
700
+
.post(format!("{}/xrpc/com.atproto.repo.applyWrites", base))
701
+
.bearer_auth(&jwt)
702
+
.json(&json!({
703
+
"repo": did,
704
+
"writes": [{
705
+
"$type": "com.atproto.repo.applyWrites#create",
706
+
"collection": "app.bsky.feed.post",
707
+
"value": {
708
+
"$type": "app.bsky.feed.post",
709
+
"text": "Batch post",
710
+
"createdAt": chrono::Utc::now().to_rfc3339()
711
+
}
712
+
}]
713
+
}))
714
+
.send()
715
+
.await
716
+
.expect("Failed to send request");
717
+
assert!(
718
+
res.status().is_client_error(),
719
+
"applyWrites should fail for migrated account: {}",
720
+
res.status()
721
+
);
722
+
let res = client
723
+
.post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base))
724
+
.bearer_auth(&jwt)
725
+
.header("Content-Type", "text/plain")
726
+
.body("test blob content")
727
+
.send()
728
+
.await
729
+
.expect("Failed to send request");
730
+
assert!(
731
+
res.status().is_client_error(),
732
+
"uploadBlob should fail for migrated account: {}",
733
+
res.status()
734
+
);
735
+
}
736
+
737
+
#[tokio::test]
738
+
async fn test_migrated_session_status() {
739
+
let client = client();
740
+
let base = base_url().await;
741
+
let handle = format!("ses{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
742
+
let payload = json!({
743
+
"handle": handle,
744
+
"email": format!("{}@example.com", handle),
745
+
"password": "Testpass123!",
746
+
"didType": "web"
747
+
});
748
+
let res = client
749
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
750
+
.json(&payload)
751
+
.send()
752
+
.await
753
+
.expect("Failed to send request");
754
+
assert_eq!(res.status(), StatusCode::OK);
755
+
let body: Value = res.json().await.expect("Response was not JSON");
756
+
let did = body["did"].as_str().expect("No DID").to_string();
757
+
let jwt = verify_new_account(&client, &did).await;
758
+
let res = client
759
+
.get(format!("{}/xrpc/com.atproto.server.getSession", base))
760
+
.bearer_auth(&jwt)
761
+
.send()
762
+
.await
763
+
.expect("Failed to send request");
764
+
assert_eq!(res.status(), StatusCode::OK);
765
+
let body: Value = res.json().await.expect("Response was not JSON");
766
+
assert_eq!(body["active"], true);
767
+
assert!(
768
+
body["status"].is_null() || body["status"] == "active",
769
+
"Status should be null or 'active' for normal accounts"
770
+
);
771
+
let target_pds = "https://pds3.example.com";
772
+
let res = client
773
+
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
774
+
.bearer_auth(&jwt)
775
+
.json(&json!({ "migratingTo": target_pds }))
776
+
.send()
777
+
.await
778
+
.expect("Failed to send request");
779
+
assert_eq!(res.status(), StatusCode::OK);
780
+
let res = client
781
+
.get(format!("{}/xrpc/com.atproto.server.getSession", base))
782
+
.bearer_auth(&jwt)
783
+
.send()
784
+
.await
785
+
.expect("Failed to send request");
786
+
assert_eq!(res.status(), StatusCode::OK);
787
+
let body: Value = res.json().await.expect("Response was not JSON");
788
+
assert_eq!(body["active"], false, "Migrated account should not be active");
789
+
assert_eq!(
790
+
body["status"], "migrated",
791
+
"Status should be 'migrated' after migration"
792
+
);
793
+
assert_eq!(
794
+
body["migratedToPds"], target_pds,
795
+
"migratedToPds should be set to target PDS"
796
+
);
797
+
}
798
+
799
+
#[tokio::test]
800
+
async fn test_migrating_to_ignored_for_did_plc() {
801
+
let client = client();
802
+
let base = base_url().await;
803
+
let handle = format!("plc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
804
+
let payload = json!({
805
+
"handle": handle,
806
+
"email": format!("{}@example.com", handle),
807
+
"password": "Testpass123!",
808
+
"didType": "plc"
809
+
});
810
+
let res = client
811
+
.post(format!("{}/xrpc/com.atproto.server.createAccount", base))
812
+
.json(&payload)
813
+
.send()
814
+
.await
815
+
.expect("Failed to send request");
816
+
assert_eq!(res.status(), StatusCode::OK);
817
+
let body: Value = res.json().await.expect("Response was not JSON");
818
+
let did = body["did"].as_str().expect("No DID").to_string();
819
+
assert!(did.starts_with("did:plc:"), "Should be did:plc account");
820
+
let jwt = verify_new_account(&client, &did).await;
821
+
let res = client
822
+
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
823
+
.bearer_auth(&jwt)
824
+
.json(&json!({ "migratingTo": "https://pds2.example.com" }))
825
+
.send()
826
+
.await
827
+
.expect("Failed to send request");
828
+
assert_eq!(res.status(), StatusCode::OK);
829
+
let pool = get_test_db_pool().await;
830
+
let row = sqlx::query!(
831
+
r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#,
832
+
&did
833
+
)
834
+
.fetch_one(pool)
835
+
.await
836
+
.expect("Failed to query user");
837
+
assert!(
838
+
row.migrated_to_pds.is_none(),
839
+
"migrated_to_pds should NOT be set for did:plc accounts"
840
+
);
841
+
assert!(
842
+
row.deactivated_at.is_some(),
843
+
"deactivated_at should still be set"
844
+
);
845
+
let res = client
846
+
.get(format!("{}/xrpc/com.atproto.server.getSession", base))
847
+
.bearer_auth(&jwt)
848
+
.send()
849
+
.await
850
+
.expect("Failed to send request");
851
+
assert_eq!(res.status(), StatusCode::OK);
852
+
let body: Value = res.json().await.expect("Response was not JSON");
853
+
assert_eq!(body["active"], false);
854
+
assert_eq!(
855
+
body["status"], "deactivated",
856
+
"Status should be 'deactivated' not 'migrated' for did:plc"
857
+
);
858
+
assert!(
859
+
body["migratedToPds"].is_null(),
860
+
"migratedToPds should not be set for did:plc accounts"
861
+
);
862
+
}
+3
-2
tests/invite.rs
+3
-2
tests/invite.rs
···
53
#[tokio::test]
54
async fn test_create_invite_code_non_admin() {
55
let client = client();
56
let (access_jwt, _did) = create_account_and_login(&client).await;
57
let payload = json!({
58
"useCount": 5
···
121
#[tokio::test]
122
async fn test_create_invite_codes_success() {
123
let client = client();
124
-
let (access_jwt, _did) = create_admin_account_and_login(&client).await;
125
let payload = json!({
126
"useCount": 2,
127
"codeCount": 3
···
141
assert!(body["codes"].is_array());
142
let codes = body["codes"].as_array().unwrap();
143
assert_eq!(codes.len(), 1);
144
-
assert_eq!(codes[0]["account"], "admin");
145
assert_eq!(codes[0]["codes"].as_array().unwrap().len(), 3);
146
}
147
···
53
#[tokio::test]
54
async fn test_create_invite_code_non_admin() {
55
let client = client();
56
+
let _ = create_admin_account_and_login(&client).await;
57
let (access_jwt, _did) = create_account_and_login(&client).await;
58
let payload = json!({
59
"useCount": 5
···
122
#[tokio::test]
123
async fn test_create_invite_codes_success() {
124
let client = client();
125
+
let (access_jwt, did) = create_admin_account_and_login(&client).await;
126
let payload = json!({
127
"useCount": 2,
128
"codeCount": 3
···
142
assert!(body["codes"].is_array());
143
let codes = body["codes"].as_array().unwrap();
144
assert_eq!(codes.len(), 1);
145
+
assert_eq!(codes[0]["account"], did);
146
assert_eq!(codes[0]["codes"].as_array().unwrap().len(), 3);
147
}
148