pds-hosted did migrates away

This commit is contained in:
lewis
2025-12-30 21:16:49 +02:00
parent ea55590b6c
commit 4d6e21b00d
44 changed files with 2088 additions and 116 deletions
+4
View File
@@ -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)
# =============================================================================
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+8 -8
View File
@@ -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
+3
View File
@@ -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
}
@@ -620,7 +620,15 @@
<div class="code-block">
<pre>{`{
"@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",
+84 -1
View File
@@ -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<DidDocument> {
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<void> {
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,
});
},
};
+11 -2
View File
@@ -327,12 +327,19 @@ export class AtprotoClient {
);
}
async deactivateAccount(): Promise<void> {
apiLog("POST", `${this.baseUrl}/xrpc/com.atproto.server.deactivateAccount`);
async deactivateAccount(migratingTo?: string): Promise<void> {
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;
+1 -12
View File
@@ -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) {
+42 -1
View File
@@ -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",
+42 -1
View File
@@ -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",
+30 -1
View File
@@ -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:webBYOD",
"didWebBYODHint": "独自ドメインを持ち込む",
"didWebWarningTitle": "重要:トレードオフを理解する",
+30 -1
View File
@@ -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": "중요: 장단점 이해하기",
+30 -1
View File
@@ -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",
+30 -1
View File
@@ -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": "重要:了解利弊",
+107 -43
View File
@@ -99,7 +99,12 @@
</div>
</header>
{#if auth.session.status === 'deactivated' || auth.session.active === false}
{#if auth.session.status === 'migrated'}
<div class="migrated-banner">
<strong>{$_('dashboard.migratedTitle')}</strong>
<p>{$_('dashboard.migratedMessage', { values: { pds: auth.session.migratedToPds || 'another PDS' } })}</p>
</div>
{:else if auth.session.status === 'deactivated' || auth.session.active === false}
<div class="deactivated-banner">
<strong>{$_('dashboard.deactivatedTitle')}</strong>
<p>{$_('dashboard.deactivatedMessage')}</p>
@@ -115,7 +120,9 @@
{#if auth.session.isAdmin}
<span class="badge admin">{$_('dashboard.admin')}</span>
{/if}
{#if auth.session.status === 'deactivated' || auth.session.active === false}
{#if auth.session.status === 'migrated'}
<span class="badge migrated">{$_('dashboard.migrated')}</span>
{:else if auth.session.status === 'deactivated' || auth.session.active === false}
<span class="badge deactivated">{$_('dashboard.deactivated')}</span>
{/if}
</dd>
@@ -156,49 +163,68 @@
</section>
<nav class="nav-grid">
<a href="#/app-passwords" class="nav-card">
<h3>{$_('dashboard.navAppPasswords')}</h3>
<p>{$_('dashboard.navAppPasswordsDesc')}</p>
</a>
<a href="#/sessions" class="nav-card">
<h3>{$_('dashboard.navSessions')}</h3>
<p>{$_('dashboard.navSessionsDesc')}</p>
</a>
{#if inviteCodesEnabled && auth.session.isAdmin}
<a href="#/invite-codes" class="nav-card">
<h3>{$_('dashboard.navInviteCodes')}</h3>
<p>{$_('dashboard.navInviteCodesDesc')}</p>
{#if auth.session.status === 'migrated'}
<a href="#/did-document" class="nav-card migrated-card">
<h3>{$_('dashboard.navDidDocument')}</h3>
<p>{$_('dashboard.navDidDocumentDesc')}</p>
</a>
{/if}
<a href="#/settings" class="nav-card">
<h3>{$_('dashboard.navSettings')}</h3>
<p>{$_('dashboard.navSettingsDesc')}</p>
</a>
<a href="#/security" class="nav-card">
<h3>{$_('dashboard.navSecurity')}</h3>
<p>{$_('dashboard.navSecurityDesc')}</p>
</a>
<a href="#/comms" class="nav-card">
<h3>{$_('dashboard.navComms')}</h3>
<p>{$_('dashboard.navCommsDesc')}</p>
</a>
<a href="#/repo" class="nav-card">
<h3>{$_('dashboard.navRepo')}</h3>
<p>{$_('dashboard.navRepoDesc')}</p>
</a>
<a href="#/controllers" class="nav-card">
<h3>{$_('dashboard.navDelegation')}</h3>
<p>{$_('dashboard.navDelegationDesc')}</p>
</a>
<a href="#/migrate" class="nav-card">
<h3>{$_('migration.navTitle')}</h3>
<p>{$_('migration.navDesc')}</p>
</a>
{#if auth.session.isAdmin}
<a href="#/admin" class="nav-card admin-card">
<h3>{$_('dashboard.navAdmin')}</h3>
<p>{$_('dashboard.navAdminDesc')}</p>
<a href="#/sessions" class="nav-card">
<h3>{$_('dashboard.navSessions')}</h3>
<p>{$_('dashboard.navSessionsDesc')}</p>
</a>
<a href="#/security" class="nav-card">
<h3>{$_('dashboard.navSecurity')}</h3>
<p>{$_('dashboard.navSecurityDesc')}</p>
</a>
<a href="#/migrate" class="nav-card">
<h3>{$_('dashboard.navMigrateAgain')}</h3>
<p>{$_('dashboard.navMigrateAgainDesc')}</p>
</a>
{:else}
<a href="#/app-passwords" class="nav-card">
<h3>{$_('dashboard.navAppPasswords')}</h3>
<p>{$_('dashboard.navAppPasswordsDesc')}</p>
</a>
<a href="#/sessions" class="nav-card">
<h3>{$_('dashboard.navSessions')}</h3>
<p>{$_('dashboard.navSessionsDesc')}</p>
</a>
{#if inviteCodesEnabled && auth.session.isAdmin}
<a href="#/invite-codes" class="nav-card">
<h3>{$_('dashboard.navInviteCodes')}</h3>
<p>{$_('dashboard.navInviteCodesDesc')}</p>
</a>
{/if}
<a href="#/settings" class="nav-card">
<h3>{$_('dashboard.navSettings')}</h3>
<p>{$_('dashboard.navSettingsDesc')}</p>
</a>
<a href="#/security" class="nav-card">
<h3>{$_('dashboard.navSecurity')}</h3>
<p>{$_('dashboard.navSecurityDesc')}</p>
</a>
<a href="#/comms" class="nav-card">
<h3>{$_('dashboard.navComms')}</h3>
<p>{$_('dashboard.navCommsDesc')}</p>
</a>
<a href="#/repo" class="nav-card">
<h3>{$_('dashboard.navRepo')}</h3>
<p>{$_('dashboard.navRepoDesc')}</p>
</a>
<a href="#/controllers" class="nav-card">
<h3>{$_('dashboard.navDelegation')}</h3>
<p>{$_('dashboard.navDelegationDesc')}</p>
</a>
<a href="#/migrate" class="nav-card">
<h3>{$_('migration.navTitle')}</h3>
<p>{$_('migration.navDesc')}</p>
</a>
{#if auth.session.isAdmin}
<a href="#/admin" class="nav-card admin-card">
<h3>{$_('dashboard.navAdmin')}</h3>
<p>{$_('dashboard.navAdminDesc')}</p>
</a>
{/if}
{/if}
</nav>
</div>
@@ -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);
}
</style>
@@ -0,0 +1,457 @@
<script lang="ts">
import { onMount } from 'svelte'
import { getAuthState } from '../lib/auth.svelte'
import { navigate } from '../lib/router.svelte'
import { api, ApiError, type VerificationMethod, type DidDocument } from '../lib/api'
import { _ } from '../lib/i18n'
const auth = getAuthState()
let loading = $state(true)
let saving = $state(false)
let message = $state<{ type: 'success' | 'error'; text: string } | null>(null)
let didDocument = $state<DidDocument | null>(null)
let verificationMethods = $state<VerificationMethod[]>([])
let alsoKnownAs = $state<string[]>([])
let serviceEndpoint = $state('')
let newKeyId = $state('#atproto')
let newKeyPublic = $state('')
let newHandle = $state('')
$effect(() => {
if (!auth.loading && !auth.session) {
navigate('/login')
}
})
onMount(async () => {
if (!auth.session) return
try {
didDocument = await api.getDidDocument(auth.session.accessJwt)
verificationMethods = didDocument.verificationMethod.map(vm => ({
id: vm.id.replace(didDocument!.id, ''),
type: vm.type,
publicKeyMultibase: vm.publicKeyMultibase
}))
alsoKnownAs = [...didDocument.alsoKnownAs]
const pdsService = didDocument.service.find(s => s.id === '#atproto_pds')
serviceEndpoint = pdsService?.serviceEndpoint || ''
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('didEditor.loadFailed'))
} finally {
loading = false
}
})
function showMessage(type: 'success' | 'error', text: string) {
message = { type, text }
setTimeout(() => {
if (message?.text === text) message = null
}, 5000)
}
function addVerificationMethod() {
if (!newKeyId || !newKeyPublic) return
if (!newKeyPublic.startsWith('z')) {
showMessage('error', $_('didEditor.invalidMultibase'))
return
}
verificationMethods = [...verificationMethods, {
id: newKeyId.startsWith('#') ? newKeyId : `#${newKeyId}`,
type: 'Multikey',
publicKeyMultibase: newKeyPublic
}]
newKeyId = '#atproto'
newKeyPublic = ''
}
function removeVerificationMethod(index: number) {
verificationMethods = verificationMethods.filter((_, i) => i !== index)
}
function addHandle() {
if (!newHandle) return
if (!newHandle.startsWith('at://')) {
showMessage('error', $_('didEditor.invalidHandle'))
return
}
alsoKnownAs = [...alsoKnownAs, newHandle]
newHandle = ''
}
function removeHandle(index: number) {
alsoKnownAs = alsoKnownAs.filter((_, i) => i !== index)
}
async function handleSave() {
if (!auth.session) return
saving = true
message = null
try {
await api.updateDidDocument(auth.session.accessJwt, {
verificationMethods: verificationMethods.length > 0 ? verificationMethods : undefined,
alsoKnownAs: alsoKnownAs.length > 0 ? alsoKnownAs : undefined,
serviceEndpoint: serviceEndpoint || undefined
})
showMessage('success', $_('didEditor.success'))
didDocument = await api.getDidDocument(auth.session.accessJwt)
} catch (e) {
showMessage('error', e instanceof ApiError ? e.message : $_('didEditor.saveFailed'))
} finally {
saving = false
}
}
</script>
<div class="page">
<header>
<a href="#/dashboard" class="back">{$_('common.backToDashboard')}</a>
<h1>{$_('didEditor.title')}</h1>
</header>
{#if message}
<div class="message {message.type}">{message.text}</div>
{/if}
{#if loading}
<div class="loading">{$_('common.loading')}</div>
{:else}
<div class="help-section">
<h3>{$_('didEditor.helpTitle')}</h3>
<p>{$_('didEditor.helpText')}</p>
</div>
<section>
<h2>{$_('didEditor.preview')}</h2>
<pre class="did-preview">{JSON.stringify(didDocument, null, 2)}</pre>
</section>
<section>
<h2>{$_('didEditor.verificationMethods')}</h2>
<p class="description">{$_('didEditor.verificationMethodsDesc')}</p>
{#if verificationMethods.length > 0}
<ul class="key-list">
{#each verificationMethods as method, index}
<li class="key-item">
<div class="key-info">
<span class="key-id">{method.id}</span>
<span class="key-type">{method.type}</span>
<code class="key-value">{method.publicKeyMultibase}</code>
</div>
<button type="button" class="danger-link" onclick={() => removeVerificationMethod(index)}>
{$_('didEditor.removeKey')}
</button>
</li>
{/each}
</ul>
{:else}
<p class="empty-state">{$_('didEditor.noKeys')}</p>
{/if}
<div class="add-form">
<h4>{$_('didEditor.addKey')}</h4>
<div class="field-row">
<div class="field small">
<label for="key-id">{$_('didEditor.keyId')}</label>
<input
id="key-id"
type="text"
bind:value={newKeyId}
placeholder={$_('didEditor.keyIdPlaceholder')}
/>
</div>
<div class="field large">
<label for="key-public">{$_('didEditor.publicKey')}</label>
<input
id="key-public"
type="text"
bind:value={newKeyPublic}
placeholder={$_('didEditor.publicKeyPlaceholder')}
/>
</div>
<button type="button" class="add-btn" onclick={addVerificationMethod} disabled={!newKeyId || !newKeyPublic}>
{$_('didEditor.addKey')}
</button>
</div>
</div>
</section>
<section>
<h2>{$_('didEditor.alsoKnownAs')}</h2>
<p class="description">{$_('didEditor.alsoKnownAsDesc')}</p>
{#if alsoKnownAs.length > 0}
<ul class="handle-list">
{#each alsoKnownAs as handle, index}
<li class="handle-item">
<span>{handle}</span>
<button type="button" class="danger-link" onclick={() => removeHandle(index)}>
{$_('didEditor.removeHandle')}
</button>
</li>
{/each}
</ul>
{:else}
<p class="empty-state">{$_('didEditor.noHandles')}</p>
{/if}
<div class="add-form">
<div class="field-row">
<div class="field large">
<label for="new-handle">{$_('didEditor.handle')}</label>
<input
id="new-handle"
type="text"
bind:value={newHandle}
placeholder={$_('didEditor.handlePlaceholder')}
/>
</div>
<button type="button" class="add-btn" onclick={addHandle} disabled={!newHandle}>
{$_('didEditor.addHandle')}
</button>
</div>
</div>
</section>
<section>
<h2>{$_('didEditor.serviceEndpoint')}</h2>
<p class="description">{$_('didEditor.serviceEndpointDesc')}</p>
<div class="field">
<label for="service-endpoint">{$_('didEditor.currentPds')}</label>
<input
id="service-endpoint"
type="url"
bind:value={serviceEndpoint}
placeholder="https://pds.example.com"
/>
</div>
</section>
<div class="actions">
<button onclick={handleSave} disabled={saving}>
{saving ? $_('didEditor.saving') : $_('didEditor.save')}
</button>
</div>
{/if}
</div>
<style>
.page {
max-width: var(--width-lg);
margin: 0 auto;
padding: var(--space-7);
}
header {
margin-bottom: var(--space-7);
}
.back {
color: var(--text-secondary);
text-decoration: none;
font-size: var(--text-sm);
}
.back:hover {
color: var(--accent);
}
h1 {
margin: var(--space-2) 0 0 0;
}
.help-section {
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-6);
}
.help-section h3 {
margin: 0 0 var(--space-2) 0;
color: var(--info-text, #0369a1);
font-size: var(--text-base);
}
.help-section p {
margin: 0;
color: var(--info-text, #0369a1);
font-size: var(--text-sm);
}
section {
padding: var(--space-6);
background: var(--bg-secondary);
border-radius: var(--radius-xl);
margin-bottom: var(--space-6);
}
section h2 {
margin: 0 0 var(--space-2) 0;
font-size: var(--text-lg);
}
.description {
color: var(--text-secondary);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
.did-preview {
background: var(--bg-input);
padding: var(--space-4);
border-radius: var(--radius-md);
font-size: var(--text-xs);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
overflow-y: auto;
}
.key-list, .handle-list {
list-style: none;
padding: 0;
margin: 0 0 var(--space-4) 0;
}
.key-item, .handle-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: var(--space-3) var(--space-4);
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
margin-bottom: var(--space-2);
gap: var(--space-4);
}
.key-info {
display: flex;
flex-direction: column;
gap: var(--space-1);
flex: 1;
min-width: 0;
}
.key-id {
font-weight: var(--font-medium);
font-size: var(--text-sm);
}
.key-type {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.key-value {
font-size: var(--text-xs);
background: var(--bg-input);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
word-break: break-all;
}
.handle-item span {
font-family: ui-monospace, monospace;
font-size: var(--text-sm);
}
.danger-link {
background: none;
border: none;
color: var(--error-text);
cursor: pointer;
font-size: var(--text-xs);
padding: var(--space-1) var(--space-2);
white-space: nowrap;
}
.danger-link:hover {
text-decoration: underline;
}
.empty-state {
color: var(--text-muted);
font-size: var(--text-sm);
font-style: italic;
padding: var(--space-4);
text-align: center;
background: var(--bg-card);
border-radius: var(--radius-md);
margin-bottom: var(--space-4);
}
.add-form {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
padding: var(--space-4);
}
.add-form h4 {
margin: 0 0 var(--space-3) 0;
font-size: var(--text-sm);
color: var(--text-secondary);
}
.field-row {
display: flex;
gap: var(--space-3);
align-items: flex-end;
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.field.small {
flex: 0 0 120px;
}
.field.large {
flex: 1;
}
.field label {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.add-btn {
white-space: nowrap;
}
.actions {
display: flex;
gap: var(--space-3);
justify-content: flex-end;
margin-top: var(--space-6);
}
.loading {
text-align: center;
padding: var(--space-9);
color: var(--text-secondary);
}
@media (max-width: 600px) {
.field-row {
flex-direction: column;
align-items: stretch;
}
.field.small, .field.large {
flex: none;
}
.add-btn {
width: 100%;
}
}
</style>
+17 -3
View File
@@ -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 @@
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
<span class="radio-content">
<strong>{$_('register.didWeb')}</strong>
<span class="radio-hint">{$_('register.didWebHint')}</span>
{#if serverInfo?.selfHostedDidWebEnabled === false}
<span class="radio-hint disabled-hint">{$_('register.didWebDisabledHint')}</span>
{:else}
<span class="radio-hint">{$_('register.didWebHint')}</span>
{/if}
</span>
</label>
@@ -550,6 +555,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);
+17 -3
View File
@@ -14,6 +14,7 @@
availableUserDomains: string[]
inviteCodeRequired: boolean
availableCommsChannels?: string[]
selfHostedDidWebEnabled?: boolean
} | null>(null)
let loadingServerInfo = $state(true)
let serverInfoLoaded = false
@@ -350,11 +351,15 @@
<span class="radio-hint">{$_('registerPasskey.didPlcHint')}</span>
</span>
</label>
<label class="radio-label">
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting} />
<label class="radio-label" class:disabled={serverInfo?.selfHostedDidWebEnabled === false}>
<input type="radio" name="didType" value="web" bind:group={flow.info.didType} disabled={flow.state.submitting || serverInfo?.selfHostedDidWebEnabled === false} />
<span class="radio-content">
<strong>{$_('registerPasskey.didWeb')}</strong>
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
{#if serverInfo?.selfHostedDidWebEnabled === false}
<span class="radio-hint disabled-hint">{$_('registerPasskey.didWebDisabledHint')}</span>
{:else}
<span class="radio-hint">{$_('registerPasskey.didWebHint')}</span>
{/if}
</span>
</label>
<label class="radio-label">
@@ -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);
+1
View File
@@ -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",
@@ -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()
);
+10
View File
@@ -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);
+130 -5
View File
@@ -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::<Vec<DidWebVerificationMethod>>(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::<Vec<_>>(),
"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<AppState>, Path(handle): Path<Stri
)
.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::<Vec<DidWebVerificationMethod>>(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::<Vec<_>>(),
"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<AppState>, Path(handle): Path<Stri
.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",
@@ -316,7 +441,7 @@ pub async fn user_did_doc(State(state): State<AppState>, Path(handle): Path<Stri
"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",
+14
View File
@@ -92,6 +92,20 @@ pub async fn upload_blob(
}
};
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. Blob operations are not allowed."
})),
)
.into_response();
}
let max_size = get_max_blob_size();
if body.len() > max_size {
+13
View File
@@ -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);
+14
View File
@@ -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;
+13
View File
@@ -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);
+47 -15
View File
@@ -568,6 +568,7 @@ pub async fn activate_account(
#[serde(rename_all = "camelCase")]
pub struct DeactivateAccountInput {
pub delete_after: Option<String>,
pub migrating_to: Option<String>,
}
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()
}
+8 -1
View File
@@ -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<AppState>) -> impl IntoResponse {
+367
View File
@@ -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<Vec<VerificationMethod>>,
pub also_known_as: Option<Vec<String>>,
pub service_endpoint: Option<String>,
}
#[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<AppState>,
headers: axum::http::HeaderMap,
Json(input): Json<UpdateDidDocumentInput>,
) -> 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<Vec<String>> = 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<AppState>,
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::<Vec<VerificationMethod>>(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::<Vec<_>>(),
"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
}]
})
}
+2 -1
View File
@@ -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,
+11 -2
View File
@@ -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");
}
+4 -3
View File
@@ -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<AppState> 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<AppState> 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<AppState> 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,
+8
View File
@@ -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),
+1 -1
View File
@@ -257,7 +257,7 @@ impl FromRequestParts<AppState> 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) => {
+22
View File
@@ -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<bool, sqlx::Error> {
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<String> {
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::*;
+315
View File
@@ -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"
);
}
+3 -2
View File
@@ -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);
}