From c53b4125fb5c710a6cdf0f66a720f3dd4834f374 Mon Sep 17 00:00:00 2001 From: Emilio Amaya Date: Wed, 4 Mar 2026 18:18:58 +0000 Subject: [PATCH 1/2] feat: add getProfile() to ATXPAccount - Add MeResponse type with account profile fields - Cache full /me response in ATXPAccount - Add getProfile() that returns cached profile data - Works whether account_id is in connection string or not Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/atxp-common/src/atxpAccount.test.ts | 105 +++++++++++++++++++ packages/atxp-common/src/atxpAccount.ts | 22 +++- packages/atxp-common/src/index.ts | 1 + packages/atxp-common/src/types.ts | 16 +++ 4 files changed, 141 insertions(+), 3 deletions(-) diff --git a/packages/atxp-common/src/atxpAccount.test.ts b/packages/atxp-common/src/atxpAccount.test.ts index dd860f6..b7cb35d 100644 --- a/packages/atxp-common/src/atxpAccount.test.ts +++ b/packages/atxp-common/src/atxpAccount.test.ts @@ -122,4 +122,109 @@ describe('ATXPAccount', () => { ); }); }); + + describe('getProfile', () => { + it('should return cached profile after getAccountId fetches /me', async () => { + const meResponse = { + accountId: 'atxp_acct_test', + accountType: 'agent', + funded: false, + developerMode: false, + stripeConnected: false, + }; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => meResponse, + }); + + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123', + { fetchFn: mockFetch } + ); + + // First call triggers /me fetch + const profile = await account.getProfile(); + expect(profile).toEqual(meResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + + // Second call returns cached — no extra fetch + const profile2 = await account.getProfile(); + expect(profile2).toEqual(meResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should share /me response with getAccountId (no duplicate request)', async () => { + const meResponse = { + accountId: 'atxp_acct_shared', + accountType: 'human', + funded: undefined, + }; + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => meResponse, + }); + + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123', + { fetchFn: mockFetch } + ); + + // getAccountId fetches /me + const accountId = await account.getAccountId(); + expect(accountId).toBe('atxp:atxp_acct_shared'); + + // getProfile returns cached data — no extra request + const profile = await account.getProfile(); + expect(profile.accountType).toBe('human'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should fetch /me even when account_id is in connection string', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + accountId: 'atxp_acct_inline', + accountType: 'agent', + funded: false, + }), + }); + + // account_id is in the connection string, so getAccountId() won't call /me + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123&account_id=atxp_acct_inline', + { fetchFn: mockFetch } + ); + + // getAccountId uses cached value — no fetch + const accountId = await account.getAccountId(); + expect(accountId).toBe('atxp:atxp_acct_inline'); + expect(mockFetch).not.toHaveBeenCalled(); + + // getProfile must fetch /me to get full profile + const profile = await account.getProfile(); + expect(profile.accountType).toBe('agent'); + expect(profile.funded).toBe(false); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('should return profile with funded field for agent accounts', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + accountId: 'atxp_acct_agent', + accountType: 'agent', + funded: true, + }), + }); + + const account = new ATXPAccount( + 'https://accounts.example.com?connection_token=ct_abc123', + { fetchFn: mockFetch } + ); + + const profile = await account.getProfile(); + expect(profile.accountType).toBe('agent'); + expect(profile.funded).toBe(true); + }); + }); }); diff --git a/packages/atxp-common/src/atxpAccount.ts b/packages/atxp-common/src/atxpAccount.ts index e39b83a..92f06f4 100644 --- a/packages/atxp-common/src/atxpAccount.ts +++ b/packages/atxp-common/src/atxpAccount.ts @@ -1,4 +1,4 @@ -import type { Account, PaymentMaker } from './types.js'; +import type { Account, PaymentMaker, MeResponse } from './types.js'; import type { FetchLike, Currency, AccountId, PaymentIdentifier, Destination, Chain, Source } from './types.js'; import BigNumber from 'bignumber.js'; @@ -147,6 +147,7 @@ export class ATXPAccount implements Account { private _cachedAccountId: AccountId | null = null; private _unqualifiedAccountId: string | null = null; private _accountIdPromise: Promise | null = null; + private _cachedProfile: MeResponse | null = null; constructor(connectionString: string, opts?: { fetchFn?: FetchLike; }) { const { origin, token, accountId } = parseConnectionString(connectionString); @@ -188,6 +189,20 @@ export class ATXPAccount implements Account { return this._accountIdPromise; } + /** + * Get the full /me profile, fetching if not already cached. + * If getAccountId() was satisfied from the connection string (no /me call), + * this will make a /me request to get the full profile. + */ + async getProfile(): Promise { + if (this._cachedProfile) { + return this._cachedProfile; + } + // Force a /me fetch to populate the profile + await this.fetchAccountIdFromMe(); + return this._cachedProfile!; + } + /** * Fetch account ID from the /me endpoint using Bearer auth */ @@ -205,12 +220,13 @@ export class ATXPAccount implements Account { throw new Error(`ATXPAccount: /me failed: ${response.status} ${response.statusText} ${text}`); } - const json = await response.json() as { accountId?: string }; + const json = await response.json() as MeResponse; if (!json?.accountId) { throw new Error('ATXPAccount: /me did not return accountId'); } - // Cache the result + // Cache the full profile and account ID + this._cachedProfile = json; this._unqualifiedAccountId = json.accountId; this._cachedAccountId = `atxp:${json.accountId}` as AccountId; return this._cachedAccountId; diff --git a/packages/atxp-common/src/index.ts b/packages/atxp-common/src/index.ts index b54ac64..5cf714a 100644 --- a/packages/atxp-common/src/index.ts +++ b/packages/atxp-common/src/index.ts @@ -70,6 +70,7 @@ export { type DestinationMaker, type PaymentDestination, type Account, + type MeResponse, type Source, extractAddressFromAccountId, extractNetworkFromAccountId diff --git a/packages/atxp-common/src/types.ts b/packages/atxp-common/src/types.ts index f2df6f0..30c8b8a 100644 --- a/packages/atxp-common/src/types.ts +++ b/packages/atxp-common/src/types.ts @@ -185,6 +185,22 @@ export type Account = PaymentDestination & { createSpendPermission: (resourceUrl: string) => Promise; } +/** + * Response from the /me endpoint on the accounts service. + * Contains account identity and status information. + */ +export interface MeResponse { + accountId: string; + accountType: string; + funded?: boolean; + developerMode?: boolean; + stripeConnected?: boolean; + displayName?: string; + email?: string; + ownerEmail?: string; + isOrphan?: boolean; +} + /** * Extract the address portion from a fully-qualified accountId * @param accountId - Format: network:address From a7db48e445d4d1311f893b7cae9563f0c0b42161 Mon Sep 17 00:00:00 2001 From: Emilio Amaya Date: Wed, 4 Mar 2026 18:46:04 +0000 Subject: [PATCH 2/2] fix: add dedup guard for concurrent getProfile() calls Mirrors the _accountIdPromise pattern from getAccountId() to prevent duplicate /me requests when getProfile() is called concurrently. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/atxp-common/src/atxpAccount.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/atxp-common/src/atxpAccount.ts b/packages/atxp-common/src/atxpAccount.ts index 92f06f4..032cbfa 100644 --- a/packages/atxp-common/src/atxpAccount.ts +++ b/packages/atxp-common/src/atxpAccount.ts @@ -148,6 +148,7 @@ export class ATXPAccount implements Account { private _unqualifiedAccountId: string | null = null; private _accountIdPromise: Promise | null = null; private _cachedProfile: MeResponse | null = null; + private _profilePromise: Promise | null = null; constructor(connectionString: string, opts?: { fetchFn?: FetchLike; }) { const { origin, token, accountId } = parseConnectionString(connectionString); @@ -198,9 +199,15 @@ export class ATXPAccount implements Account { if (this._cachedProfile) { return this._cachedProfile; } - // Force a /me fetch to populate the profile - await this.fetchAccountIdFromMe(); - return this._cachedProfile!; + if (!this._profilePromise) { + this._profilePromise = this.fetchAccountIdFromMe().then(() => { + if (!this._cachedProfile) { + throw new Error('ATXPAccount: /me succeeded but profile was not cached'); + } + return this._cachedProfile; + }); + } + return this._profilePromise; } /**