Skip to content

Commit 064bddf

Browse files
committed
feat(sdk): add getAccountEmail helper method
- Implement email retrieval from authenticated session - Support both transitional and granular permissions - Return null when permission not granted - Add comprehensive error handling and validation - Add 7 test cases covering all scenarios
1 parent 43a7b75 commit 064bddf

File tree

2 files changed

+201
-1
lines changed

2 files changed

+201
-1
lines changed

packages/sdk-core/src/core/SDK.ts

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { InMemorySessionStore } from "../storage/InMemorySessionStore.js";
66
import { InMemoryStateStore } from "../storage/InMemoryStateStore.js";
77
import type { ATProtoSDKConfig } from "./config.js";
88
import { ATProtoSDKConfigSchema } from "./config.js";
9-
import { ValidationError } from "./errors.js";
9+
import { ValidationError, NetworkError } from "./errors.js";
1010
import type { Session } from "./types.js";
1111

1212
/**
@@ -303,6 +303,105 @@ export class ATProtoSDK {
303303
return this.oauthClient.revoke(did.trim());
304304
}
305305

306+
/**
307+
* Gets the account email address from the authenticated session.
308+
*
309+
* This method retrieves the email address associated with the user's account
310+
* by calling the `com.atproto.server.getSession` endpoint. The email will only
311+
* be returned if the appropriate OAuth scope was granted during authorization.
312+
*
313+
* Required OAuth scopes:
314+
* - **Granular permissions**: `account:email?action=read` or `account:email`
315+
* - **Transitional permissions**: `transition:email`
316+
*
317+
* @param session - An authenticated OAuth session
318+
* @returns A Promise resolving to email info, or `null` if permission not granted
319+
* @throws {@link ValidationError} if the session is invalid
320+
* @throws {@link NetworkError} if the API request fails
321+
*
322+
* @example Using granular permissions
323+
* ```typescript
324+
* import { ScopePresets } from '@hypercerts-org/sdk-core';
325+
*
326+
* // Authorize with email scope
327+
* const authUrl = await sdk.authorize("user.bsky.social", {
328+
* scope: ScopePresets.EMAIL_READ
329+
* });
330+
*
331+
* // After callback...
332+
* const emailInfo = await sdk.getAccountEmail(session);
333+
* if (emailInfo) {
334+
* console.log(`Email: ${emailInfo.email}`);
335+
* console.log(`Confirmed: ${emailInfo.emailConfirmed}`);
336+
* } else {
337+
* console.log("Email permission not granted");
338+
* }
339+
* ```
340+
*
341+
* @example Using transitional permissions (legacy)
342+
* ```typescript
343+
* // Authorize with transition:email scope
344+
* const authUrl = await sdk.authorize("user.bsky.social", {
345+
* scope: "atproto transition:email"
346+
* });
347+
*
348+
* // After callback...
349+
* const emailInfo = await sdk.getAccountEmail(session);
350+
* ```
351+
*/
352+
async getAccountEmail(session: Session): Promise<{ email: string; emailConfirmed: boolean } | null> {
353+
if (!session) {
354+
throw new ValidationError("Session is required");
355+
}
356+
357+
try {
358+
// Determine PDS URL from session or config
359+
const pdsUrl = this.config.servers?.pds;
360+
if (!pdsUrl) {
361+
throw new ValidationError("PDS server URL not configured");
362+
}
363+
364+
// Call com.atproto.server.getSession endpoint using session's fetchHandler
365+
// which automatically includes proper authorization with DPoP
366+
const response = await session.fetchHandler("/xrpc/com.atproto.server.getSession", {
367+
method: "GET",
368+
headers: {
369+
"Content-Type": "application/json",
370+
},
371+
});
372+
373+
if (!response.ok) {
374+
throw new NetworkError(`Failed to get session info: ${response.status} ${response.statusText}`);
375+
}
376+
377+
const data = (await response.json()) as {
378+
email?: string;
379+
emailConfirmed?: boolean;
380+
did: string;
381+
handle: string;
382+
};
383+
384+
// Return null if email not present (permission not granted)
385+
if (!data.email) {
386+
return null;
387+
}
388+
389+
return {
390+
email: data.email,
391+
emailConfirmed: data.emailConfirmed ?? false,
392+
};
393+
} catch (error) {
394+
this.logger?.error("Failed to get account email", { error });
395+
if (error instanceof ValidationError || error instanceof NetworkError) {
396+
throw error;
397+
}
398+
throw new NetworkError(
399+
`Failed to get account email: ${error instanceof Error ? error.message : String(error)}`,
400+
error,
401+
);
402+
}
403+
}
404+
306405
/**
307406
* Creates a repository instance for data operations.
308407
*

packages/sdk-core/tests/core/SDK.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,107 @@ describe("ATProtoSDK", () => {
107107
});
108108
});
109109

110+
describe("getAccountEmail", () => {
111+
it("should throw ValidationError for null session", async () => {
112+
const sdk = new ATProtoSDK(config);
113+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
114+
await expect(sdk.getAccountEmail(null as any)).rejects.toThrow(ValidationError);
115+
});
116+
117+
it("should throw ValidationError when PDS not configured", async () => {
118+
const configWithoutPds = await createTestConfigAsync();
119+
delete configWithoutPds.servers;
120+
const sdk = new ATProtoSDK(configWithoutPds);
121+
const mockSession = createMockSession();
122+
await expect(sdk.getAccountEmail(mockSession)).rejects.toThrow(ValidationError);
123+
await expect(sdk.getAccountEmail(mockSession)).rejects.toThrow("PDS server URL not configured");
124+
});
125+
126+
it("should return email info when permission granted", async () => {
127+
const sdk = new ATProtoSDK(config);
128+
const mockSession = createMockSession({
129+
fetchHandler: async () =>
130+
new Response(
131+
JSON.stringify({
132+
did: "did:plc:testdid123456789012345678901234567890",
133+
handle: "test.bsky.social",
134+
135+
emailConfirmed: true,
136+
}),
137+
{ status: 200 },
138+
),
139+
});
140+
141+
const result = await sdk.getAccountEmail(mockSession);
142+
expect(result).not.toBeNull();
143+
expect(result?.email).toBe("[email protected]");
144+
expect(result?.emailConfirmed).toBe(true);
145+
});
146+
147+
it("should return null when permission not granted", async () => {
148+
const sdk = new ATProtoSDK(config);
149+
const mockSession = createMockSession({
150+
fetchHandler: async () =>
151+
new Response(
152+
JSON.stringify({
153+
did: "did:plc:testdid123456789012345678901234567890",
154+
handle: "test.bsky.social",
155+
// No email field - permission not granted
156+
}),
157+
{ status: 200 },
158+
),
159+
});
160+
161+
const result = await sdk.getAccountEmail(mockSession);
162+
expect(result).toBeNull();
163+
});
164+
165+
it("should default emailConfirmed to false when not provided", async () => {
166+
const sdk = new ATProtoSDK(config);
167+
const mockSession = createMockSession({
168+
fetchHandler: async () =>
169+
new Response(
170+
JSON.stringify({
171+
did: "did:plc:testdid123456789012345678901234567890",
172+
handle: "test.bsky.social",
173+
174+
// emailConfirmed not provided
175+
}),
176+
{ status: 200 },
177+
),
178+
});
179+
180+
const result = await sdk.getAccountEmail(mockSession);
181+
expect(result).not.toBeNull();
182+
expect(result?.email).toBe("[email protected]");
183+
expect(result?.emailConfirmed).toBe(false);
184+
});
185+
186+
it("should throw NetworkError on API failure", async () => {
187+
const sdk = new ATProtoSDK(config);
188+
const mockSession = createMockSession({
189+
fetchHandler: async () =>
190+
new Response(JSON.stringify({ error: "Internal Server Error" }), {
191+
status: 500,
192+
statusText: "Internal Server Error",
193+
}),
194+
});
195+
196+
await expect(sdk.getAccountEmail(mockSession)).rejects.toThrow("Failed to get session info");
197+
});
198+
199+
it("should throw NetworkError on network failure", async () => {
200+
const sdk = new ATProtoSDK(config);
201+
const mockSession = createMockSession({
202+
fetchHandler: async () => {
203+
throw new Error("Network error");
204+
},
205+
});
206+
207+
await expect(sdk.getAccountEmail(mockSession)).rejects.toThrow("Failed to get account email");
208+
});
209+
});
210+
110211
describe("repository", () => {
111212
it("should throw ValidationError when session is null", () => {
112213
const sdk = new ATProtoSDK(config);

0 commit comments

Comments
 (0)