this repo has no description

pds-hosted did migrates away

lewis 0bdef257 ed387916

+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
···
··· 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
··· 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
···
··· 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
···
··· 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
···
··· 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
··· 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
···
··· 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
···
··· 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
··· 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
··· 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
··· 620 621 <div class="code-block"> 622 <pre>{`{ 623 "id": "${flow.state.sourceDid}", 624 "verificationMethod": [ 625 { 626 "id": "${flow.state.sourceDid}#atproto",
··· 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
··· 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
··· 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
··· 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");
··· 906 907 setProgress({ currentOperation: "Deactivating old account..." }); 908 try { 909 + await localClient.deactivateAccount(state.targetPdsUrl); 910 setProgress({ deactivated: true }); 911 } catch { 912 } 913 914 setStep("success");
+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
··· 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
··· 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
··· 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
··· 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
··· 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
··· 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
···
··· 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
··· 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
··· 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
··· 144 privacyPolicy: "https://example.com/privacy", 145 termsOfService: "https://example.com/tos", 146 }, 147 }), 148 describeRepo: (did: string) => ({ 149 handle: "testuser.test.tranquil.dev",
··· 144 privacyPolicy: "https://example.com/privacy", 145 termsOfService: "https://example.com/tos", 146 }, 147 + selfHostedDidWebEnabled: true, 148 }), 149 describeRepo: (did: string) => ({ 150 handle: "testuser.test.tranquil.dev",
+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
··· 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
··· 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
··· 92 } 93 }; 94 95 let max_size = get_max_blob_size(); 96 97 if body.len() > max_size {
··· 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
··· 129 ) 130 .into_response(); 131 } 132 let is_verified = has_verified_comms_channel(&state.db, &did) 133 .await 134 .unwrap_or(false);
··· 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
··· 57 return e; 58 } 59 60 let did = auth.did; 61 let user_id = auth.user_id; 62 let current_root_cid = auth.current_root_cid;
··· 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
··· 102 ) 103 .into_response()); 104 } 105 let is_verified = has_verified_comms_channel(&state.db, &auth_user.did) 106 .await 107 .unwrap_or(false);
··· 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
··· 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
··· 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
··· 237 } 238 } 239 }
··· 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
··· 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
··· 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
··· 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
··· 296 post(api::server::clear_migration_forwarding), 297 ) 298 .route( 299 "/xrpc/com.atproto.server.requestEmailUpdate", 300 post(api::server::request_email_update), 301 )
··· 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
··· 257 }); 258 } 259 let http_method = parts.method.as_str(); 260 - let http_uri = parts.uri.to_string(); 261 match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await 262 { 263 Ok(result) => {
··· 257 }); 258 } 259 let http_method = parts.method.as_str(); 260 + let http_uri = crate::util::build_full_url(&parts.uri.to_string()); 261 match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await 262 { 263 Ok(result) => {
+22
src/util.rs
··· 86 .ok_or(DbLookupError::NotFound) 87 } 88 89 pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec<String> { 90 query 91 .map(|q| { ··· 126 return value.trim().to_string(); 127 } 128 "unknown".to_string() 129 } 130 131 #[cfg(test)]
··· 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
··· 545 "Activated BYOD account should be able to create records" 546 ); 547 }
··· 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
··· 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