mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-08-18 23:36:06 +00:00
pds-hosted did migrates away
This commit is contained in:
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
+15
-3
@@ -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"
|
||||
}
|
||||
+16
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+34
@@ -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"
|
||||
}
|
||||
+17
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
+11
-5
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "重要:トレードオフを理解する",
|
||||
|
||||
@@ -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": "중요: 장단점 이해하기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "重要:了解利弊",
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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::*;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user