diff --git a/.env.example b/.env.example
index 4187515..a2e05ad 100644
--- a/.env.example
+++ b/.env.example
@@ -106,6 +106,10 @@ AWS_SECRET_ACCESS_KEY=minioadmin
# INVITE_CODE_REQUIRED=false
# Comma-separated list of available user domains
# AVAILABLE_USER_DOMAINS=example.com
+# Enable self-hosted did:web identities (default: true)
+# Hosting did:web requires a long-term commitment to serve DID documents.
+# Set to false if you don't want to offer this option.
+# ENABLE_SELF_HOSTED_DID_WEB=true
# =============================================================================
# Server Metadata (returned by describeServer)
# =============================================================================
diff --git a/.sqlx/query-c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782.json b/.sqlx/query-0d0622d485361d9f4d4f85e1ba23a331f16155c08208b65b448e1e4659a070b8.json
similarity index 81%
rename from .sqlx/query-c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782.json
rename to .sqlx/query-0d0622d485361d9f4d4f85e1ba23a331f16155c08208b65b448e1e4659a070b8.json
index fd29910..e0e7996 100644
--- a/.sqlx/query-c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782.json
+++ b/.sqlx/query-0d0622d485361d9f4d4f85e1ba23a331f16155c08208b65b448e1e4659a070b8.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "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",
+ "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",
"describe": {
"columns": [
{
@@ -69,6 +69,16 @@
"ordinal": 10,
"name": "signal_verified",
"type_info": "Bool"
+ },
+ {
+ "ordinal": 11,
+ "name": "migrated_to_pds",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 12,
+ "name": "migrated_at",
+ "type_info": "Timestamptz"
}
],
"parameters": {
@@ -87,8 +97,10 @@
false,
false,
false,
- false
+ false,
+ true,
+ true
]
},
- "hash": "c36e3ae06df1d0f795771b2452df4cb3d78b00fdb7ed44b9adbc105cd2cb2782"
+ "hash": "0d0622d485361d9f4d4f85e1ba23a331f16155c08208b65b448e1e4659a070b8"
}
diff --git a/.sqlx/query-0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe.json b/.sqlx/query-0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe.json
new file mode 100644
index 0000000..5c8966b
--- /dev/null
+++ b/.sqlx/query-0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe.json
@@ -0,0 +1,16 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Timestamptz",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "0d6565c792bb9c2845d03ac1cb984658d77a26f90df511686e47b358c79a8ebe"
+}
diff --git a/.sqlx/query-36f84d8aa41bb289d09fda5e26a91543b1bfd100c659db47bef97954f4c25580.json b/.sqlx/query-36f84d8aa41bb289d09fda5e26a91543b1bfd100c659db47bef97954f4c25580.json
new file mode 100644
index 0000000..7380479
--- /dev/null
+++ b/.sqlx/query-36f84d8aa41bb289d09fda5e26a91543b1bfd100c659db47bef97954f4c25580.json
@@ -0,0 +1,34 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT id, handle, migrated_to_pds FROM users WHERE did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "handle",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "migrated_to_pds",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ true
+ ]
+ },
+ "hash": "36f84d8aa41bb289d09fda5e26a91543b1bfd100c659db47bef97954f4c25580"
+}
diff --git a/.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json b/.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json
new file mode 100644
index 0000000..d7aaa1e
--- /dev/null
+++ b/.sqlx/query-63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073.json
@@ -0,0 +1,34 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "id",
+ "type_info": "Uuid"
+ },
+ {
+ "ordinal": 1,
+ "name": "migrated_to_pds",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 2,
+ "name": "handle",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ false
+ ]
+ },
+ "hash": "63cfbd8c2fda2c01cb9a97fc2768b60cafecaa4fa3006c2db9848e852d867073"
+}
diff --git a/.sqlx/query-7f8bc1ef416b851704ccc5232abd65a71e624aab95ae44f57448a88bef78e2a3.json b/.sqlx/query-7f8bc1ef416b851704ccc5232abd65a71e624aab95ae44f57448a88bef78e2a3.json
new file mode 100644
index 0000000..bac9712
--- /dev/null
+++ b/.sqlx/query-7f8bc1ef416b851704ccc5232abd65a71e624aab95ae44f57448a88bef78e2a3.json
@@ -0,0 +1,17 @@
+{
+ "db_name": "PostgreSQL",
+ "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 ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Uuid",
+ "Jsonb",
+ "TextArray",
+ "Timestamptz"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "7f8bc1ef416b851704ccc5232abd65a71e624aab95ae44f57448a88bef78e2a3"
+}
diff --git a/.sqlx/query-d91efadf413a90b907727cffd1ce13eb68c3ef017a34e69629df1255de312317.json b/.sqlx/query-d91efadf413a90b907727cffd1ce13eb68c3ef017a34e69629df1255de312317.json
new file mode 100644
index 0000000..5cd61a2
--- /dev/null
+++ b/.sqlx/query-d91efadf413a90b907727cffd1ce13eb68c3ef017a34e69629df1255de312317.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT (migrated_to_pds IS NOT NULL AND deactivated_at IS NOT NULL) as \"migrated!: bool\" FROM users WHERE did = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "migrated!: bool",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "d91efadf413a90b907727cffd1ce13eb68c3ef017a34e69629df1255de312317"
+}
diff --git a/.sqlx/query-e6aa80223281c7d4303122f92a9cf0e8718ca331412fb48c4bd24a5c5acb4492.json b/.sqlx/query-e6aa80223281c7d4303122f92a9cf0e8718ca331412fb48c4bd24a5c5acb4492.json
new file mode 100644
index 0000000..d5a8a86
--- /dev/null
+++ b/.sqlx/query-e6aa80223281c7d4303122f92a9cf0e8718ca331412fb48c4bd24a5c5acb4492.json
@@ -0,0 +1,28 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "verification_methods",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 1,
+ "name": "also_known_as",
+ "type_info": "TextArray"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Uuid"
+ ]
+ },
+ "nullable": [
+ false,
+ false
+ ]
+ },
+ "hash": "e6aa80223281c7d4303122f92a9cf0e8718ca331412fb48c4bd24a5c5acb4492"
+}
diff --git a/.sqlx/query-1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce.json b/.sqlx/query-f06ceae0d1567cab89c48516544879b7ee5a0e9e07afeca837cd49ddd54c129d.json
similarity index 79%
rename from .sqlx/query-1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce.json
rename to .sqlx/query-f06ceae0d1567cab89c48516544879b7ee5a0e9e07afeca837cd49ddd54c129d.json
index d7fdd62..a21de0d 100644
--- a/.sqlx/query-1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce.json
+++ b/.sqlx/query-f06ceae0d1567cab89c48516544879b7ee5a0e9e07afeca837cd49ddd54c129d.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "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",
+ "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",
"describe": {
"columns": [
{
@@ -65,6 +65,11 @@
},
{
"ordinal": 12,
+ "name": "migrated_to_pds",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 13,
"name": "preferred_comms_channel: crate::comms::CommsChannel",
"type_info": {
"Custom": {
@@ -81,17 +86,17 @@
}
},
{
- "ordinal": 13,
+ "ordinal": 14,
"name": "key_bytes",
"type_info": "Bytea"
},
{
- "ordinal": 14,
+ "ordinal": 15,
"name": "encryption_version",
"type_info": "Int4"
},
{
- "ordinal": 15,
+ "ordinal": 16,
"name": "totp_enabled",
"type_info": "Bool"
}
@@ -114,11 +119,12 @@
false,
false,
false,
+ true,
false,
false,
true,
null
]
},
- "hash": "1901ab0945813eee128c0f5de066c61ef13f671243add1d1c4d722e4f8b5c1ce"
+ "hash": "f06ceae0d1567cab89c48516544879b7ee5a0e9e07afeca837cd49ddd54c129d"
}
diff --git a/TODO.md b/TODO.md
index dd77b6f..17cd6f0 100644
--- a/TODO.md
+++ b/TODO.md
@@ -5,14 +5,14 @@
### Migration tool
Seamless account migration built into the UI, inspired by pdsmoover. Users shouldn't need external tools or brain surgery on half-done account states.
-- [ ] Add `migratingTo` parameter to `deactivateAccount` endpoint
-- [ ] For self-hosted did:web users: set `migrated_to_pds`, update DID doc serviceEndpoint
-- [ ] "Migrated" account state for self-hosted did:web: can authenticate but no repo operations
-- [ ] Migrated did:web user UI: minimal dashboard with "update forwarding PDS" setting, or full migration wizard to handle PDS 2 -> PDS 3 moves automatically
-- [ ] Outbound UI wizard: new PDS URL -> export repo -> guide account creation -> complete migration
-- [ ] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow
-- [ ] Support `createAccount` with existing DID + service auth token
-- [ ] Progress tracking with resume capability
+- [x] Add `migratingTo` parameter to `deactivateAccount` endpoint
+- [x] For self-hosted did:web users: set `migrated_to_pds`, update DID doc serviceEndpoint
+- [x] "Migrated" account state for self-hosted did:web: can authenticate but no repo operations
+- [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
+- [x] Outbound UI wizard: new PDS URL -> export repo -> guide account creation -> complete migration
+- [x] Inbound UI wizard: login to old PDS -> choose handle -> import -> PLC token flow
+- [x] Support `createAccount` with existing DID + service auth token
+- [x] Progress tracking with resume capability
- [ ] Scheduled automatic backups (CAR export)
- [ ] One-click restore from backup
diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte
index c0ab1cd..2d3d629 100644
--- a/frontend/src/App.svelte
+++ b/frontend/src/App.svelte
@@ -33,6 +33,7 @@
import DelegationAudit from './routes/DelegationAudit.svelte'
import ActAs from './routes/ActAs.svelte'
import Migration from './routes/Migration.svelte'
+ import DidDocumentEditor from './routes/DidDocumentEditor.svelte'
import Home from './routes/Home.svelte'
initI18n()
@@ -116,6 +117,8 @@
return ActAs
case '/migrate':
return Migration
+ case '/did-document':
+ return DidDocumentEditor
default:
return Home
}
diff --git a/frontend/src/components/migration/InboundWizard.svelte b/frontend/src/components/migration/InboundWizard.svelte
index dddcfa0..95cb853 100644
--- a/frontend/src/components/migration/InboundWizard.svelte
+++ b/frontend/src/components/migration/InboundWizard.svelte
@@ -620,7 +620,15 @@
{`{
+ "@context": [
+ "https://www.w3.org/ns/did/v1",
+ "https://w3id.org/security/multikey/v1",
+ "https://w3id.org/security/suites/secp256k1-2019/v1"
+ ],
"id": "${flow.state.sourceDid}",
+ "alsoKnownAs": [
+ "at://${flow.state.targetHandle || '...'}"
+ ],
"verificationMethod": [
{
"id": "${flow.state.sourceDid}#atproto",
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 406ab63..af68482 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -86,11 +86,36 @@ export interface Session {
preferredChannelVerified?: boolean;
isAdmin?: boolean;
active?: boolean;
- status?: "active" | "deactivated";
+ status?: "active" | "deactivated" | "migrated";
+ migratedToPds?: string;
+ migratedAt?: string;
accessJwt: string;
refreshJwt: string;
}
+export interface VerificationMethod {
+ id: string;
+ type: string;
+ publicKeyMultibase: string;
+}
+
+export interface DidDocument {
+ "@context": string[];
+ id: string;
+ alsoKnownAs: string[];
+ verificationMethod: Array<{
+ id: string;
+ type: string;
+ controller: string;
+ publicKeyMultibase: string;
+ }>;
+ service: Array<{
+ id: string;
+ type: string;
+ serviceEndpoint: string;
+ }>;
+}
+
export interface AppPassword {
name: string;
createdAt: string;
@@ -330,6 +355,7 @@ export const api = {
links?: { privacyPolicy?: string; termsOfService?: string };
version?: string;
availableCommsChannels?: string[];
+ selfHostedDidWebEnabled?: boolean;
}> {
return xrpc("com.atproto.server.describeServer");
},
@@ -1057,4 +1083,61 @@ export const api = {
token: accessToken,
});
},
+
+ async getDidDocument(token: string): Promise {
+ return xrpc("com.tranquil.account.getDidDocument", { token });
+ },
+
+ async updateDidDocument(
+ token: string,
+ params: {
+ verificationMethods?: VerificationMethod[];
+ alsoKnownAs?: string[];
+ serviceEndpoint?: string;
+ },
+ ): Promise<{ success: boolean }> {
+ return xrpc("com.tranquil.account.updateDidDocument", {
+ method: "POST",
+ token,
+ body: params,
+ });
+ },
+
+ async deactivateAccount(
+ token: string,
+ deleteAfter?: string,
+ migratingTo?: string,
+ ): Promise {
+ await xrpc("com.atproto.server.deactivateAccount", {
+ method: "POST",
+ token,
+ body: { deleteAfter, migratingTo },
+ });
+ },
+
+ async getMigrationStatus(token: string): Promise<{
+ migratedToPds?: string;
+ migratedAt?: string;
+ forwardingEnabled: boolean;
+ }> {
+ return xrpc("com.tranquil.account.getMigrationStatus", { token });
+ },
+
+ async updateMigrationForwarding(
+ token: string,
+ forwardingPds?: string,
+ ): Promise<{ success: boolean }> {
+ return xrpc("com.tranquil.account.updateMigrationForwarding", {
+ method: "POST",
+ token,
+ body: { forwardingPds },
+ });
+ },
+
+ async clearMigrationForwarding(token: string): Promise<{ success: boolean }> {
+ return xrpc("com.tranquil.account.clearMigrationForwarding", {
+ method: "POST",
+ token,
+ });
+ },
};
diff --git a/frontend/src/lib/migration/atproto-client.ts b/frontend/src/lib/migration/atproto-client.ts
index 15e3025..e2f2104 100644
--- a/frontend/src/lib/migration/atproto-client.ts
+++ b/frontend/src/lib/migration/atproto-client.ts
@@ -327,12 +327,19 @@ export class AtprotoClient {
);
}
- async deactivateAccount(): Promise {
- apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`);
+ async deactivateAccount(migratingTo?: string): Promise {
+ apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`, {
+ migratingTo,
+ });
const start = Date.now();
try {
+ const body: { migratingTo?: string } = {};
+ if (migratingTo) {
+ body.migratingTo = migratingTo;
+ }
await this.xrpc("com.atproto.server.deactivateAccount", {
httpMethod: "POST",
+ body,
});
apiLog(
"POST",
@@ -340,6 +347,7 @@ export class AtprotoClient {
{
durationMs: Date.now() - start,
success: true,
+ migratingTo,
},
);
} catch (e) {
@@ -352,6 +360,7 @@ export class AtprotoClient {
error: err.message,
errorCode: err.error,
status: err.status,
+ migratingTo,
},
);
throw e;
diff --git a/frontend/src/lib/migration/flow.svelte.ts b/frontend/src/lib/migration/flow.svelte.ts
index 73a2fc4..c21ea80 100644
--- a/frontend/src/lib/migration/flow.svelte.ts
+++ b/frontend/src/lib/migration/flow.svelte.ts
@@ -906,22 +906,11 @@ export function createOutboundMigrationFlow() {
setProgress({ currentOperation: "Deactivating old account..." });
try {
- await localClient.deactivateAccount();
+ await localClient.deactivateAccount(state.targetPdsUrl);
setProgress({ deactivated: true });
} catch {
}
- if (state.localDid.startsWith("did:web:")) {
- setProgress({
- currentOperation: "Updating DID document forwarding...",
- });
- try {
- await localClient.updateMigrationForwarding(state.targetPdsUrl);
- } catch (e) {
- console.warn("Failed to update migration forwarding:", e);
- }
- }
-
setStep("success");
clearMigrationState();
} catch (e) {
diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json
index c5994d8..806ea9a 100644
--- a/frontend/src/locales/en.json
+++ b/frontend/src/locales/en.json
@@ -87,6 +87,7 @@
"didPlcHint": "Portable identity managed by PLC Directory",
"didWeb": "did:web",
"didWebHint": "Identity hosted on this PDS (read warning below)",
+ "didWebDisabledHint": "Not available on this PDS - use did:plc or bring your own did:web",
"didWebBYOD": "did:web (BYOD)",
"didWebBYODHint": "Bring your own domain",
"didWebWarningTitle": "Important: Understand the trade-offs",
@@ -175,7 +176,46 @@
"navDelegation": "Delegation",
"navDelegationDesc": "Manage account controllers and delegated accounts",
"navAdmin": "Admin Panel",
- "navAdminDesc": "Server stats and admin operations"
+ "navAdminDesc": "Server stats and admin operations",
+ "navDidDocument": "DID Document",
+ "navDidDocumentDesc": "Manage your DID document for external migrations",
+ "migrated": "Migrated",
+ "migratedTitle": "Account Migrated",
+ "migratedMessage": "Your account has migrated to {pds}. Your DID document is still hosted here, and you can update it for future migrations.",
+ "navMigrateAgain": "Migrate Again",
+ "navMigrateAgainDesc": "Move to another PDS and update your DID document"
+ },
+ "didEditor": {
+ "title": "DID Document Editor",
+ "preview": "Current DID Document",
+ "verificationMethods": "Verification Methods",
+ "verificationMethodsDesc": "Signing keys that can act on behalf of your DID. When you migrate to a new PDS, add their signing key here.",
+ "addKey": "Add Key",
+ "removeKey": "Remove",
+ "keyId": "Key ID",
+ "keyIdPlaceholder": "#atproto",
+ "publicKey": "Public Key (Multibase)",
+ "publicKeyPlaceholder": "zQ3sh...",
+ "noKeys": "No verification methods configured. Using the local PDS key.",
+ "alsoKnownAs": "Also Known As",
+ "alsoKnownAsDesc": "Handles that point to your DID. Update this when your handle changes on a new PDS.",
+ "addHandle": "Add Handle",
+ "removeHandle": "Remove",
+ "handle": "Handle",
+ "handlePlaceholder": "at://handle.newpds.com",
+ "noHandles": "No handles configured. Using the local handle.",
+ "serviceEndpoint": "Service Endpoint",
+ "serviceEndpointDesc": "The PDS that currently hosts your account data. Update this when migrating.",
+ "currentPds": "Current PDS URL",
+ "save": "Save Changes",
+ "saving": "Saving...",
+ "success": "DID document updated successfully",
+ "saveFailed": "Failed to save DID document",
+ "loadFailed": "Failed to load DID document",
+ "invalidMultibase": "Public key must be a valid multibase string starting with 'z'",
+ "invalidHandle": "Handle must be an at:// URI (e.g., at://handle.example.com)",
+ "helpTitle": "What is this?",
+ "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)."
},
"settings": {
"title": "Account Settings",
@@ -792,6 +832,7 @@
"didPlcHint": "Portable identity managed by PLC Directory",
"didWeb": "did:web",
"didWebHint": "Identity hosted on this PDS (read warning below)",
+ "didWebDisabledHint": "Not available on this PDS - use did:plc or bring your own did:web",
"didWebBYOD": "did:web (BYOD)",
"didWebBYODHint": "Bring your own domain",
"didWebWarningTitle": "Important: Understand the trade-offs",
diff --git a/frontend/src/locales/fi.json b/frontend/src/locales/fi.json
index e258758..42e6adf 100644
--- a/frontend/src/locales/fi.json
+++ b/frontend/src/locales/fi.json
@@ -87,6 +87,7 @@
"didPlcHint": "Siirrettävä identiteetti, jota hallinnoi PLC Directory",
"didWeb": "did:web",
"didWebHint": "Identiteetti isännöidään tällä PDS:llä (lue alla oleva varoitus)",
+ "didWebDisabledHint": "Ei saatavilla tällä PDS:llä - käytä did:plc:tä tai tuo oma did:web",
"didWebBYOD": "did:web (oma verkkotunnus)",
"didWebBYODHint": "Käytä omaa verkkotunnustasi",
"didWebWarningTitle": "Tärkeää: Ymmärrä kompromissit",
@@ -175,7 +176,46 @@
"navDelegation": "Delegointi",
"navDelegationDesc": "Hallitse tilin ohjaajia ja delegoituja tilejä",
"navAdmin": "Ylläpitopaneeli",
- "navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot"
+ "navAdminDesc": "Palvelintilastot ja ylläpitotoiminnot",
+ "navDidDocument": "DID-dokumentti",
+ "navDidDocumentDesc": "Hallitse DID-dokumenttiasi ulkoisia siirtoja varten",
+ "migrated": "Siirretty",
+ "migratedTitle": "Tili siirretty",
+ "migratedMessage": "Tilisi on siirretty palvelimelle {pds}. DID-dokumenttisi isännöidään edelleen täällä, ja voit päivittää sen tulevia siirtoja varten.",
+ "navMigrateAgain": "Siirrä uudelleen",
+ "navMigrateAgainDesc": "Siirrä toiseen PDS:ään ja päivitä DID-dokumenttisi"
+ },
+ "didEditor": {
+ "title": "DID-dokumentin muokkain",
+ "preview": "Nykyinen DID-dokumentti",
+ "verificationMethods": "Vahvistusmenetelmät",
+ "verificationMethodsDesc": "Allekirjoitusavaimet, jotka voivat toimia DID:si puolesta. Kun siirryt uuteen PDS:ään, lisää niiden allekirjoitusavain tähän.",
+ "addKey": "Lisää avain",
+ "removeKey": "Poista",
+ "keyId": "Avaimen tunnus",
+ "keyIdPlaceholder": "#atproto",
+ "publicKey": "Julkinen avain (Multibase)",
+ "publicKeyPlaceholder": "zQ3sh...",
+ "noKeys": "Ei vahvistusmenetelmiä määritetty. Käytetään paikallista PDS-avainta.",
+ "alsoKnownAs": "Tunnetaan myös nimellä",
+ "alsoKnownAsDesc": "Kahvat, jotka osoittavat DID:iisi. Päivitä tämä, kun kahvasi muuttuu uudessa PDS:ssä.",
+ "addHandle": "Lisää kahva",
+ "removeHandle": "Poista",
+ "handle": "Kahva",
+ "handlePlaceholder": "at://kahva.uusipds.com",
+ "noHandles": "Ei kahvoja määritetty. Käytetään paikallista kahvaa.",
+ "serviceEndpoint": "Palvelupäätepiste",
+ "serviceEndpointDesc": "PDS, joka tällä hetkellä isännöi tilitietojasi. Päivitä tämä siirron yhteydessä.",
+ "currentPds": "Nykyinen PDS-URL",
+ "save": "Tallenna muutokset",
+ "saving": "Tallennetaan...",
+ "success": "DID-dokumentti päivitetty onnistuneesti",
+ "saveFailed": "DID-dokumentin tallennus epäonnistui",
+ "loadFailed": "DID-dokumentin lataus epäonnistui",
+ "invalidMultibase": "Julkisen avaimen on oltava kelvollinen multibase-merkkijono, joka alkaa 'z':llä",
+ "invalidHandle": "Kahvan on oltava at://-URI (esim. at://kahva.esimerkki.com)",
+ "helpTitle": "Mikä tämä on?",
+ "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)."
},
"settings": {
"title": "Tilin asetukset",
@@ -817,6 +857,7 @@
"didPlcHint": "Siirrettävä identiteetti, jota hallinnoi PLC Directory",
"didWeb": "did:web",
"didWebHint": "Tällä PDS:llä isännöity identiteetti (lue varoitus alla)",
+ "didWebDisabledHint": "Ei saatavilla tällä PDS:llä - käytä did:plc:tä tai tuo oma did:web",
"didWebBYOD": "did:web (BYOD)",
"didWebBYODHint": "Tuo oma verkkotunnuksesi",
"didWebWarningTitle": "Tärkeää: Ymmärrä kompromissit",
diff --git a/frontend/src/locales/ja.json b/frontend/src/locales/ja.json
index cf62d21..6f55e68 100644
--- a/frontend/src/locales/ja.json
+++ b/frontend/src/locales/ja.json
@@ -87,6 +87,7 @@
"didPlcHint": "PLC ディレクトリで管理されるポータブルアイデンティティ",
"didWeb": "did:web",
"didWebHint": "この PDS でホストされるアイデンティティ(下記の警告をお読みください)",
+ "didWebDisabledHint": "この PDS では利用できません - did:plc を使用するか、独自の did:web を持ち込んでください",
"didWebBYOD": "did:web (自前ドメイン)",
"didWebBYODHint": "独自ドメインを持ち込む",
"didWebWarningTitle": "重要: トレードオフをご理解ください",
@@ -175,7 +176,34 @@
"navDelegation": "委任",
"navDelegationDesc": "アカウントコントローラーと委任アカウントを管理",
"navAdmin": "管理パネル",
- "navAdminDesc": "サーバー統計と管理操作"
+ "navAdminDesc": "サーバー統計と管理操作",
+ "navDidDocument": "DID ドキュメント",
+ "navDidDocumentDesc": "DID ドキュメントとキーを管理",
+ "migrated": "移行済み",
+ "migratedTitle": "アカウント移行済み",
+ "migratedMessage": "アカウントは {pds} に移行されました。DID ドキュメントは引き続きここでホストされています。",
+ "navMigrateAgain": "再移行",
+ "navMigrateAgainDesc": "別の PDS に移行して DID ドキュメントを更新"
+ },
+ "didEditor": {
+ "title": "DID ドキュメントエディター",
+ "preview": "現在の DID ドキュメント",
+ "verificationMethods": "検証方法(署名キー)",
+ "addKey": "キーを追加",
+ "removeKey": "削除",
+ "keyId": "キー ID",
+ "keyIdPlaceholder": "#atproto",
+ "publicKey": "公開キー(Multibase)",
+ "publicKeyPlaceholder": "zQ3sh...",
+ "alsoKnownAs": "別名(ハンドル)",
+ "addHandle": "ハンドルを追加",
+ "handlePlaceholder": "at://handle.pds.com",
+ "serviceEndpoint": "サービスエンドポイント(現在の PDS)",
+ "save": "変更を保存",
+ "saving": "保存中...",
+ "success": "DID ドキュメントを更新しました",
+ "helpTitle": "これは何ですか?",
+ "helpText": "別の PDS に移行すると、その PDS が新しい署名キーを生成します。ここで DID ドキュメントを更新して、新しいキーと場所を指すようにしてください。"
},
"settings": {
"title": "アカウント設定",
@@ -817,6 +845,7 @@
"didPlcHint": "PLC Directoryで管理されるポータブルなアイデンティティ",
"didWeb": "did:web",
"didWebHint": "このPDSでホストされるアイデンティティ(以下の警告を参照)",
+ "didWebDisabledHint": "この PDS では利用できません - did:plc を使用するか、独自の did:web を持ち込んでください",
"didWebBYOD": "did:web(BYOD)",
"didWebBYODHint": "独自ドメインを持ち込む",
"didWebWarningTitle": "重要:トレードオフを理解する",
diff --git a/frontend/src/locales/ko.json b/frontend/src/locales/ko.json
index a5d5ef0..b4ec653 100644
--- a/frontend/src/locales/ko.json
+++ b/frontend/src/locales/ko.json
@@ -87,6 +87,7 @@
"didPlcHint": "PLC 디렉토리에서 관리하는 이동 가능한 ID",
"didWeb": "did:web",
"didWebHint": "이 PDS에서 호스팅되는 ID (아래 경고 참조)",
+ "didWebDisabledHint": "이 PDS에서 사용할 수 없음 - did:plc를 사용하거나 자체 did:web을 가져오세요",
"didWebBYOD": "did:web (자체 도메인)",
"didWebBYODHint": "자체 도메인 사용",
"didWebWarningTitle": "중요: 장단점을 이해하세요",
@@ -175,7 +176,34 @@
"navDelegation": "위임",
"navDelegationDesc": "계정 컨트롤러 및 위임된 계정 관리",
"navAdmin": "관리 패널",
- "navAdminDesc": "서버 통계 및 관리 작업"
+ "navAdminDesc": "서버 통계 및 관리 작업",
+ "navDidDocument": "DID 문서",
+ "navDidDocumentDesc": "DID 문서 및 키 관리",
+ "migrated": "마이그레이션됨",
+ "migratedTitle": "계정 마이그레이션됨",
+ "migratedMessage": "계정이 {pds}로 마이그레이션되었습니다. DID 문서는 여전히 여기에서 호스팅됩니다.",
+ "navMigrateAgain": "다시 마이그레이션",
+ "navMigrateAgainDesc": "다른 PDS로 이동하고 DID 문서 업데이트"
+ },
+ "didEditor": {
+ "title": "DID 문서 편집기",
+ "preview": "현재 DID 문서",
+ "verificationMethods": "검증 방법 (서명 키)",
+ "addKey": "키 추가",
+ "removeKey": "삭제",
+ "keyId": "키 ID",
+ "keyIdPlaceholder": "#atproto",
+ "publicKey": "공개 키 (Multibase)",
+ "publicKeyPlaceholder": "zQ3sh...",
+ "alsoKnownAs": "다른 이름 (핸들)",
+ "addHandle": "핸들 추가",
+ "handlePlaceholder": "at://handle.pds.com",
+ "serviceEndpoint": "서비스 엔드포인트 (현재 PDS)",
+ "save": "변경사항 저장",
+ "saving": "저장 중...",
+ "success": "DID 문서가 업데이트되었습니다",
+ "helpTitle": "이것은 무엇인가요?",
+ "helpText": "다른 PDS로 마이그레이션하면 해당 PDS가 새 서명 키를 생성합니다. 여기에서 DID 문서를 업데이트하여 새 키와 위치를 가리키도록 하세요."
},
"settings": {
"title": "계정 설정",
@@ -817,6 +845,7 @@
"didPlcHint": "PLC Directory에서 관리하는 이동 가능한 아이덴티티",
"didWeb": "did:web",
"didWebHint": "이 PDS에서 호스팅되는 아이덴티티 (아래 경고 읽기)",
+ "didWebDisabledHint": "이 PDS에서 사용할 수 없음 - did:plc를 사용하거나 자체 did:web을 가져오세요",
"didWebBYOD": "did:web (BYOD)",
"didWebBYODHint": "자체 도메인 사용",
"didWebWarningTitle": "중요: 장단점 이해하기",
diff --git a/frontend/src/locales/sv.json b/frontend/src/locales/sv.json
index db5cfa5..f605bc7 100644
--- a/frontend/src/locales/sv.json
+++ b/frontend/src/locales/sv.json
@@ -87,6 +87,7 @@
"didPlcHint": "Portabel identitet hanterad av PLC Directory",
"didWeb": "did:web",
"didWebHint": "Identitet lagrad på denna PDS (läs varningen nedan)",
+ "didWebDisabledHint": "Inte tillgänglig på denna PDS - använd did:plc eller ta med din egen did:web",
"didWebBYOD": "did:web (egen domän)",
"didWebBYODHint": "Använd din egen domän",
"didWebWarningTitle": "Viktigt: Förstå avvägningarna",
@@ -175,7 +176,34 @@
"navDelegation": "Delegering",
"navDelegationDesc": "Hantera kontokontrollanter och delegerade konton",
"navAdmin": "Adminpanel",
- "navAdminDesc": "Serverstatistik och administratörsoperationer"
+ "navAdminDesc": "Serverstatistik och administratörsoperationer",
+ "navDidDocument": "DID-dokument",
+ "navDidDocumentDesc": "Hantera ditt DID-dokument och nycklar",
+ "migrated": "Flyttad",
+ "migratedTitle": "Konto flyttat",
+ "migratedMessage": "Ditt konto har flyttats till {pds}. Ditt DID-dokument finns fortfarande här.",
+ "navMigrateAgain": "Flytta igen",
+ "navMigrateAgainDesc": "Flytta till en annan PDS och uppdatera ditt DID-dokument"
+ },
+ "didEditor": {
+ "title": "DID-dokumentredigerare",
+ "preview": "Nuvarande DID-dokument",
+ "verificationMethods": "Verifieringsmetoder (signeringsnycklar)",
+ "addKey": "Lägg till nyckel",
+ "removeKey": "Ta bort",
+ "keyId": "Nyckel-ID",
+ "keyIdPlaceholder": "#atproto",
+ "publicKey": "Publik nyckel (Multibase)",
+ "publicKeyPlaceholder": "zQ3sh...",
+ "alsoKnownAs": "Även känd som (användarnamn)",
+ "addHandle": "Lägg till användarnamn",
+ "handlePlaceholder": "at://handle.pds.com",
+ "serviceEndpoint": "Tjänstslutpunkt (nuvarande PDS)",
+ "save": "Spara ändringar",
+ "saving": "Sparar...",
+ "success": "DID-dokumentet har uppdaterats",
+ "helpTitle": "Vad är detta?",
+ "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."
},
"settings": {
"title": "Kontoinställningar",
@@ -817,6 +845,7 @@
"didPlcHint": "Portabel identitet som hanteras av PLC Directory",
"didWeb": "did:web",
"didWebHint": "Identitet som lagras på denna PDS (läs varningen nedan)",
+ "didWebDisabledHint": "Inte tillgänglig på denna PDS - använd did:plc eller ta med din egen did:web",
"didWebBYOD": "did:web (BYOD)",
"didWebBYODHint": "Ta med din egen domän",
"didWebWarningTitle": "Viktigt: Förstå kompromisserna",
diff --git a/frontend/src/locales/zh.json b/frontend/src/locales/zh.json
index 1161cb4..49fa3bc 100644
--- a/frontend/src/locales/zh.json
+++ b/frontend/src/locales/zh.json
@@ -87,6 +87,7 @@
"didPlcHint": "由 PLC 目录管理的可迁移身份",
"didWeb": "did:web",
"didWebHint": "托管在此 PDS 上的身份(请阅读下方警告)",
+ "didWebDisabledHint": "此 PDS 不可用 - 请使用 did:plc 或携带自己的 did:web",
"didWebBYOD": "did:web(自带域名)",
"didWebBYODHint": "使用您自己的域名",
"didWebWarningTitle": "重要提示:了解利弊",
@@ -175,7 +176,34 @@
"navDelegation": "账户委托",
"navDelegationDesc": "管理控制者和委托账户",
"navAdmin": "管理后台",
- "navAdminDesc": "服务器统计和管理操作"
+ "navAdminDesc": "服务器统计和管理操作",
+ "navDidDocument": "DID 文档",
+ "navDidDocumentDesc": "管理您的 DID 文档和密钥",
+ "migrated": "已迁移",
+ "migratedTitle": "账户已迁移",
+ "migratedMessage": "您的账户已迁移到 {pds}。您的 DID 文档仍在此处托管。",
+ "navMigrateAgain": "再次迁移",
+ "navMigrateAgainDesc": "迁移到另一个 PDS 并更新您的 DID 文档"
+ },
+ "didEditor": {
+ "title": "DID 文档编辑器",
+ "preview": "当前 DID 文档",
+ "verificationMethods": "验证方法(签名密钥)",
+ "addKey": "添加密钥",
+ "removeKey": "删除",
+ "keyId": "密钥 ID",
+ "keyIdPlaceholder": "#atproto",
+ "publicKey": "公钥(Multibase)",
+ "publicKeyPlaceholder": "zQ3sh...",
+ "alsoKnownAs": "别名(用户名)",
+ "addHandle": "添加用户名",
+ "handlePlaceholder": "at://handle.pds.com",
+ "serviceEndpoint": "服务端点(当前 PDS)",
+ "save": "保存更改",
+ "saving": "保存中...",
+ "success": "DID 文档已更新",
+ "helpTitle": "这是什么?",
+ "helpText": "当您迁移到另一个 PDS 时,该 PDS 会生成新的签名密钥。在此处更新您的 DID 文档,使其指向您的新密钥和位置。"
},
"settings": {
"title": "账户设置",
@@ -792,6 +820,7 @@
"didPlcHint": "由 PLC 目录管理的可迁移身份",
"didWeb": "did:web",
"didWebHint": "托管在此 PDS 上的身份(请阅读下方警告)",
+ "didWebDisabledHint": "此 PDS 不可用 - 请使用 did:plc 或携带自己的 did:web",
"didWebBYOD": "did:web(自带域名)",
"didWebBYODHint": "使用您自己的域名",
"didWebWarningTitle": "重要:了解利弊",
diff --git a/frontend/src/routes/Dashboard.svelte b/frontend/src/routes/Dashboard.svelte
index eaad876..0aca5b8 100644
--- a/frontend/src/routes/Dashboard.svelte
+++ b/frontend/src/routes/Dashboard.svelte
@@ -99,7 +99,12 @@
- {#if auth.session.status === 'deactivated' || auth.session.active === false}
+ {#if auth.session.status === 'migrated'}
+
+
{$_('dashboard.migratedTitle')}
+
{$_('dashboard.migratedMessage', { values: { pds: auth.session.migratedToPds || 'another PDS' } })}
+
+ {:else if auth.session.status === 'deactivated' || auth.session.active === false}
@@ -374,6 +400,12 @@
border: 1px solid var(--warning-border);
}
+ .badge.migrated {
+ background: var(--info-bg, #e0f2fe);
+ color: var(--info-text, #0369a1);
+ border: 1px solid var(--info-border, #7dd3fc);
+ }
+
.nav-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@@ -440,4 +472,36 @@
color: var(--warning-text);
font-size: var(--text-sm);
}
+
+ .migrated-banner {
+ background: var(--info-bg, #e0f2fe);
+ border: 1px solid var(--info-border, #7dd3fc);
+ border-radius: var(--radius-xl);
+ padding: var(--space-5) var(--space-6);
+ margin-bottom: var(--space-7);
+ }
+
+ .migrated-banner strong {
+ color: var(--info-text, #0369a1);
+ font-size: var(--text-base);
+ }
+
+ .migrated-banner p {
+ margin: var(--space-3) 0 0 0;
+ color: var(--info-text, #0369a1);
+ font-size: var(--text-sm);
+ }
+
+ .nav-card.migrated-card {
+ border-color: var(--info-border, #7dd3fc);
+ background: linear-gradient(135deg, var(--bg-card) 0%, var(--info-bg, #e0f2fe) 100%);
+ }
+
+ .nav-card.migrated-card:hover {
+ box-shadow: 0 2px 12px var(--info-bg, #e0f2fe);
+ }
+
+ .nav-card.migrated-card h3 {
+ color: var(--info-text, #0369a1);
+ }
diff --git a/frontend/src/routes/DidDocumentEditor.svelte b/frontend/src/routes/DidDocumentEditor.svelte
new file mode 100644
index 0000000..0a2cf34
--- /dev/null
+++ b/frontend/src/routes/DidDocumentEditor.svelte
@@ -0,0 +1,457 @@
+
+
+
+
+
+ {#if message}
+
{message.text}
+ {/if}
+
+ {#if loading}
+
{$_('common.loading')}
+ {:else}
+
+
{$_('didEditor.helpTitle')}
+
{$_('didEditor.helpText')}
+
+
+
+ {$_('didEditor.preview')}
+ {JSON.stringify(didDocument, null, 2)}
+
+
+
+ {$_('didEditor.verificationMethods')}
+ {$_('didEditor.verificationMethodsDesc')}
+
+ {#if verificationMethods.length > 0}
+
+ {:else}
+ {$_('didEditor.noKeys')}
+ {/if}
+
+
+
+
+
+ {$_('didEditor.alsoKnownAs')}
+ {$_('didEditor.alsoKnownAsDesc')}
+
+ {#if alsoKnownAs.length > 0}
+
+ {#each alsoKnownAs as handle, index}
+ -
+ {handle}
+
+
+ {/each}
+
+ {:else}
+ {$_('didEditor.noHandles')}
+ {/if}
+
+
+
+
+
+
+
+
+
+ {/if}
+
+
+
diff --git a/frontend/src/routes/Register.svelte b/frontend/src/routes/Register.svelte
index 76f3654..4839921 100644
--- a/frontend/src/routes/Register.svelte
+++ b/frontend/src/routes/Register.svelte
@@ -13,6 +13,7 @@
availableUserDomains: string[]
inviteCodeRequired: boolean
availableCommsChannels?: string[]
+ selfHostedDidWebEnabled?: boolean
} | null>(null)
let loadingServerInfo = $state(true)
let serverInfoLoaded = false
@@ -237,11 +238,15 @@
-
-
-
+
+
{$_('registerPasskey.didWeb')}
- {$_('registerPasskey.didWebHint')}
+ {#if serverInfo?.selfHostedDidWebEnabled === false}
+ {$_('registerPasskey.didWebDisabledHint')}
+ {:else}
+ {$_('registerPasskey.didWebHint')}
+ {/if}
@@ -582,6 +587,15 @@
color: var(--text-secondary);
}
+ .radio-label.disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .radio-hint.disabled-hint {
+ color: var(--warning-text);
+ }
+
.warning-box {
margin-top: var(--space-5);
padding: var(--space-5);
diff --git a/frontend/src/tests/mocks.ts b/frontend/src/tests/mocks.ts
index d63f8a1..75ccfcc 100644
--- a/frontend/src/tests/mocks.ts
+++ b/frontend/src/tests/mocks.ts
@@ -144,6 +144,7 @@ export const mockData = {
privacyPolicy: "https://example.com/privacy",
termsOfService: "https://example.com/tos",
},
+ selfHostedDidWebEnabled: true,
}),
describeRepo: (did: string) => ({
handle: "testuser.test.tranquil.dev",
diff --git a/migrations/20251244_did_web_overrides.sql b/migrations/20251244_did_web_overrides.sql
new file mode 100644
index 0000000..3a5e6c1
--- /dev/null
+++ b/migrations/20251244_did_web_overrides.sql
@@ -0,0 +1,7 @@
+CREATE TABLE IF NOT EXISTS did_web_overrides (
+ user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
+ verification_methods JSONB NOT NULL DEFAULT '[]',
+ also_known_as TEXT[] NOT NULL DEFAULT '{}',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
diff --git a/src/api/identity/account.rs b/src/api/identity/account.rs
index cb3bcd5..dbe8c90 100644
--- a/src/api/identity/account.rs
+++ b/src/api/identity/account.rs
@@ -355,6 +355,16 @@ pub async fn create_account(
let did_type = input.did_type.as_deref().unwrap_or("plc");
let did = match did_type {
"web" => {
+ if !crate::api::server::meta::is_self_hosted_did_web_enabled() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "SelfHostedDidWebDisabled",
+ "message": "This PDS does not offer self-hosted did:web identities. Please use did:plc or bring your own did:web."
+ })),
+ )
+ .into_response();
+ }
let subdomain_host = format!("{}.{}", input.handle, hostname);
let encoded_subdomain = subdomain_host.replace(':', "%3A");
let self_hosted_did = format!("did:web:{}", encoded_subdomain);
diff --git a/src/api/identity/did.rs b/src/api/identity/did.rs
index 7cfde31..b7d21b0 100644
--- a/src/api/identity/did.rs
+++ b/src/api/identity/did.rs
@@ -11,10 +11,19 @@ use base64::Engine;
use k256::SecretKey;
use k256::elliptic_curve::sec1::ToEncodedPoint;
use reqwest;
-use serde::Deserialize;
+use serde::{Deserialize, Serialize};
use serde_json::json;
use tracing::{error, warn};
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DidWebVerificationMethod {
+ pub id: String,
+ #[serde(rename = "type")]
+ pub method_type: String,
+ pub public_key_multibase: String,
+}
+
#[derive(Deserialize)]
pub struct ResolveHandleParams {
pub handle: String,
@@ -170,6 +179,54 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
)
.into_response();
}
+
+ let overrides = sqlx::query!(
+ "SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
+ user_id
+ )
+ .fetch_optional(&state.db)
+ .await
+ .ok()
+ .flatten();
+
+ let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
+
+ if let Some(ref ovr) = overrides {
+ if let Ok(parsed) =
+ serde_json::from_value::>(ovr.verification_methods.clone())
+ {
+ if !parsed.is_empty() {
+ let also_known_as = if !ovr.also_known_as.is_empty() {
+ ovr.also_known_as.clone()
+ } else {
+ vec![format!("at://{}", full_handle)]
+ };
+
+ return Json(json!({
+ "@context": [
+ "https://www.w3.org/ns/did/v1",
+ "https://w3id.org/security/multikey/v1",
+ "https://w3id.org/security/suites/secp256k1-2019/v1"
+ ],
+ "id": did,
+ "alsoKnownAs": also_known_as,
+ "verificationMethod": parsed.iter().map(|m| json!({
+ "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
+ "type": m.method_type,
+ "controller": did,
+ "publicKeyMultibase": m.public_key_multibase
+ })).collect::>(),
+ "service": [{
+ "id": "#atproto_pds",
+ "type": "AtprotoPersonalDataServer",
+ "serviceEndpoint": service_endpoint
+ }]
+ }))
+ .into_response();
+ }
+ }
+ }
+
let key_row = sqlx::query!(
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
user_id
@@ -206,7 +263,17 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
.into_response();
}
};
- let service_endpoint = migrated_to_pds.unwrap_or_else(|| format!("https://{}", hostname));
+
+ let also_known_as = if let Some(ref ovr) = overrides {
+ if !ovr.also_known_as.is_empty() {
+ ovr.also_known_as.clone()
+ } else {
+ vec![format!("at://{}", full_handle)]
+ }
+ } else {
+ vec![format!("at://{}", full_handle)]
+ };
+
Json(json!({
"@context": [
"https://www.w3.org/ns/did/v1",
@@ -214,7 +281,7 @@ async fn serve_subdomain_did_doc(state: &AppState, handle: &str, hostname: &str)
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": did,
- "alsoKnownAs": [format!("at://{}", handle)],
+ "alsoKnownAs": also_known_as,
"verificationMethod": [{
"id": format!("{}#atproto", did),
"type": "Multikey",
@@ -272,6 +339,54 @@ pub async fn user_did_doc(State(state): State, Path(handle): Path>(ovr.verification_methods.clone())
+ {
+ if !parsed.is_empty() {
+ let also_known_as = if !ovr.also_known_as.is_empty() {
+ ovr.also_known_as.clone()
+ } else {
+ vec![format!("at://{}", full_handle)]
+ };
+
+ return Json(json!({
+ "@context": [
+ "https://www.w3.org/ns/did/v1",
+ "https://w3id.org/security/multikey/v1",
+ "https://w3id.org/security/suites/secp256k1-2019/v1"
+ ],
+ "id": did,
+ "alsoKnownAs": also_known_as,
+ "verificationMethod": parsed.iter().map(|m| json!({
+ "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
+ "type": m.method_type,
+ "controller": did,
+ "publicKeyMultibase": m.public_key_multibase
+ })).collect::>(),
+ "service": [{
+ "id": "#atproto_pds",
+ "type": "AtprotoPersonalDataServer",
+ "serviceEndpoint": service_endpoint
+ }]
+ }))
+ .into_response();
+ }
+ }
+ }
+
let key_row = sqlx::query!(
"SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
user_id
@@ -308,7 +423,17 @@ pub async fn user_did_doc(State(state): State, Path(handle): Path, Path(handle): Path max_size {
diff --git a/src/api/repo/record/batch.rs b/src/api/repo/record/batch.rs
index e1db768..d04b450 100644
--- a/src/api/repo/record/batch.rs
+++ b/src/api/repo/record/batch.rs
@@ -129,6 +129,19 @@ pub async fn apply_writes(
)
.into_response();
}
+ if crate::util::is_account_migrated(&state.db, &did)
+ .await
+ .unwrap_or(false)
+ {
+ return (
+ StatusCode::FORBIDDEN,
+ Json(json!({
+ "error": "AccountMigrated",
+ "message": "Account has been migrated to another PDS. Repo operations are not allowed."
+ })),
+ )
+ .into_response();
+ }
let is_verified = has_verified_comms_channel(&state.db, &did)
.await
.unwrap_or(false);
diff --git a/src/api/repo/record/delete.rs b/src/api/repo/record/delete.rs
index 28936a3..5aa1f82 100644
--- a/src/api/repo/record/delete.rs
+++ b/src/api/repo/record/delete.rs
@@ -57,6 +57,20 @@ pub async fn delete_record(
return e;
}
+ if crate::util::is_account_migrated(&state.db, &auth.did)
+ .await
+ .unwrap_or(false)
+ {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "AccountMigrated",
+ "message": "Account has been migrated. Repo operations are not allowed."
+ })),
+ )
+ .into_response();
+ }
+
let did = auth.did;
let user_id = auth.user_id;
let current_root_cid = auth.current_root_cid;
diff --git a/src/api/repo/record/write.rs b/src/api/repo/record/write.rs
index 783e063..a0e9819 100644
--- a/src/api/repo/record/write.rs
+++ b/src/api/repo/record/write.rs
@@ -102,6 +102,19 @@ pub async fn prepare_repo_write(
)
.into_response());
}
+ if crate::util::is_account_migrated(&state.db, &auth_user.did)
+ .await
+ .unwrap_or(false)
+ {
+ return Err((
+ StatusCode::FORBIDDEN,
+ Json(json!({
+ "error": "AccountMigrated",
+ "message": "Account has been migrated to another PDS. Repo operations are not allowed."
+ })),
+ )
+ .into_response());
+ }
let is_verified = has_verified_comms_channel(&state.db, &auth_user.did)
.await
.unwrap_or(false);
diff --git a/src/api/server/account_status.rs b/src/api/server/account_status.rs
index 904d4ec..1b1c89f 100644
--- a/src/api/server/account_status.rs
+++ b/src/api/server/account_status.rs
@@ -568,6 +568,7 @@ pub async fn activate_account(
#[serde(rename_all = "camelCase")]
pub struct DeactivateAccountInput {
pub delete_after: Option,
+ pub migrating_to: Option,
}
pub async fn deactivate_account(
@@ -617,32 +618,63 @@ pub async fn deactivate_account(
.map(|dt| dt.with_timezone(&chrono::Utc));
let did = auth_user.did;
+
+ let migrating_to = if let Some(ref url) = input.migrating_to {
+ let url = url.trim().trim_end_matches('/');
+ if url.is_empty() || !did.starts_with("did:web:") {
+ None
+ } else {
+ if !url.starts_with("https://") {
+ return ApiError::InvalidRequest("migratingTo must start with https://".into())
+ .into_response();
+ }
+ Some(url.to_string())
+ }
+ } else {
+ None
+ };
+
let handle = sqlx::query_scalar!("SELECT handle FROM users WHERE did = $1", did)
.fetch_optional(&state.db)
.await
.ok()
.flatten();
- let result = sqlx::query!(
- "UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
- did,
- delete_after
- )
- .execute(&state.db)
- .await;
+
+ let result = if let Some(ref pds_url) = migrating_to {
+ sqlx::query!(
+ "UPDATE users SET deactivated_at = NOW(), delete_after = $2, migrated_to_pds = $3, migrated_at = NOW() WHERE did = $1",
+ did,
+ delete_after,
+ pds_url
+ )
+ .execute(&state.db)
+ .await
+ } else {
+ sqlx::query!(
+ "UPDATE users SET deactivated_at = NOW(), delete_after = $2 WHERE did = $1",
+ did,
+ delete_after
+ )
+ .execute(&state.db)
+ .await
+ };
+
+ let status = if migrating_to.is_some() {
+ "migrated"
+ } else {
+ "deactivated"
+ };
+
match result {
Ok(_) => {
if let Some(ref h) = handle {
let _ = state.cache.delete(&format!("handle:{}", h)).await;
}
- if let Err(e) = crate::api::repo::record::sequence_account_event(
- &state,
- &did,
- false,
- Some("deactivated"),
- )
- .await
+ if let Err(e) =
+ crate::api::repo::record::sequence_account_event(&state, &did, false, Some(status))
+ .await
{
- warn!("Failed to sequence account deactivation event: {}", e);
+ warn!("Failed to sequence account {} event: {}", status, e);
}
(StatusCode::OK, Json(json!({}))).into_response()
}
diff --git a/src/api/server/meta.rs b/src/api/server/meta.rs
index 063c956..b1b6f6b 100644
--- a/src/api/server/meta.rs
+++ b/src/api/server/meta.rs
@@ -24,6 +24,12 @@ pub async fn robots_txt() -> impl IntoResponse {
"# Hello!\n\n# Crawling the public API is allowed\nUser-agent: *\nAllow: /\n",
)
}
+pub fn is_self_hosted_did_web_enabled() -> bool {
+ std::env::var("ENABLE_SELF_HOSTED_DID_WEB")
+ .map(|v| v != "false" && v != "0")
+ .unwrap_or(true)
+}
+
pub async fn describe_server() -> impl IntoResponse {
let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let domains_str =
@@ -53,7 +59,8 @@ pub async fn describe_server() -> impl IntoResponse {
"links": links,
"contact": contact,
"version": env!("CARGO_PKG_VERSION"),
- "availableCommsChannels": get_available_comms_channels()
+ "availableCommsChannels": get_available_comms_channels(),
+ "selfHostedDidWebEnabled": is_self_hosted_did_web_enabled()
}))
}
pub async fn health(State(state): State) -> impl IntoResponse {
diff --git a/src/api/server/migration.rs b/src/api/server/migration.rs
index 096fe10..9dfa549 100644
--- a/src/api/server/migration.rs
+++ b/src/api/server/migration.rs
@@ -237,3 +237,370 @@ pub async fn clear_migration_forwarding(
}
}
}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct VerificationMethod {
+ pub id: String,
+ #[serde(rename = "type")]
+ pub method_type: String,
+ pub public_key_multibase: String,
+}
+
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateDidDocumentInput {
+ pub verification_methods: Option>,
+ pub also_known_as: Option>,
+ pub service_endpoint: Option,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateDidDocumentOutput {
+ pub success: bool,
+ pub did_document: serde_json::Value,
+}
+
+pub async fn update_did_document(
+ State(state): State,
+ headers: axum::http::HeaderMap,
+ Json(input): Json,
+) -> Response {
+ let extracted = match crate::auth::extract_auth_token_from_header(
+ headers.get("Authorization").and_then(|h| h.to_str().ok()),
+ ) {
+ Some(t) => t,
+ None => return ApiError::AuthenticationRequired.into_response(),
+ };
+ let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
+ let http_uri = format!(
+ "https://{}/xrpc/com.tranquil.account.updateDidDocument",
+ std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
+ );
+ let auth_user = match crate::auth::validate_token_with_dpop(
+ &state.db,
+ &extracted.token,
+ extracted.is_dpop,
+ dpop_proof,
+ "POST",
+ &http_uri,
+ true,
+ )
+ .await
+ {
+ Ok(user) => user,
+ Err(e) => return ApiError::from(e).into_response(),
+ };
+
+ if !auth_user.did.starts_with("did:web:") {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "InvalidRequest",
+ "message": "DID document updates are only available for did:web accounts"
+ })),
+ )
+ .into_response();
+ }
+
+ let user = match sqlx::query!(
+ "SELECT id, migrated_to_pds, handle FROM users WHERE did = $1",
+ auth_user.did
+ )
+ .fetch_optional(&state.db)
+ .await
+ {
+ Ok(Some(row)) => row,
+ Ok(None) => return ApiError::AccountNotFound.into_response(),
+ Err(e) => {
+ tracing::error!("DB error getting user: {:?}", e);
+ return ApiError::InternalError.into_response();
+ }
+ };
+
+ if user.migrated_to_pds.is_none() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "InvalidRequest",
+ "message": "DID document updates are only available for migrated accounts. Use the migration flow to migrate first."
+ })),
+ )
+ .into_response();
+ }
+
+ if let Some(ref methods) = input.verification_methods {
+ if methods.is_empty() {
+ return ApiError::InvalidRequest(
+ "verification_methods cannot be empty".into(),
+ )
+ .into_response();
+ }
+ for method in methods {
+ if method.id.is_empty() {
+ return ApiError::InvalidRequest("verification method id is required".into())
+ .into_response();
+ }
+ if method.method_type != "Multikey" {
+ return ApiError::InvalidRequest(
+ "verification method type must be 'Multikey'".into(),
+ )
+ .into_response();
+ }
+ if !method.public_key_multibase.starts_with('z') {
+ return ApiError::InvalidRequest(
+ "publicKeyMultibase must start with 'z' (base58btc)".into(),
+ )
+ .into_response();
+ }
+ if method.public_key_multibase.len() < 40 {
+ return ApiError::InvalidRequest(
+ "publicKeyMultibase appears too short for a valid key".into(),
+ )
+ .into_response();
+ }
+ }
+ }
+
+ if let Some(ref handles) = input.also_known_as {
+ for handle in handles {
+ if !handle.starts_with("at://") {
+ return ApiError::InvalidRequest(
+ "alsoKnownAs entries must be at:// URIs".into(),
+ )
+ .into_response();
+ }
+ }
+ }
+
+ if let Some(ref endpoint) = input.service_endpoint {
+ let endpoint = endpoint.trim();
+ if !endpoint.starts_with("https://") {
+ return ApiError::InvalidRequest(
+ "serviceEndpoint must start with https://".into(),
+ )
+ .into_response();
+ }
+ }
+
+ let verification_methods_json = input
+ .verification_methods
+ .as_ref()
+ .map(|v| serde_json::to_value(v).unwrap_or_default());
+
+ let also_known_as: Option> = input.also_known_as.clone();
+
+ let now = Utc::now();
+
+ let upsert_result = sqlx::query!(
+ r#"
+ INSERT INTO did_web_overrides (user_id, verification_methods, also_known_as, updated_at)
+ VALUES ($1, COALESCE($2, '[]'::jsonb), COALESCE($3, '{}'::text[]), $4)
+ ON CONFLICT (user_id) DO UPDATE SET
+ verification_methods = CASE WHEN $2 IS NOT NULL THEN $2 ELSE did_web_overrides.verification_methods END,
+ also_known_as = CASE WHEN $3 IS NOT NULL THEN $3 ELSE did_web_overrides.also_known_as END,
+ updated_at = $4
+ "#,
+ user.id,
+ verification_methods_json,
+ also_known_as.as_deref(),
+ now
+ )
+ .execute(&state.db)
+ .await;
+
+ if let Err(e) = upsert_result {
+ tracing::error!("DB error upserting did_web_overrides: {:?}", e);
+ return ApiError::InternalError.into_response();
+ }
+
+ if let Some(ref endpoint) = input.service_endpoint {
+ let endpoint_clean = endpoint.trim().trim_end_matches('/');
+ let update_result = sqlx::query!(
+ "UPDATE users SET migrated_to_pds = $1, migrated_at = $2 WHERE did = $3",
+ endpoint_clean,
+ now,
+ auth_user.did
+ )
+ .execute(&state.db)
+ .await;
+
+ if let Err(e) = update_result {
+ tracing::error!("DB error updating service endpoint: {:?}", e);
+ return ApiError::InternalError.into_response();
+ }
+ }
+
+ let did_doc = build_did_document(&state.db, &auth_user.did).await;
+
+ tracing::info!("Updated DID document for {}", auth_user.did);
+
+ (
+ StatusCode::OK,
+ Json(UpdateDidDocumentOutput {
+ success: true,
+ did_document: did_doc,
+ }),
+ )
+ .into_response()
+}
+
+pub async fn get_did_document(
+ State(state): State,
+ headers: axum::http::HeaderMap,
+) -> Response {
+ let extracted = match crate::auth::extract_auth_token_from_header(
+ headers.get("Authorization").and_then(|h| h.to_str().ok()),
+ ) {
+ Some(t) => t,
+ None => return ApiError::AuthenticationRequired.into_response(),
+ };
+ let dpop_proof = headers.get("DPoP").and_then(|h| h.to_str().ok());
+ let http_uri = format!(
+ "https://{}/xrpc/com.tranquil.account.getDidDocument",
+ std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
+ );
+ let auth_user = match crate::auth::validate_token_with_dpop(
+ &state.db,
+ &extracted.token,
+ extracted.is_dpop,
+ dpop_proof,
+ "GET",
+ &http_uri,
+ true,
+ )
+ .await
+ {
+ Ok(user) => user,
+ Err(e) => return ApiError::from(e).into_response(),
+ };
+
+ if !auth_user.did.starts_with("did:web:") {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(json!({
+ "error": "InvalidRequest",
+ "message": "This endpoint is only available for did:web accounts"
+ })),
+ )
+ .into_response();
+ }
+
+ let did_doc = build_did_document(&state.db, &auth_user.did).await;
+
+ (StatusCode::OK, Json(json!({ "didDocument": did_doc }))).into_response()
+}
+
+async fn build_did_document(db: &sqlx::PgPool, did: &str) -> serde_json::Value {
+ let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
+
+ let user = match sqlx::query!(
+ "SELECT id, handle, migrated_to_pds FROM users WHERE did = $1",
+ did
+ )
+ .fetch_optional(db)
+ .await
+ {
+ Ok(Some(row)) => row,
+ _ => {
+ return json!({
+ "error": "User not found"
+ });
+ }
+ };
+
+ let overrides = sqlx::query!(
+ "SELECT verification_methods, also_known_as FROM did_web_overrides WHERE user_id = $1",
+ user.id
+ )
+ .fetch_optional(db)
+ .await
+ .ok()
+ .flatten();
+
+ let service_endpoint = user
+ .migrated_to_pds
+ .unwrap_or_else(|| format!("https://{}", hostname));
+
+ if let Some(ref ovr) = overrides {
+ if let Ok(parsed) = serde_json::from_value::>(ovr.verification_methods.clone()) {
+ if !parsed.is_empty() {
+ let also_known_as = if !ovr.also_known_as.is_empty() {
+ ovr.also_known_as.clone()
+ } else {
+ vec![format!("at://{}", user.handle)]
+ };
+ return json!({
+ "@context": [
+ "https://www.w3.org/ns/did/v1",
+ "https://w3id.org/security/multikey/v1",
+ "https://w3id.org/security/suites/secp256k1-2019/v1"
+ ],
+ "id": did,
+ "alsoKnownAs": also_known_as,
+ "verificationMethod": parsed.iter().map(|m| json!({
+ "id": format!("{}{}", did, if m.id.starts_with('#') { m.id.clone() } else { format!("#{}", m.id) }),
+ "type": m.method_type,
+ "controller": did,
+ "publicKeyMultibase": m.public_key_multibase
+ })).collect::>(),
+ "service": [{
+ "id": "#atproto_pds",
+ "type": "AtprotoPersonalDataServer",
+ "serviceEndpoint": service_endpoint
+ }]
+ });
+ }
+ }
+ }
+
+ let key_row = sqlx::query!(
+ "SELECT key_bytes, encryption_version FROM user_keys WHERE user_id = $1",
+ user.id
+ )
+ .fetch_optional(db)
+ .await;
+
+ let public_key_multibase = match key_row {
+ Ok(Some(row)) => {
+ match crate::config::decrypt_key(&row.key_bytes, row.encryption_version) {
+ Ok(key_bytes) => crate::api::identity::did::get_public_key_multibase(&key_bytes)
+ .unwrap_or_else(|_| "error".to_string()),
+ Err(_) => "error".to_string(),
+ }
+ }
+ _ => "error".to_string(),
+ };
+
+ let also_known_as = if let Some(ref ovr) = overrides {
+ if !ovr.also_known_as.is_empty() {
+ ovr.also_known_as.clone()
+ } else {
+ vec![format!("at://{}", user.handle)]
+ }
+ } else {
+ vec![format!("at://{}", user.handle)]
+ };
+
+ json!({
+ "@context": [
+ "https://www.w3.org/ns/did/v1",
+ "https://w3id.org/security/multikey/v1",
+ "https://w3id.org/security/suites/secp256k1-2019/v1"
+ ],
+ "id": did,
+ "alsoKnownAs": also_known_as,
+ "verificationMethod": [{
+ "id": format!("{}#atproto", did),
+ "type": "Multikey",
+ "controller": did,
+ "publicKeyMultibase": public_key_multibase
+ }],
+ "service": [{
+ "id": "#atproto_pds",
+ "type": "AtprotoPersonalDataServer",
+ "serviceEndpoint": service_endpoint
+ }]
+ })
+}
diff --git a/src/api/server/mod.rs b/src/api/server/mod.rs
index 54cb913..f97edc1 100644
--- a/src/api/server/mod.rs
+++ b/src/api/server/mod.rs
@@ -27,7 +27,8 @@ pub use invite::{create_invite_code, create_invite_codes, get_account_invite_cod
pub use logo::get_logo;
pub use meta::{describe_server, health, robots_txt};
pub use migration::{
- clear_migration_forwarding, get_migration_status, update_migration_forwarding,
+ clear_migration_forwarding, get_did_document, get_migration_status, update_did_document,
+ update_migration_forwarding,
};
pub use passkey_account::{
complete_passkey_setup, create_passkey_account, recover_passkey_account,
diff --git a/src/api/server/session.rs b/src/api/server/session.rs
index 22354c2..dc35af5 100644
--- a/src/api/server/session.rs
+++ b/src/api/server/session.rs
@@ -104,7 +104,7 @@ pub async fn create_session(
r#"SELECT
u.id, u.did, u.handle, u.password_hash, u.email, u.deactivated_at, u.takedown_ref,
u.email_verified, u.discord_verified, u.telegram_verified, u.signal_verified,
- u.allow_legacy_login,
+ u.allow_legacy_login, u.migrated_to_pds,
u.preferred_comms_channel as "preferred_comms_channel: crate::comms::CommsChannel",
k.key_bytes, k.encryption_version,
(SELECT verified FROM user_totp WHERE did = u.did) as totp_enabled
@@ -276,9 +276,12 @@ pub async fn create_session(
}
}
let handle = full_handle(&row.handle, &pds_hostname);
+ let is_migrated = row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
let is_active = row.deactivated_at.is_none() && !is_takendown;
let status = if is_takendown {
Some("takendown".to_string())
+ } else if is_migrated {
+ Some("migrated".to_string())
} else if row.deactivated_at.is_some() {
Some("deactivated".to_string())
} else {
@@ -312,7 +315,7 @@ pub async fn get_session(
r#"SELECT
handle, email, email_verified, is_admin, deactivated_at, takedown_ref, preferred_locale,
preferred_comms_channel as "preferred_channel: crate::comms::CommsChannel",
- discord_verified, telegram_verified, signal_verified
+ discord_verified, telegram_verified, signal_verified, migrated_to_pds, migrated_at
FROM users WHERE did = $1"#,
auth_user.did
)
@@ -331,6 +334,8 @@ pub async fn get_session(
std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
let handle = full_handle(&row.handle, &pds_hostname);
let is_takendown = row.takedown_ref.is_some();
+ let is_migrated =
+ row.deactivated_at.is_some() && row.migrated_to_pds.is_some();
let is_active = row.deactivated_at.is_none() && !is_takendown;
let email_value = if can_read_email {
row.email.clone()
@@ -353,6 +358,10 @@ pub async fn get_session(
}
if is_takendown {
response["status"] = json!("takendown");
+ } else if is_migrated {
+ response["status"] = json!("migrated");
+ response["migratedToPds"] = json!(row.migrated_to_pds);
+ response["migratedAt"] = json!(row.migrated_at);
} else if row.deactivated_at.is_some() {
response["status"] = json!("deactivated");
}
diff --git a/src/auth/extractor.rs b/src/auth/extractor.rs
index f794f7f..9fefc39 100644
--- a/src/auth/extractor.rs
+++ b/src/auth/extractor.rs
@@ -11,6 +11,7 @@ use super::{
validate_bearer_token_cached_allow_deactivated, validate_token_with_dpop,
};
use crate::state::AppState;
+use crate::util::build_full_url;
pub struct BearerAuth(pub AuthenticatedUser);
@@ -164,7 +165,7 @@ impl FromRequestParts for BearerAuth {
if extracted.is_dpop {
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
- let uri = parts.uri.to_string();
+ let uri = build_full_url(&parts.uri.to_string());
match validate_token_with_dpop(
&state.db,
@@ -217,7 +218,7 @@ impl FromRequestParts for BearerAuthAllowDeactivated {
if extracted.is_dpop {
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
- let uri = parts.uri.to_string();
+ let uri = build_full_url(&parts.uri.to_string());
match validate_token_with_dpop(
&state.db,
@@ -274,7 +275,7 @@ impl FromRequestParts for BearerAuthAdmin {
let user = if extracted.is_dpop {
let dpop_proof = parts.headers.get("dpop").and_then(|h| h.to_str().ok());
let method = parts.method.as_str();
- let uri = parts.uri.to_string();
+ let uri = build_full_url(&parts.uri.to_string());
match validate_token_with_dpop(
&state.db,
diff --git a/src/lib.rs b/src/lib.rs
index 4db4c95..74c1197 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -295,6 +295,14 @@ pub fn app(state: AppState) -> Router {
"/xrpc/com.tranquil.account.clearMigrationForwarding",
post(api::server::clear_migration_forwarding),
)
+ .route(
+ "/xrpc/com.tranquil.account.updateDidDocument",
+ post(api::server::update_did_document),
+ )
+ .route(
+ "/xrpc/com.tranquil.account.getDidDocument",
+ get(api::server::get_did_document),
+ )
.route(
"/xrpc/com.atproto.server.requestEmailUpdate",
post(api::server::request_email_update),
diff --git a/src/oauth/verify.rs b/src/oauth/verify.rs
index b84290d..dd504c8 100644
--- a/src/oauth/verify.rs
+++ b/src/oauth/verify.rs
@@ -257,7 +257,7 @@ impl FromRequestParts for OAuthUser {
});
}
let http_method = parts.method.as_str();
- let http_uri = parts.uri.to_string();
+ let http_uri = crate::util::build_full_url(&parts.uri.to_string());
match verify_oauth_access_token(&state.db, token, dpop_proof, http_method, &http_uri).await
{
Ok(result) => {
diff --git a/src/util.rs b/src/util.rs
index dfb3d4d..55a1721 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -86,6 +86,16 @@ pub async fn get_user_by_identifier(
.ok_or(DbLookupError::NotFound)
}
+pub async fn is_account_migrated(db: &PgPool, did: &str) -> Result {
+ let row = sqlx::query!(
+ r#"SELECT (migrated_to_pds IS NOT NULL AND deactivated_at IS NOT NULL) as "migrated!: bool" FROM users WHERE did = $1"#,
+ did
+ )
+ .fetch_optional(db)
+ .await?;
+ Ok(row.map(|r| r.migrated).unwrap_or(false))
+}
+
pub fn parse_repeated_query_param(query: Option<&str>, key: &str) -> Vec {
query
.map(|q| {
@@ -128,6 +138,18 @@ pub fn extract_client_ip(headers: &HeaderMap) -> String {
"unknown".to_string()
}
+pub fn pds_hostname() -> String {
+ std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string())
+}
+
+pub fn pds_public_url() -> String {
+ format!("https://{}", pds_hostname())
+}
+
+pub fn build_full_url(path: &str) -> String {
+ format!("{}{}", pds_public_url(), path)
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/tests/did_web.rs b/tests/did_web.rs
index fa5bec0..405836b 100644
--- a/tests/did_web.rs
+++ b/tests/did_web.rs
@@ -545,3 +545,318 @@ async fn test_did_web_byod_flow() {
"Activated BYOD account should be able to create records"
);
}
+
+#[tokio::test]
+async fn test_deactivate_with_migrating_to() {
+ let client = client();
+ let base = base_url().await;
+ let handle = format!("mig{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
+ let payload = json!({
+ "handle": handle,
+ "email": format!("{}@example.com", handle),
+ "password": "Testpass123!",
+ "didType": "web"
+ });
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.server.createAccount", base))
+ .json(&payload)
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert_eq!(res.status(), StatusCode::OK);
+ let body: Value = res.json().await.expect("Response was not JSON");
+ let did = body["did"].as_str().expect("No DID").to_string();
+ let jwt = verify_new_account(&client, &did).await;
+ let target_pds = "https://pds2.example.com";
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
+ .bearer_auth(&jwt)
+ .json(&json!({ "migratingTo": target_pds }))
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert_eq!(res.status(), StatusCode::OK);
+ let pool = get_test_db_pool().await;
+ let row = sqlx::query!(
+ r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#,
+ &did
+ )
+ .fetch_one(pool)
+ .await
+ .expect("Failed to query user");
+ assert_eq!(
+ row.migrated_to_pds.as_deref(),
+ Some(target_pds),
+ "migrated_to_pds should be set to target PDS"
+ );
+ assert!(
+ row.deactivated_at.is_some(),
+ "deactivated_at should be set for migrated account"
+ );
+}
+
+#[tokio::test]
+async fn test_migrated_account_blocked_from_repo_ops() {
+ let client = client();
+ let base = base_url().await;
+ let handle = format!("blk{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
+ let payload = json!({
+ "handle": handle,
+ "email": format!("{}@example.com", handle),
+ "password": "Testpass123!",
+ "didType": "web"
+ });
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.server.createAccount", base))
+ .json(&payload)
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert_eq!(res.status(), StatusCode::OK);
+ let body: Value = res.json().await.expect("Response was not JSON");
+ let did = body["did"].as_str().expect("No DID").to_string();
+ let jwt = verify_new_account(&client, &did).await;
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
+ .bearer_auth(&jwt)
+ .json(&json!({
+ "repo": did,
+ "collection": "app.bsky.feed.post",
+ "record": {
+ "$type": "app.bsky.feed.post",
+ "text": "Pre-migration post",
+ "createdAt": chrono::Utc::now().to_rfc3339()
+ }
+ }))
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert_eq!(res.status(), StatusCode::OK);
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
+ .bearer_auth(&jwt)
+ .json(&json!({ "migratingTo": "https://pds2.example.com" }))
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert_eq!(res.status(), StatusCode::OK);
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.repo.createRecord", base))
+ .bearer_auth(&jwt)
+ .json(&json!({
+ "repo": did,
+ "collection": "app.bsky.feed.post",
+ "record": {
+ "$type": "app.bsky.feed.post",
+ "text": "Post-migration post - should fail",
+ "createdAt": chrono::Utc::now().to_rfc3339()
+ }
+ }))
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert!(
+ res.status().is_client_error(),
+ "createRecord should fail for migrated account: {}",
+ res.status()
+ );
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.repo.putRecord", base))
+ .bearer_auth(&jwt)
+ .json(&json!({
+ "repo": did,
+ "collection": "app.bsky.actor.profile",
+ "rkey": "self",
+ "record": {
+ "$type": "app.bsky.actor.profile",
+ "displayName": "Test"
+ }
+ }))
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert!(
+ res.status().is_client_error(),
+ "putRecord should fail for migrated account: {}",
+ res.status()
+ );
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", base))
+ .bearer_auth(&jwt)
+ .json(&json!({
+ "repo": did,
+ "collection": "app.bsky.feed.post",
+ "rkey": "test123"
+ }))
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert!(
+ res.status().is_client_error(),
+ "deleteRecord should fail for migrated account: {}",
+ res.status()
+ );
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.repo.applyWrites", base))
+ .bearer_auth(&jwt)
+ .json(&json!({
+ "repo": did,
+ "writes": [{
+ "$type": "com.atproto.repo.applyWrites#create",
+ "collection": "app.bsky.feed.post",
+ "value": {
+ "$type": "app.bsky.feed.post",
+ "text": "Batch post",
+ "createdAt": chrono::Utc::now().to_rfc3339()
+ }
+ }]
+ }))
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert!(
+ res.status().is_client_error(),
+ "applyWrites should fail for migrated account: {}",
+ res.status()
+ );
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", base))
+ .bearer_auth(&jwt)
+ .header("Content-Type", "text/plain")
+ .body("test blob content")
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert!(
+ res.status().is_client_error(),
+ "uploadBlob should fail for migrated account: {}",
+ res.status()
+ );
+}
+
+#[tokio::test]
+async fn test_migrated_session_status() {
+ let client = client();
+ let base = base_url().await;
+ let handle = format!("ses{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
+ let payload = json!({
+ "handle": handle,
+ "email": format!("{}@example.com", handle),
+ "password": "Testpass123!",
+ "didType": "web"
+ });
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.server.createAccount", base))
+ .json(&payload)
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert_eq!(res.status(), StatusCode::OK);
+ let body: Value = res.json().await.expect("Response was not JSON");
+ let did = body["did"].as_str().expect("No DID").to_string();
+ let jwt = verify_new_account(&client, &did).await;
+ let res = client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", base))
+ .bearer_auth(&jwt)
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert_eq!(res.status(), StatusCode::OK);
+ let body: Value = res.json().await.expect("Response was not JSON");
+ assert_eq!(body["active"], true);
+ assert!(
+ body["status"].is_null() || body["status"] == "active",
+ "Status should be null or 'active' for normal accounts"
+ );
+ let target_pds = "https://pds3.example.com";
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
+ .bearer_auth(&jwt)
+ .json(&json!({ "migratingTo": target_pds }))
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert_eq!(res.status(), StatusCode::OK);
+ let res = client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", base))
+ .bearer_auth(&jwt)
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert_eq!(res.status(), StatusCode::OK);
+ let body: Value = res.json().await.expect("Response was not JSON");
+ assert_eq!(body["active"], false, "Migrated account should not be active");
+ assert_eq!(
+ body["status"], "migrated",
+ "Status should be 'migrated' after migration"
+ );
+ assert_eq!(
+ body["migratedToPds"], target_pds,
+ "migratedToPds should be set to target PDS"
+ );
+}
+
+#[tokio::test]
+async fn test_migrating_to_ignored_for_did_plc() {
+ let client = client();
+ let base = base_url().await;
+ let handle = format!("plc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
+ let payload = json!({
+ "handle": handle,
+ "email": format!("{}@example.com", handle),
+ "password": "Testpass123!",
+ "didType": "plc"
+ });
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.server.createAccount", base))
+ .json(&payload)
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert_eq!(res.status(), StatusCode::OK);
+ let body: Value = res.json().await.expect("Response was not JSON");
+ let did = body["did"].as_str().expect("No DID").to_string();
+ assert!(did.starts_with("did:plc:"), "Should be did:plc account");
+ let jwt = verify_new_account(&client, &did).await;
+ let res = client
+ .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", base))
+ .bearer_auth(&jwt)
+ .json(&json!({ "migratingTo": "https://pds2.example.com" }))
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert_eq!(res.status(), StatusCode::OK);
+ let pool = get_test_db_pool().await;
+ let row = sqlx::query!(
+ r#"SELECT migrated_to_pds, deactivated_at FROM users WHERE did = $1"#,
+ &did
+ )
+ .fetch_one(pool)
+ .await
+ .expect("Failed to query user");
+ assert!(
+ row.migrated_to_pds.is_none(),
+ "migrated_to_pds should NOT be set for did:plc accounts"
+ );
+ assert!(
+ row.deactivated_at.is_some(),
+ "deactivated_at should still be set"
+ );
+ let res = client
+ .get(format!("{}/xrpc/com.atproto.server.getSession", base))
+ .bearer_auth(&jwt)
+ .send()
+ .await
+ .expect("Failed to send request");
+ assert_eq!(res.status(), StatusCode::OK);
+ let body: Value = res.json().await.expect("Response was not JSON");
+ assert_eq!(body["active"], false);
+ assert_eq!(
+ body["status"], "deactivated",
+ "Status should be 'deactivated' not 'migrated' for did:plc"
+ );
+ assert!(
+ body["migratedToPds"].is_null(),
+ "migratedToPds should not be set for did:plc accounts"
+ );
+}
diff --git a/tests/invite.rs b/tests/invite.rs
index 7a6dc8b..f54922c 100644
--- a/tests/invite.rs
+++ b/tests/invite.rs
@@ -53,6 +53,7 @@ async fn test_create_invite_code_no_auth() {
#[tokio::test]
async fn test_create_invite_code_non_admin() {
let client = client();
+ let _ = create_admin_account_and_login(&client).await;
let (access_jwt, _did) = create_account_and_login(&client).await;
let payload = json!({
"useCount": 5
@@ -121,7 +122,7 @@ async fn test_create_invite_code_for_another_account() {
#[tokio::test]
async fn test_create_invite_codes_success() {
let client = client();
- let (access_jwt, _did) = create_admin_account_and_login(&client).await;
+ let (access_jwt, did) = create_admin_account_and_login(&client).await;
let payload = json!({
"useCount": 2,
"codeCount": 3
@@ -141,7 +142,7 @@ async fn test_create_invite_codes_success() {
assert!(body["codes"].is_array());
let codes = body["codes"].as_array().unwrap();
assert_eq!(codes.len(), 1);
- assert_eq!(codes[0]["account"], "admin");
+ assert_eq!(codes[0]["account"], did);
assert_eq!(codes[0]["codes"].as_array().unwrap().len(), 3);
}