An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(identity-wallet): address PR review feedback

Critical fixes:
- ClaimSuccessScreen: use plcDoc.did (PLC format) instead of .id (W3C)
- IdentityListHome: push degraded card on inner catch instead of dropping

Important fixes:
- IdentityListHome: show error state when listIdentities() fails
- EmailVerificationScreen: differentiate UNAUTHORIZED from generic errors
- +page.svelte: log Keychain errors in onMount catch block
- CLAUDE.md: correct sign_and_verify_claim signature (did, not device_key_id)
- normalizePlcDocToW3c: fix JSDoc, handle verificationMethods map, use did??id

Code quality (suggestions):
- Extract truncateDid, extractHandle, isCodedError, normalizePlcDocToW3c to
shared did-doc-utils.ts (eliminates duplication across 6 components)
- Remove dead selectedDid state from +page.svelte
- Add console.error in all structured catch blocks for diagnostic trail
- Include error code in default switch messages
- Fix step comment numbering in lib.rs (was 1,2,4,5,6 → now 1,2,3,4,5)
- Fix startPdsAuth JSDoc (resolves promise, not emits event)
- Fix IdentityStoreError TS type (add message to KEYCHAIN/KEY_GEN/SERIALIZATION)
- ClaimSuccessScreen: strip at:// prefix from handle display

authored by

Malpercio and committed by
Tangled
684f9a02 2608cd0a

+204 -149
+1 -1
apps/identity-wallet/CLAUDE.md
··· 36 36 - `src/lib.rs::perform_did_ceremony(handle: String, password: String) -> Result<DIDCeremonyResult, DIDCeremonyError>` — Tauri IPC command: fetches relay signing key (GET /v1/relay/keys), builds signed did:plc genesis op via `crypto::build_did_plc_genesis_op_with_external_signer` using device key as signer, POSTs genesis op + password to relay (POST /v1/dids with Bearer token), persists DID + upgraded session token + Share 1 in Keychain, returns `{ did, share3 }` to frontend 37 37 - `src/home.rs` — Home screen data module: `load_home_data(AppState) -> Result<HomeData, String>` (Tauri IPC command: fires GET /xrpc/_health and GET /xrpc/com.atproto.server.getSession concurrently via OAuthClient; always succeeds -- partial failures encoded as HomeData fields); `log_out(AppState) -> Result<(), String>` (Tauri IPC command: deletes oauth-access-token, oauth-refresh-token, and did from Keychain, clears in-memory oauth_session; always succeeds -- Keychain errors swallowed); output types: `HomeData` { relay_healthy, session, session_error, share1_in_keychain }, `SessionInfo` { did, handle, email, email_confirmed, did_doc } 38 38 - `src/oauth.rs` — OAuth PKCE client module: `AppState` (pending_auth + oauth_session mutexes + relay_client OnceLock + pds_client + claim_state), `OAuthSession` (access/refresh/expiry/nonce), `DPoPKeypair` (P-256, persisted in Keychain), `OAuthError` enum, PKCE utilities (verifier + S256 challenge), `start_oauth_flow` (Tauri IPC command: DPoP keygen, PKCE, PAR, Safari redirect, deep-link callback, token exchange), `handle_deep_link` (routes deep-link URLs to pending flow); `AppState::pds_client()` accessor exposes `PdsClient` for claim flow commands 39 - - `src/claim.rs` — PLC rotation key claim flow module (5 Tauri IPC commands): `resolve_identity(handle_or_did) -> Result<IdentityInfo, ResolveError>` (resolves handle/DID to identity info via plc.directory, stores ClaimState), `start_pds_auth(pds_url) -> Result<(), ClaimError>` (OAuth PKCE+DPoP to arbitrary PDS, stores OAuthClient in ClaimState, emits `"pds_auth_ready"` event), `request_claim_verification(did) -> Result<(), ClaimError>` (calls `requestPlcOperationSignature` XRPC on old PDS to trigger email verification), `sign_and_verify_claim(device_key_id, token) -> Result<VerifiedClaimOp, ClaimError>` (calls `getRecommendedDidCredentials` and `signPlcOperation` on old PDS, fetches audit log, verifies signature + 4-point local checks, stores signed op in ClaimState), `submit_claim(did) -> Result<ClaimResult, ClaimError>` (POSTs signed op to plc.directory, persists identity to IdentityStore, clears ClaimState). Types: `IdentityInfo`, `VerifiedClaimOp`, `OpDiff`, `ServiceChange`, `ClaimResult`, `ClaimState`. Error enums: `ResolveError` (HANDLE_NOT_FOUND, DID_NOT_FOUND, PDS_UNREACHABLE, NETWORK_ERROR), `ClaimError` (INVALID_TOKEN, VERIFICATION_FAILED, PLC_DIRECTORY_ERROR, UNAUTHORIZED, NETWORK_ERROR) 39 + - `src/claim.rs` — PLC rotation key claim flow module (5 Tauri IPC commands): `resolve_identity(handle_or_did) -> Result<IdentityInfo, ResolveError>` (resolves handle/DID to identity info via plc.directory, stores ClaimState), `start_pds_auth(pds_url) -> Result<(), ClaimError>` (OAuth PKCE+DPoP to arbitrary PDS, stores OAuthClient in ClaimState, emits `"pds_auth_ready"` event), `request_claim_verification(did) -> Result<(), ClaimError>` (calls `requestPlcOperationSignature` XRPC on old PDS to trigger email verification), `sign_and_verify_claim(did, token) -> Result<VerifiedClaimOp, ClaimError>` (calls `getRecommendedDidCredentials` and `signPlcOperation` on old PDS, fetches audit log, verifies signature + 4-point local checks, stores signed op in ClaimState), `submit_claim(did) -> Result<ClaimResult, ClaimError>` (POSTs signed op to plc.directory, persists identity to IdentityStore, clears ClaimState). Types: `IdentityInfo`, `VerifiedClaimOp`, `OpDiff`, `ServiceChange`, `ClaimResult`, `ClaimState`. Error enums: `ResolveError` (HANDLE_NOT_FOUND, DID_NOT_FOUND, PDS_UNREACHABLE, NETWORK_ERROR), `ClaimError` (INVALID_TOKEN, VERIFICATION_FAILED, PLC_DIRECTORY_ERROR, UNAUTHORIZED, NETWORK_ERROR) 40 40 - `src/oauth_client.rs` — `OAuthClient`: authenticated HTTP client wrapping every request with `Authorization: DPoP {access_token}` + `DPoP` proof headers; transparent lazy refresh when token has <60s remaining; automatic retry on `use_dpop_nonce` 400 responses; methods: `get(path)`, `post(path, body)` 41 41 - `src/device_key.rs` — P-256 device key management with `#[cfg]`-based dispatch: macOS/simulator uses software keys via `crypto` crate + Keychain storage; real iOS device uses Secure Enclave via `security-framework`. Public API: `get_or_create() -> Result<DevicePublicKey, DeviceKeyError>` (idempotent), `sign(data) -> Result<Vec<u8>, DeviceKeyError>` 42 42 - `src/keychain.rs` — iOS Keychain abstraction (`store_item`, `get_item`, `delete_item`) under service `"ezpds-identity-wallet"`; Relay URL helpers: `store_relay_url`/`load_relay_url` (relay base URL); OAuth helpers: `store_dpop_key`/`load_dpop_key` (P-256 DPoP private key scalar), `store_oauth_tokens`/`load_oauth_tokens` (access + refresh token pair)
+3 -3
apps/identity-wallet/src-tauri/src/lib.rs
··· 307 307 let status = resp.status(); 308 308 309 309 if status.is_success() { 310 - // 4. Deserialize success body. 310 + // 3. Deserialize success body. 311 311 let body: CreateMobileAccountResponse = 312 312 resp.json().await.map_err(|e| CreateAccountError::Unknown { 313 313 message: e.to_string(), 314 314 })?; 315 315 316 - // 5. Store tokens in Keychain. 316 + // 4. Store tokens in Keychain. 317 317 // If session-token write fails, best-effort remove the already-written device-token. 318 318 // The device key is persistent by design and is NOT cleaned up on failure. 319 319 keychain::store_item("device-token", body.device_token.as_bytes()).map_err(|_| { ··· 331 331 next_step: body.next_step, 332 332 }) 333 333 } else { 334 - // 6. Map relay error codes to typed variants. 334 + // 5. Map relay error codes to typed variants. 335 335 match status.as_u16() { 336 336 // 404: Relay returns this for both invalid (never-existed) and expired claim codes. 337 337 // The frontend cannot distinguish them, so we map both to ExpiredCode.
+25 -27
apps/identity-wallet/src/lib/components/home/IdentityListHome.svelte
··· 1 1 <script lang="ts"> 2 2 import { onMount } from 'svelte'; 3 3 import { listIdentities, getStoredDidDoc, getDeviceKeyId } from '$lib/ipc'; 4 - import { extractPdsFromPlcDoc } from '$lib/did-doc-utils'; 4 + import { extractPdsFromPlcDoc, extractHandle, truncateDid } from '$lib/did-doc-utils'; 5 5 import DIDAvatar from './DIDAvatar.svelte'; 6 6 7 7 let { ··· 22 22 let identities = $state<IdentityCard[]>([]); 23 23 let didDocs = $state<Map<string, Record<string, unknown>>>(new Map()); 24 24 let loading = $state(true); 25 - 26 - function truncateDid(did: string): string { 27 - const prefix = 'did:plc:'; 28 - if (!did.startsWith(prefix)) return did; 29 - const specific = did.slice(prefix.length); 30 - if (specific.length < 14) return did; 31 - return `${prefix}${specific.slice(0, 8)}…${specific.slice(-6)}`; 32 - } 33 - 34 - function extractHandle(didDoc: Record<string, unknown>): string | null { 35 - const alsoKnownAs = didDoc.alsoKnownAs; 36 - if (!Array.isArray(alsoKnownAs)) return null; 37 - for (const aka of alsoKnownAs) { 38 - if (typeof aka === 'string' && aka.startsWith('at://')) { 39 - return aka.slice(5); // Extract after "at://" 40 - } 41 - } 42 - return null; 43 - } 44 - 45 - function extractPds(didDoc: Record<string, unknown>): string | null { 46 - return extractPdsFromPlcDoc(didDoc); 47 - } 25 + let loadError = $state<string | null>(null); 48 26 49 27 function isDeviceKeyRoot( 50 28 didDoc: Record<string, unknown>, ··· 57 35 58 36 async function loadData() { 59 37 loading = true; 38 + loadError = null; 60 39 try { 61 40 const dids = await listIdentities(); 62 41 identities = []; ··· 69 48 getDeviceKeyId(did), 70 49 ]); 71 50 72 - // Show identity even if DID doc is missing (with fallback display) 73 51 const handle = docResult ? extractHandle(docResult) : null; 74 - const pdsUrl = docResult ? extractPds(docResult) : null; 52 + const pdsUrl = docResult ? extractPdsFromPlcDoc(docResult) : null; 75 53 const deviceKeyIsRoot = docResult ? isDeviceKeyRoot(docResult, keyIdResult) : null; 76 54 77 55 if (docResult) { ··· 86 64 }); 87 65 } catch (e) { 88 66 console.error(`Failed to load identity ${did}:`, e); 67 + // Show degraded card instead of silently dropping the identity 68 + identities.push({ 69 + did, 70 + handle: null, 71 + pdsUrl: null, 72 + deviceKeyIsRoot: null, 73 + }); 89 74 } 90 75 } 91 76 } catch (e) { 92 77 console.error('Failed to load identities:', e); 93 78 identities = []; 94 79 didDocs.clear(); 80 + loadError = 'Failed to load identities. Tap refresh to try again.'; 95 81 } finally { 96 82 loading = false; 97 83 } ··· 123 109 <button class="refresh-btn" onclick={loadData} aria-label="Refresh">↻</button> 124 110 </div> 125 111 126 - {#if identities.length === 0} 112 + {#if loadError} 113 + <div class="empty-state"> 114 + <p class="error-text">{loadError}</p> 115 + <button class="add-btn" onclick={loadData}>Refresh</button> 116 + </div> 117 + {:else if identities.length === 0} 127 118 <div class="empty-state"> 128 119 <p class="empty-text">No identities yet</p> 129 120 <button class="add-btn" onclick={onadd}>+ Add Identity</button> ··· 355 346 font-size: 1rem; 356 347 color: #6b7280; 357 348 margin: 0; 349 + } 350 + 351 + .error-text { 352 + font-size: 1rem; 353 + color: #ef4444; 354 + margin: 0; 355 + text-align: center; 358 356 } 359 357 360 358 .add-btn {
+14 -14
apps/identity-wallet/src/lib/components/onboarding/ClaimSuccessScreen.svelte
··· 1 1 <script lang="ts"> 2 2 import { type ClaimResult } from '$lib/ipc'; 3 - import { extractPdsFromPlcDoc } from '$lib/did-doc-utils'; 3 + import { extractPdsFromPlcDoc, extractHandle } from '$lib/did-doc-utils'; 4 4 5 5 let { 6 6 claimResult, ··· 10 10 ondone: () => void; 11 11 } = $props(); 12 12 13 - // Extract typed fields from the loosely-typed updatedDidDoc 14 - let didId = $derived( 15 - typeof claimResult.updatedDidDoc?.id === 'string' 16 - ? claimResult.updatedDidDoc.id 17 - : '—' 18 - ); 13 + let didId = $derived.by(() => { 14 + const doc = claimResult.updatedDidDoc; 15 + if (typeof doc !== 'object' || doc === null) return '—'; 16 + const d = doc as Record<string, unknown>; 17 + return typeof d.did === 'string' ? d.did : typeof d.id === 'string' ? d.id : '—'; 18 + }); 19 19 20 - let alsoKnownAs = $derived( 21 - Array.isArray(claimResult.updatedDidDoc?.alsoKnownAs) 22 - ? (claimResult.updatedDidDoc.alsoKnownAs as string[]) 23 - : [] 24 - ); 20 + let handle = $derived.by(() => { 21 + const doc = claimResult.updatedDidDoc; 22 + if (typeof doc !== 'object' || doc === null) return null; 23 + return extractHandle(doc as Record<string, unknown>); 24 + }); 25 25 26 26 let pdsEndpoint = $derived.by(() => { 27 27 const doc = claimResult.updatedDidDoc; ··· 47 47 <code class="summary-value">{didId}</code> 48 48 </div> 49 49 50 - {#if alsoKnownAs.length > 0} 50 + {#if handle} 51 51 <div class="summary-item"> 52 52 <p class="summary-label">Handle</p> 53 - <p class="summary-value">{alsoKnownAs[0]}</p> 53 + <p class="summary-value">@{handle}</p> 54 54 </div> 55 55 {/if} 56 56
+11 -10
apps/identity-wallet/src/lib/components/onboarding/EmailVerificationScreen.svelte
··· 6 6 type VerifiedClaimOp, 7 7 type ClaimError, 8 8 } from '$lib/ipc'; 9 + import { isCodedError } from '$lib/did-doc-utils'; 9 10 10 11 let { 11 12 did, ··· 31 32 try { 32 33 await requestClaimVerification(did); 33 34 sending = false; 34 - } catch { 35 + } catch (e) { 35 36 sending = false; 36 - sendError = 'Failed to send verification email. Please try again.'; 37 + console.error('Failed to send verification email:', e); 38 + if (isCodedError(e) && e.code === 'UNAUTHORIZED') { 39 + sendError = 'Authorization expired. Please go back and re-authenticate with your PDS.'; 40 + } else { 41 + sendError = 'Failed to send verification email. Please try again.'; 42 + } 37 43 } 38 44 } 39 45 ··· 47 53 onnext(result); 48 54 } catch (raw: unknown) { 49 55 verifying = false; 56 + console.error('Claim verification failed:', raw); 50 57 51 - // Guard against non-ClaimError shapes 52 - if ( 53 - typeof raw === 'object' && 54 - raw !== null && 55 - 'code' in raw && 56 - typeof (raw as ClaimError).code === 'string' 57 - ) { 58 + if (isCodedError(raw)) { 58 59 const err = raw as ClaimError; 59 60 switch (err.code) { 60 61 case 'INVALID_TOKEN': ··· 67 68 verifyError = 'Network error. Check your connection and try again.'; 68 69 break; 69 70 default: 70 - verifyError = 'An error occurred. Please try again.'; 71 + verifyError = `An error occurred (${err.code}). Please try again.`; 71 72 } 72 73 } else { 73 74 verifyError = 'An error occurred. Please try again.';
+7 -15
apps/identity-wallet/src/lib/components/onboarding/IdentityInputScreen.svelte
··· 1 1 <script lang="ts"> 2 2 import { resolveIdentity, type IdentityInfo, type ResolveError } from '$lib/ipc'; 3 + import { truncateDid, isCodedError } from '$lib/did-doc-utils'; 3 4 4 5 let { 5 6 value = $bindable(''), ··· 27 28 resolved = info; 28 29 error = null; 29 30 } catch (raw: unknown) { 30 - // Map ResolveError codes to user-friendly messages. 31 - if (typeof raw === 'object' && raw !== null && 'code' in raw) { 32 - const err = raw as ResolveError; 33 - switch (err.code) { 31 + console.error('Identity resolution failed:', raw); 32 + 33 + if (isCodedError(raw)) { 34 + switch (raw.code) { 34 35 case 'HANDLE_NOT_FOUND': 35 36 error = 'Handle not found. Check the spelling and try again.'; 36 37 break; ··· 44 45 error = 'Network error. Check your connection and try again.'; 45 46 break; 46 47 default: 47 - error = 'An unexpected error occurred. Please try again.'; 48 + error = `An unexpected error occurred (${raw.code}). Please try again.`; 48 49 } 49 50 } else { 50 51 error = 'An unexpected error occurred. Please try again.'; ··· 62 63 } 63 64 } 64 65 65 - // Truncate the DID for display on narrow mobile screens. 66 - // "did:plc:abcdefghijklmnopqrstuvwx" → "did:plc:abcdefgh…uvwxyz" 67 - let displayDid = $derived.by(() => { 68 - const did = resolved?.did ?? ''; 69 - const prefix = 'did:plc:'; 70 - if (!did.startsWith(prefix)) return did; 71 - const specific = did.slice(prefix.length); 72 - if (specific.length < 14) return did; 73 - return `${prefix}${specific.slice(0, 8)}…${specific.slice(-6)}`; 74 - }); 66 + let displayDid = $derived(truncateDid(resolved?.did ?? '')); 75 67 </script> 76 68 77 69 <div class="screen">
+4 -8
apps/identity-wallet/src/lib/components/onboarding/PdsAuthScreen.svelte
··· 1 1 <script lang="ts"> 2 2 import { startPdsAuth, type ClaimError } from '$lib/ipc'; 3 + import { isCodedError } from '$lib/did-doc-utils'; 3 4 4 5 let { 5 6 pdsUrl, ··· 23 24 onnext(); 24 25 } catch (raw: unknown) { 25 26 authenticating = false; 27 + console.error('PDS authentication failed:', raw); 26 28 27 - // Guard against non-ClaimError shapes 28 - if ( 29 - typeof raw === 'object' && 30 - raw !== null && 31 - 'code' in raw && 32 - typeof (raw as ClaimError).code === 'string' 33 - ) { 29 + if (isCodedError(raw)) { 34 30 const err = raw as ClaimError; 35 31 switch (err.code) { 36 32 case 'UNAUTHORIZED': ··· 40 36 error = 'Network error. Check your connection and try again.'; 41 37 break; 42 38 default: 43 - error = 'Authentication failed. Please try again.'; 39 + error = `Authentication failed (${err.code}). Please try again.`; 44 40 } 45 41 } else { 46 42 error = 'Authentication failed. Please try again.';
+5 -7
apps/identity-wallet/src/lib/components/onboarding/ReviewOperationScreen.svelte
··· 1 1 <script lang="ts"> 2 2 import { submitClaim, type VerifiedClaimOp, type ClaimResult, type ClaimError } from '$lib/ipc'; 3 + import { isCodedError } from '$lib/did-doc-utils'; 3 4 4 5 let { 5 6 did, ··· 25 26 const result = await submitClaim(did); 26 27 onnext(result); 27 28 } catch (raw: unknown) { 28 - if ( 29 - typeof raw === 'object' && 30 - raw !== null && 31 - 'code' in raw && 32 - typeof (raw as ClaimError).code === 'string' 33 - ) { 29 + console.error('Claim submission failed:', raw); 30 + 31 + if (isCodedError(raw)) { 34 32 const err = raw as ClaimError; 35 33 switch (err.code) { 36 34 case 'PLC_DIRECTORY_ERROR': ··· 43 41 error = 'Authorization expired. Please restart the import flow.'; 44 42 break; 45 43 default: 46 - error = 'Submission failed. Please try again.'; 44 + error = `Submission failed (${err.code}). Please try again.`; 47 45 } 48 46 } else { 49 47 error = 'Submission failed. Please try again.';
+125
apps/identity-wallet/src/lib/did-doc-utils.ts
··· 1 1 /** 2 2 * Utility functions for working with DID documents, especially PLC directory format. 3 + * 4 + * PLC directory format differs from W3C DID format: 5 + * - PLC uses "did" (W3C uses "id") 6 + * - PLC uses "services" as a map (W3C uses "service" as an array) 7 + * - PLC uses "rotationKeys" as a flat string array (W3C uses "verificationMethod" as an object array) 8 + * - PLC uses "verificationMethods" as a map (W3C uses "verificationMethod" as an object array) 3 9 */ 4 10 5 11 /** ··· 21 27 const endpoint = (pds as Record<string, unknown>).endpoint; 22 28 return typeof endpoint === 'string' ? endpoint : null; 23 29 } 30 + 31 + /** 32 + * Extracts the handle from a PLC directory format DID document's alsoKnownAs array. 33 + * Strips the "at://" prefix from AT Protocol identifiers. 34 + * 35 + * @param doc - The DID document (loosely typed Record) 36 + * @returns The handle string (without at:// prefix), or null if not found 37 + */ 38 + export function extractHandle(doc: Record<string, unknown>): string | null { 39 + const alsoKnownAs = doc.alsoKnownAs; 40 + if (!Array.isArray(alsoKnownAs)) return null; 41 + for (const aka of alsoKnownAs) { 42 + if (typeof aka === 'string' && aka.startsWith('at://')) { 43 + return aka.slice(5); 44 + } 45 + } 46 + return null; 47 + } 48 + 49 + /** 50 + * Truncates a did:plc identifier for display on narrow mobile screens. 51 + * "did:plc:abcdefghijklmnopqrstuvwx" → "did:plc:abcdefgh…stuvwx" 52 + * 53 + * @param did - The full DID string 54 + * @returns The truncated DID string, or the original if too short to truncate 55 + */ 56 + export function truncateDid(did: string): string { 57 + const prefix = 'did:plc:'; 58 + if (!did.startsWith(prefix)) return did; 59 + const specific = did.slice(prefix.length); 60 + if (specific.length < 14) return did; 61 + return `${prefix}${specific.slice(0, 8)}…${specific.slice(-6)}`; 62 + } 63 + 64 + /** 65 + * Type guard for Tauri IPC error objects with a `code` field. 66 + * Use in catch blocks to distinguish typed IPC errors from generic JS errors. 67 + */ 68 + export function isCodedError(raw: unknown): raw is { code: string } { 69 + return ( 70 + typeof raw === 'object' && 71 + raw !== null && 72 + 'code' in raw && 73 + typeof (raw as { code: unknown }).code === 'string' 74 + ); 75 + } 76 + 77 + /** 78 + * Normalizes a PLC directory format DID document to W3C format for DIDDocumentScreen. 79 + * 80 + * Converts: 81 + * - "did" → "id" 82 + * - "services" map → "service" array (each entry: {id, type, serviceEndpoint}) 83 + * - "rotationKeys" string array → "verificationMethod" array (each entry: {id, type, publicKeyMultibase}) 84 + * - "verificationMethods" map → appended to "verificationMethod" array (each entry: {id, type, publicKeyMultibase}) 85 + * - "alsoKnownAs" passed through 86 + */ 87 + export function normalizePlcDocToW3c(plcDoc: Record<string, unknown>): Record<string, unknown> { 88 + const normalized: Record<string, unknown> = { 89 + id: plcDoc.did ?? plcDoc.id, 90 + alsoKnownAs: plcDoc.alsoKnownAs, 91 + }; 92 + 93 + // Convert services map to service array 94 + if (typeof plcDoc.services === 'object' && plcDoc.services !== null) { 95 + const servicesMap = plcDoc.services as Record<string, unknown>; 96 + const serviceArray: Array<Record<string, unknown>> = []; 97 + 98 + for (const [key, value] of Object.entries(servicesMap)) { 99 + if (typeof value === 'object' && value !== null) { 100 + const serviceObj = value as Record<string, unknown>; 101 + serviceArray.push({ 102 + id: `#${key}`, 103 + type: serviceObj.type, 104 + serviceEndpoint: serviceObj.endpoint, 105 + }); 106 + } 107 + } 108 + 109 + if (serviceArray.length > 0) { 110 + normalized.service = serviceArray; 111 + } 112 + } 113 + 114 + // Convert rotationKeys array and verificationMethods map to verificationMethod array 115 + const verificationMethods: Array<Record<string, unknown>> = []; 116 + 117 + if (Array.isArray(plcDoc.rotationKeys)) { 118 + for (let i = 0; i < plcDoc.rotationKeys.length; i++) { 119 + const key = plcDoc.rotationKeys[i]; 120 + if (typeof key === 'string') { 121 + verificationMethods.push({ 122 + id: `#rotation-${i}`, 123 + type: 'Multikey', 124 + publicKeyMultibase: key, 125 + }); 126 + } 127 + } 128 + } 129 + 130 + if (typeof plcDoc.verificationMethods === 'object' && plcDoc.verificationMethods !== null) { 131 + const vmMap = plcDoc.verificationMethods as Record<string, unknown>; 132 + for (const [key, value] of Object.entries(vmMap)) { 133 + if (typeof value === 'string') { 134 + verificationMethods.push({ 135 + id: `#${key}`, 136 + type: 'Multikey', 137 + publicKeyMultibase: value, 138 + }); 139 + } 140 + } 141 + } 142 + 143 + if (verificationMethods.length > 0) { 144 + normalized.verificationMethod = verificationMethods; 145 + } 146 + 147 + return normalized; 148 + }
+4 -4
apps/identity-wallet/src/lib/ipc.ts
··· 436 436 * 437 437 * Opens Safari for user authentication and handles the OAuth callback via deep-link. 438 438 * On success, stores the OAuth client in claim state for use by subsequent commands. 439 - * Emits `pds_auth_ready` event when complete. 439 + * Resolves the returned Promise when complete. 440 440 */ 441 441 export const startPdsAuth = (pdsUrl: string): Promise<void> => 442 442 invoke('start_pds_auth', { pdsUrl }); ··· 474 474 export type IdentityStoreError = 475 475 | { code: 'IDENTITY_NOT_FOUND' } 476 476 | { code: 'IDENTITY_ALREADY_EXISTS' } 477 - | { code: 'KEYCHAIN_ERROR' } 478 - | { code: 'KEY_GENERATION_FAILED' } 479 - | { code: 'SERIALIZATION_ERROR' }; 477 + | { code: 'KEYCHAIN_ERROR'; message: string } 478 + | { code: 'KEY_GENERATION_FAILED'; message: string } 479 + | { code: 'SERIALIZATION_ERROR'; message: string }; 480 480 481 481 export const listIdentities = (): Promise<string[]> => 482 482 invoke('list_identities');
+5 -60
apps/identity-wallet/src/routes/+page.svelte
··· 22 22 import DIDDocumentScreen from '$lib/components/home/DIDDocumentScreen.svelte'; 23 23 import RecoveryInfoScreen from '$lib/components/home/RecoveryInfoScreen.svelte'; 24 24 import { createAccount, listIdentities, type CreateAccountError, type OAuthError, type HomeData, type IdentityInfo, type VerifiedClaimOp, type ClaimResult } from '$lib/ipc'; 25 + import { normalizePlcDocToW3c } from '$lib/did-doc-utils'; 25 26 import IdentityListHome from '$lib/components/home/IdentityListHome.svelte'; 26 27 27 28 // ── Onboarding step type ───────────────────────────────────────────────── ··· 82 83 83 84 let homeData = $state<HomeData | null>(null); 84 85 85 - let selectedDid = $state<string | null>(null); 86 86 let selectedDidDoc = $state<Record<string, unknown> | null>(null); 87 87 88 88 // ── Navigation helpers ─────────────────────────────────────────────────── ··· 92 92 step = next; 93 93 } 94 94 95 - /** 96 - * Normalizes a PLC directory format DID document to W3C format. 97 - * Converts the PLC map-based format (services, verificationMethods as objects) 98 - * to the W3C array-based format (service, verificationMethod as arrays). 99 - */ 100 - function normalizePlcDocToW3c(plcDoc: Record<string, unknown>): Record<string, unknown> { 101 - const normalized: Record<string, unknown> = { 102 - id: plcDoc.id, 103 - alsoKnownAs: plcDoc.alsoKnownAs, 104 - }; 105 - 106 - // Convert services map to service array 107 - if (typeof plcDoc.services === 'object' && plcDoc.services !== null) { 108 - const servicesMap = plcDoc.services as Record<string, unknown>; 109 - const serviceArray: Array<Record<string, unknown>> = []; 110 - 111 - for (const [key, value] of Object.entries(servicesMap)) { 112 - if (typeof value === 'object' && value !== null) { 113 - const serviceObj = value as Record<string, unknown>; 114 - serviceArray.push({ 115 - id: `#${key}`, 116 - type: serviceObj.type, 117 - serviceEndpoint: serviceObj.endpoint, 118 - }); 119 - } 120 - } 121 - 122 - if (serviceArray.length > 0) { 123 - normalized.service = serviceArray; 124 - } 125 - } 126 - 127 - // Convert rotationKeys array to verificationMethod array 128 - if (Array.isArray(plcDoc.rotationKeys)) { 129 - const verificationMethods: Array<Record<string, unknown>> = []; 130 - 131 - for (let i = 0; i < plcDoc.rotationKeys.length; i++) { 132 - const key = plcDoc.rotationKeys[i]; 133 - if (typeof key === 'string') { 134 - verificationMethods.push({ 135 - id: `#rotation-${i}`, 136 - type: 'Multikey', 137 - publicKeyMultibase: key, 138 - }); 139 - } 140 - } 141 - 142 - if (verificationMethods.length > 0) { 143 - normalized.verificationMethod = verificationMethods; 144 - } 145 - } 146 - 147 - return normalized; 148 - } 149 - 150 95 // ── Relay configuration and OAuth event listener ────────────────────── 151 96 152 97 onMount(async () => { ··· 157 102 step = 'home'; 158 103 return; 159 104 } 160 - } catch { 161 - // listIdentities failed (e.g. empty Keychain on first launch) — continue to mode_select 105 + } catch (e) { 106 + console.error('listIdentities failed on mount:', e); 107 + // First launch (empty Keychain) or Keychain error — continue to mode_select 162 108 } 163 109 164 110 // Legacy users (relay URL configured but no managed-dids) stay at mode_select. ··· 364 310 {:else if step === 'home'} 365 311 <IdentityListHome 366 312 onadd={() => goTo('mode_select')} 367 - onselect={(did, didDoc) => { 368 - selectedDid = did; 313 + onselect={(_did, didDoc) => { 369 314 selectedDidDoc = didDoc; 370 315 goTo('identity_detail'); 371 316 }}