···11+import { describe, expect, it, vi } from 'vitest';
22+import { lookupActor } from './actor-lookup';
33+44+const ok = ( body: unknown ) =>
55+ ( { ok: true, json: async () => body } ) as unknown as Response;
66+const notOk = () => ( { ok: false, json: async () => ( {} ) } ) as unknown as Response;
77+88+describe( 'lookupActor', () => {
99+ it( 'returns a preview for a resolvable handle', async () => {
1010+ const fetchImpl = vi.fn().mockResolvedValue(
1111+ ok( {
1212+ did: 'did:plc:abc',
1313+ handle: 'alice.bsky.social',
1414+ displayName: 'Alice Rivers',
1515+ avatar: 'https://cdn.example/a.jpg',
1616+ } )
1717+ );
1818+ const preview = await lookupActor( '@Alice.bsky.social', fetchImpl as unknown as typeof fetch );
1919+ expect( preview ).toEqual( {
2020+ did: 'did:plc:abc',
2121+ handle: 'alice.bsky.social',
2222+ displayName: 'Alice Rivers',
2323+ avatar: 'https://cdn.example/a.jpg',
2424+ } );
2525+ expect( fetchImpl ).toHaveBeenCalledWith(
2626+ 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=alice.bsky.social'
2727+ );
2828+ } );
2929+3030+ it( 'passes a DID through untouched', async () => {
3131+ const fetchImpl = vi.fn().mockResolvedValue( ok( { did: 'did:plc:xyz', handle: 'x.test' } ) );
3232+ await lookupActor( 'did:plc:xyz', fetchImpl as unknown as typeof fetch );
3333+ expect( fetchImpl ).toHaveBeenCalledWith(
3434+ 'https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=did%3Aplc%3Axyz'
3535+ );
3636+ } );
3737+3838+ it( 'returns null without fetching for syntactically invalid input', async () => {
3939+ const fetchImpl = vi.fn();
4040+ expect( await lookupActor( 'notahandle', fetchImpl as unknown as typeof fetch ) ).toBeNull();
4141+ expect( await lookupActor( ' ', fetchImpl as unknown as typeof fetch ) ).toBeNull();
4242+ expect( fetchImpl ).not.toHaveBeenCalled();
4343+ } );
4444+4545+ it( 'returns null on a non-ok response', async () => {
4646+ const fetchImpl = vi.fn().mockResolvedValue( notOk() );
4747+ expect( await lookupActor( 'ghost.bsky.social', fetchImpl as unknown as typeof fetch ) ).toBeNull();
4848+ } );
4949+5050+ it( 'returns null when the network throws', async () => {
5151+ const fetchImpl = vi.fn().mockRejectedValue( new Error( 'offline' ) );
5252+ expect( await lookupActor( 'alice.bsky.social', fetchImpl as unknown as typeof fetch ) ).toBeNull();
5353+ } );
5454+5555+ it( 'returns null when the body has no did', async () => {
5656+ const fetchImpl = vi.fn().mockResolvedValue( ok( { handle: 'x.test' } ) );
5757+ expect( await lookupActor( 'x.test', fetchImpl as unknown as typeof fetch ) ).toBeNull();
5858+ } );
5959+} );
+51
src/lib/landing/actor-lookup.ts
···11+/**
22+ * Live "find me on the network" lookup for the landing handle input.
33+ *
44+ * Calls the PUBLIC Bluesky AppView UNAUTHENTICATED (no OAuth, no secrets) purely to
55+ * show the writer their own avatar + name as confirmation before sign-in. This runs
66+ * client-side in a React island, the host is a fixed constant, and `actor` is the only
77+ * user input — validated by `isValidHandleOrDid` and URL-encoded — so it can't be
88+ * coerced into a different host or path. Never throws: any failure resolves to `null`
99+ * ("no preview"), because submission must never depend on this lookup succeeding.
1010+ */
1111+import { isValidHandleOrDid, normalizeHandle } from '../auth/config';
1212+1313+const APPVIEW = 'https://public.api.bsky.app';
1414+1515+export interface ActorPreview {
1616+ did: string;
1717+ handle: string;
1818+ displayName: string | null;
1919+ avatar: string | null;
2020+}
2121+2222+export async function lookupActor(
2323+ input: string,
2424+ fetchImpl: typeof fetch = fetch
2525+): Promise< ActorPreview | null > {
2626+ const value = input.trim();
2727+ if ( ! isValidHandleOrDid( value ) ) {
2828+ return null;
2929+ }
3030+ const actor = value.startsWith( 'did:' ) ? value : normalizeHandle( value );
3131+ try {
3232+ const res = await fetchImpl(
3333+ `${ APPVIEW }/xrpc/app.bsky.actor.getProfile?actor=${ encodeURIComponent( actor ) }`
3434+ );
3535+ if ( ! res.ok ) {
3636+ return null;
3737+ }
3838+ const data = ( await res.json() ) as Partial< ActorPreview >;
3939+ if ( ! data?.did ) {
4040+ return null;
4141+ }
4242+ return {
4343+ did: data.did,
4444+ handle: data.handle ?? actor,
4545+ displayName: data.displayName ?? null,
4646+ avatar: data.avatar ?? null,
4747+ };
4848+ } catch {
4949+ return null;
5050+ }
5151+}