Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/show-current-handle-on-settings.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'ePDS': minor
---

Account settings page now shows your current handle.

**Affects:** End users

**End users:** Visiting the account settings dashboard at `/account` on the auth service (not the PDS itself) now displays a "Current Handle:" row above the handle update form, so you can see at a glance what your current AT Protocol handle is before changing it. The auth service resolves the handle by calling the PDS's `com.atproto.repo.describeRepo` XRPC on every request, so the row reflects the authoritative value — including any pending rename that hasn't propagated to a local cache. If the PDS can't be reached the row displays `(unknown)` and the rest of the page still renders.
4 changes: 1 addition & 3 deletions features/account-settings.feature
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,10 @@ Scenario: User views their account information
When they view the /account page
Then the page displays their DID
And the page displays their primary email
# And the page displays their current handle
And the page displays their current handle

# --- Handle management ---

# Known gap: handle update on /account is not implemented yet.
@pending
Scenario: User changes their handle
Given the user is logged into account settings
And their current handle is a random subdomain of the PDS domain
Expand Down
160 changes: 160 additions & 0 deletions packages/auth-service/src/__tests__/get-handle-by-did.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
/**
* Tests for getHandleByDid().
*
* This helper calls the PDS's public describeRepo XRPC endpoint to look
* up the current handle for a DID. Used by the account-settings page to
* show the user their authoritative handle before offering the update
* form. Must degrade to null on any error — the settings page falls back
* to `(unknown)` rather than breaking the whole page.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { getHandleByDid } from '../lib/get-handle-by-did.js'

const PDS_URL = 'https://core:3000'
const DID = 'did:plc:abc123'

let fetchSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch')
})

afterEach(() => {
fetchSpy.mockRestore()
})

describe('getHandleByDid', () => {
it('returns the handle when describeRepo succeeds', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ did: DID, handle: 'alice.pds.test' }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
}),
)

const result = await getHandleByDid(DID, PDS_URL)

expect(result).toBe('alice.pds.test')
expect(fetchSpy).toHaveBeenCalledOnce()
expect(fetchSpy).toHaveBeenCalledWith(
`${PDS_URL}/xrpc/com.atproto.repo.describeRepo?repo=did%3Aplc%3Aabc123`,
expect.objectContaining({ signal: expect.any(AbortSignal) }),
)
})

it('returns null when describeRepo returns no handle field', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ did: DID }), { status: 200 }),
)

const result = await getHandleByDid(DID, PDS_URL)

expect(result).toBeNull()
})

it('returns null when handle field is not a string', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ did: DID, handle: 42 }), { status: 200 }),
)

const result = await getHandleByDid(DID, PDS_URL)

expect(result).toBeNull()
})

it('returns null on non-OK HTTP response', async () => {
fetchSpy.mockResolvedValueOnce(new Response('Bad Request', { status: 400 }))

const result = await getHandleByDid(DID, PDS_URL)

expect(result).toBeNull()
})

it('returns null when the repo is not found (404)', async () => {
fetchSpy.mockResolvedValueOnce(new Response('Not Found', { status: 404 }))

const result = await getHandleByDid(DID, PDS_URL)

expect(result).toBeNull()
})

it('returns null on 500 server error', async () => {
fetchSpy.mockResolvedValueOnce(
new Response('Internal Server Error', { status: 500 }),
)

const result = await getHandleByDid(DID, PDS_URL)

expect(result).toBeNull()
})

it('returns null on network error (fetch throws)', async () => {
fetchSpy.mockRejectedValueOnce(new Error('ECONNREFUSED'))

const result = await getHandleByDid(DID, PDS_URL)

expect(result).toBeNull()
})

it('returns null on timeout', async () => {
fetchSpy.mockRejectedValueOnce(new DOMException('Aborted', 'AbortError'))

const result = await getHandleByDid(DID, PDS_URL)

expect(result).toBeNull()
})

it('URL-encodes the DID in the query string', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ handle: 'x.pds.test' }), { status: 200 }),
)

await getHandleByDid('did:web:example.com:user', PDS_URL)

expect(fetchSpy).toHaveBeenCalledWith(
`${PDS_URL}/xrpc/com.atproto.repo.describeRepo?repo=did%3Aweb%3Aexample.com%3Auser`,
expect.anything(),
)
})

it('works with different PDS URLs', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ handle: 'bob.example.com' }), {
status: 200,
}),
)

const result = await getHandleByDid(DID, 'https://pds.example.com')

expect(result).toBe('bob.example.com')
expect(fetchSpy).toHaveBeenCalledWith(
'https://pds.example.com/xrpc/com.atproto.repo.describeRepo?repo=did%3Aplc%3Aabc123',
expect.anything(),
)
})

it('includes AbortSignal with timeout in request', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ handle: 'x.pds.test' }), { status: 200 }),
)

await getHandleByDid(DID, PDS_URL)

const callArgs = fetchSpy.mock.calls[0]
const options = callArgs[1] as RequestInit
expect(options.signal).toBeInstanceOf(AbortSignal)
})

it('returns null when response body is not JSON', async () => {
fetchSpy.mockResolvedValueOnce(
new Response('<html>not json</html>', {
status: 200,
headers: { 'Content-Type': 'text/html' },
}),
)

const result = await getHandleByDid(DID, PDS_URL)

expect(result).toBeNull()
})
})
27 changes: 27 additions & 0 deletions packages/auth-service/src/lib/get-handle-by-did.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Resolve the current handle for a DID via the PDS's public describeRepo
* XRPC endpoint (`com.atproto.repo.describeRepo`). Returns null if the PDS
* can't be reached or returns an unexpected shape — callers are expected to
* degrade gracefully (e.g. show `(unknown)` on the settings page).
*/
import { createLogger } from '@certified-app/shared'

const logger = createLogger('auth:get-handle-by-did')

export async function getHandleByDid(
did: string,
pdsUrl: string,
): Promise<string | null> {
try {
const res = await fetch(
`${pdsUrl}/xrpc/com.atproto.repo.describeRepo?repo=${encodeURIComponent(did)}`,
{ signal: AbortSignal.timeout(3000) },
)
if (!res.ok) return null
const data = (await res.json()) as { handle?: string }
return typeof data.handle === 'string' ? data.handle : null
} catch (err) {
logger.warn({ err, did }, 'Failed to resolve handle by DID from PDS')
return null
}
}
5 changes: 5 additions & 0 deletions packages/auth-service/src/routes/account-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '@certified-app/shared'
import { fromNodeHeaders } from 'better-auth/node'
import { getDidByEmail } from '../lib/get-did-by-email.js'
import { getHandleByDid } from '../lib/get-handle-by-did.js'
import { ensurePdsUrl } from '../lib/pds-url.js'

const logger = createLogger('auth:account-settings')
Expand Down Expand Up @@ -63,6 +64,7 @@ export function createAccountSettingsRouter(
// Look up DID from PDS
const did = await getDidByEmail(email, pdsUrl, internalSecret)
const backupEmails = did ? ctx.db.getBackupEmails(did) : []
const currentHandle = did ? await getHandleByDid(did, pdsUrl) : null

// Get all better-auth sessions for this user
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- better-auth session type not exported
Expand All @@ -81,6 +83,7 @@ export function createAccountSettingsRouter(
did: did ?? '(unknown)',
email,
handleDomain,
currentHandle,
backupEmails,
sessions,
currentSessionToken: session.session.token,
Expand Down Expand Up @@ -401,6 +404,7 @@ function renderSettingsPage(opts: {
did: string
email: string
handleDomain: string
currentHandle: string | null
backupEmails: Array<{ email: string; verified: number; id: number }>
sessions: Array<{
token: string
Expand Down Expand Up @@ -478,6 +482,7 @@ function renderSettingsPage(opts: {
<section class="section">
<h2>Handle</h2>
<p class="info">Your handle is your public username on the AT Protocol network.</p>
<div class="setting-row"><strong>Current Handle:</strong> <code>${escapeHtml(opts.currentHandle ?? '(unknown)')}</code></div>
<form method="POST" action="/account/handle" class="inline-form">
<input type="hidden" name="csrf" value="${escapeHtml(opts.csrfToken)}">
<input type="text" name="handle" placeholder="yourname" autocomplete="off" autocapitalize="none" spellcheck="false" required>
Expand Down
Loading