From c10189d994699ea4ec0024d1f84d29d739d09d32 Mon Sep 17 00:00:00 2001 From: Dovydas Stankevicius Date: Fri, 20 Mar 2026 10:49:38 +0000 Subject: [PATCH 1/6] feat: added authenticated user storage --- .../__fixtures__/authenticated-userstorage.ts | 71 +++++ .../sdk/authenticated-user-storage-types.ts | 113 ++++++++ .../sdk/authenticated-user-storage.test.ts | 222 ++++++++++++++++ .../src/sdk/authenticated-user-storage.ts | 251 ++++++++++++++++++ .../profile-sync-controller/src/sdk/index.ts | 2 + .../sdk/mocks/authenticated-userstorage.ts | 59 ++++ 6 files changed, 718 insertions(+) create mode 100644 packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts create mode 100644 packages/profile-sync-controller/src/sdk/authenticated-user-storage-types.ts create mode 100644 packages/profile-sync-controller/src/sdk/authenticated-user-storage.test.ts create mode 100644 packages/profile-sync-controller/src/sdk/authenticated-user-storage.ts create mode 100644 packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts new file mode 100644 index 00000000000..ecc17c193ce --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts @@ -0,0 +1,71 @@ +import nock from 'nock'; + +import { + MOCK_DELEGATIONS_URL, + MOCK_DELEGATION_RESPONSE, + MOCK_NOTIFICATION_PREFERENCES, + MOCK_NOTIFICATION_PREFERENCES_URL, +} from '../mocks/authenticated-userstorage'; + +type MockReply = { + status: nock.StatusCode; + body?: nock.Body; +}; + +export const handleMockListDelegations = (mockReply?: MockReply) => { + const reply = mockReply ?? { + status: 200, + body: [MOCK_DELEGATION_RESPONSE], + }; + return nock(MOCK_DELEGATIONS_URL) + .persist() + .get('') + .reply(reply.status, reply.body); +}; + +export const handleMockCreateDelegation = ( + mockReply?: MockReply, + callback?: (uri: string, requestBody: nock.Body) => Promise, +) => { + const reply = mockReply ?? { status: 200 }; + return nock(MOCK_DELEGATIONS_URL) + .persist() + .post('') + .reply(reply.status, async (uri, requestBody) => { + await callback?.(uri, requestBody); + }); +}; + +export const handleMockRevokeDelegation = (mockReply?: MockReply) => { + const reply = mockReply ?? { status: 204 }; + return nock(MOCK_DELEGATIONS_URL) + .persist() + .delete(/.*/u) + .reply(reply.status, reply.body); +}; + +export const handleMockGetNotificationPreferences = ( + mockReply?: MockReply, +) => { + const reply = mockReply ?? { + status: 200, + body: MOCK_NOTIFICATION_PREFERENCES, + }; + return nock(MOCK_NOTIFICATION_PREFERENCES_URL) + .persist() + .get('') + .reply(reply.status, reply.body); +}; + +export const handleMockPutNotificationPreferences = ( + mockReply?: MockReply, + callback?: (uri: string, requestBody: nock.Body) => Promise, +) => { + const reply = mockReply ?? { status: 200 }; + return nock(MOCK_NOTIFICATION_PREFERENCES_URL) + .persist() + .put('') + .reply(reply.status, async (uri, requestBody) => { + await callback?.(uri, requestBody); + }); +}; diff --git a/packages/profile-sync-controller/src/sdk/authenticated-user-storage-types.ts b/packages/profile-sync-controller/src/sdk/authenticated-user-storage-types.ts new file mode 100644 index 00000000000..cff365d3386 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authenticated-user-storage-types.ts @@ -0,0 +1,113 @@ +import type { Env } from '../shared/env'; + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +export type AuthenticatedUserStorageConfig = { + env: Env; + getAccessToken: () => Promise; +}; + +// --------------------------------------------------------------------------- +// Delegations +// --------------------------------------------------------------------------- + +/** A single caveat attached to a delegation. */ +export type Caveat = { + /** Address of the caveat enforcer contract (0x-prefixed). */ + enforcer: string; + /** ABI-encoded caveat terms. */ + terms: string; + /** ABI-encoded caveat arguments. */ + args: string; +}; + +/** An EIP-712 signed delegation. */ +export type SignedDelegation = { + /** Address the delegation is granted to (0x-prefixed). */ + delegate: string; + /** Address granting the delegation (0x-prefixed). */ + delegator: string; + /** Root authority or parent delegation hash (0x-prefixed). */ + authority: string; + /** Caveats restricting how the delegation may be used. */ + caveats: Caveat[]; + /** Unique salt to prevent replay (0x-prefixed). */ + salt: string; + /** EIP-712 signature over the delegation (0x-prefixed). */ + signature: string; +}; + +/** Metadata associated with a delegation. */ +export type DelegationMetadata = { + /** Keccak-256 hash uniquely identifying the delegation (0x-prefixed). */ + delegationHash: string; + /** Chain ID in hex format (0x-prefixed). */ + chainIdHex: string; + /** Token allowance in hex format (0x-prefixed). */ + allowance: string; + /** Symbol of the token (e.g. "USDC"). */ + tokenSymbol: string; + /** Token contract address (0x-prefixed). */ + tokenAddress: string; + /** Type of delegation. */ + type: string; +}; + +/** Request body for submitting a new delegation. */ +export type DelegationSubmission = { + signedDelegation: SignedDelegation; + metadata: DelegationMetadata; +}; + +/** A stored delegation record returned by the API. */ +export type DelegationResponse = { + signedDelegation: SignedDelegation; + metadata: DelegationMetadata; +}; + +// --------------------------------------------------------------------------- +// Preferences +// --------------------------------------------------------------------------- + +/** Wallet activity tracking for a single address. */ +export type WalletActivityAccount = { + /** Wallet address to track activity for (0x-prefixed). */ + address: string; + enabled: boolean; +}; + +export type WalletActivityPreference = { + enabled: boolean; + accounts: WalletActivityAccount[]; +}; + +export type MarketingPreference = { + enabled: boolean; +}; + +export type PerpsPreference = { + enabled: boolean; +}; + +export type SocialAIPreference = { + enabled: boolean; + txAmountLimit: number; + tokens: string[]; +}; + +/** Notification preferences for the authenticated user. */ +export type NotificationPreferences = { + wallet_activity: WalletActivityPreference; + marketing: MarketingPreference; + perps: PerpsPreference; + socialAI: SocialAIPreference; +}; + +// --------------------------------------------------------------------------- +// Shared +// --------------------------------------------------------------------------- + +/** The type of client making the request. */ +export type ClientType = 'extension' | 'mobile' | 'portfolio'; diff --git a/packages/profile-sync-controller/src/sdk/authenticated-user-storage.test.ts b/packages/profile-sync-controller/src/sdk/authenticated-user-storage.test.ts new file mode 100644 index 00000000000..2c0447eda9e --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authenticated-user-storage.test.ts @@ -0,0 +1,222 @@ +import { + handleMockListDelegations, + handleMockCreateDelegation, + handleMockRevokeDelegation, + handleMockGetNotificationPreferences, + handleMockPutNotificationPreferences, +} from './__fixtures__/authenticated-userstorage'; +import { + AUTHENTICATED_STORAGE_URL, + AuthenticatedUserStorage, +} from './authenticated-user-storage'; +import { UserStorageError } from './errors'; +import { + MOCK_DELEGATION_RESPONSE, + MOCK_DELEGATION_SUBMISSION, + MOCK_NOTIFICATION_PREFERENCES, +} from './mocks/authenticated-userstorage'; +import { Env } from '../shared/env'; + +const MOCK_ACCESS_TOKEN = 'mock-access-token'; + +function arrangeAuthenticatedUserStorage() { + const mockGetAccessToken = jest.fn().mockResolvedValue(MOCK_ACCESS_TOKEN); + const storage = new AuthenticatedUserStorage({ + env: Env.PRD, + getAccessToken: mockGetAccessToken, + }); + return { storage, mockGetAccessToken }; +} + +describe('AuthenticatedUserStorage - AUTHENTICATED_STORAGE_URL()', () => { + it('generates the base URL for a given environment', () => { + const result = AUTHENTICATED_STORAGE_URL(Env.PRD); + expect(result).toBe('https://user-storage.api.cx.metamask.io/api/v1'); + }); +}); + +describe('AuthenticatedUserStorage - delegations', () => { + it('lists delegations', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + const mock = handleMockListDelegations(); + + const result = await storage.delegations.list(); + + expect(mock.isDone()).toBe(true); + expect(result).toStrictEqual([MOCK_DELEGATION_RESPONSE]); + }); + + it('throws UserStorageError when list fails', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + handleMockListDelegations({ + status: 500, + body: { message: 'server error', error: 'internal' }, + }); + + await expect(storage.delegations.list()).rejects.toThrow(UserStorageError); + }); + + it('creates a delegation', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + const mock = handleMockCreateDelegation(); + + await storage.delegations.create(MOCK_DELEGATION_SUBMISSION); + + expect(mock.isDone()).toBe(true); + }); + + it('creates a delegation with clientType header', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + const mock = handleMockCreateDelegation(); + + await storage.delegations.create(MOCK_DELEGATION_SUBMISSION, 'extension'); + + expect(mock.isDone()).toBe(true); + }); + + it('throws UserStorageError on 409 conflict when creating duplicate delegation', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + handleMockCreateDelegation({ + status: 409, + body: { message: 'delegation already exists', error: 'conflict' }, + }); + + await expect( + storage.delegations.create(MOCK_DELEGATION_SUBMISSION), + ).rejects.toThrow(UserStorageError); + }); + + it('throws UserStorageError when create fails', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + handleMockCreateDelegation({ + status: 400, + body: { message: 'invalid body', error: 'bad_request' }, + }); + + await expect( + storage.delegations.create(MOCK_DELEGATION_SUBMISSION), + ).rejects.toThrow(UserStorageError); + }); + + it('revokes a delegation', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + const mock = handleMockRevokeDelegation(); + + await storage.delegations.revoke( + MOCK_DELEGATION_SUBMISSION.metadata.delegationHash, + ); + + expect(mock.isDone()).toBe(true); + }); + + it('throws UserStorageError when revoke returns 404', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + handleMockRevokeDelegation({ + status: 404, + body: { message: 'not found', error: 'not_found' }, + }); + + await expect(storage.delegations.revoke('0xdeadbeef')).rejects.toThrow( + UserStorageError, + ); + }); + + it('throws UserStorageError when revoke fails', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + handleMockRevokeDelegation({ + status: 500, + body: { message: 'server error', error: 'internal' }, + }); + + await expect(storage.delegations.revoke('0xdeadbeef')).rejects.toThrow( + UserStorageError, + ); + }); +}); + +describe('AuthenticatedUserStorage - preferences', () => { + it('gets notification preferences', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + const mock = handleMockGetNotificationPreferences(); + + const result = await storage.preferences.getNotifications(); + + expect(mock.isDone()).toBe(true); + expect(result).toStrictEqual(MOCK_NOTIFICATION_PREFERENCES); + }); + + it('returns null when notification preferences are not found', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + handleMockGetNotificationPreferences({ status: 404 }); + + const result = await storage.preferences.getNotifications(); + + expect(result).toBeNull(); + }); + + it('throws UserStorageError when get preferences fails', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + handleMockGetNotificationPreferences({ + status: 500, + body: { message: 'server error', error: 'internal' }, + }); + + await expect(storage.preferences.getNotifications()).rejects.toThrow( + UserStorageError, + ); + }); + + it('puts notification preferences', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + const mock = handleMockPutNotificationPreferences(); + + await storage.preferences.putNotifications(MOCK_NOTIFICATION_PREFERENCES); + + expect(mock.isDone()).toBe(true); + }); + + it('puts notification preferences with clientType header', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + const mock = handleMockPutNotificationPreferences(); + + await storage.preferences.putNotifications( + MOCK_NOTIFICATION_PREFERENCES, + 'mobile', + ); + + expect(mock.isDone()).toBe(true); + }); + + it('sends the correct request body when putting preferences', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + handleMockPutNotificationPreferences(undefined, async (_, requestBody) => { + expect(requestBody).toStrictEqual(MOCK_NOTIFICATION_PREFERENCES); + }); + + await storage.preferences.putNotifications(MOCK_NOTIFICATION_PREFERENCES); + }); + + it('throws UserStorageError when put preferences fails', async () => { + const { storage } = arrangeAuthenticatedUserStorage(); + handleMockPutNotificationPreferences({ + status: 400, + body: { message: 'invalid body', error: 'bad_request' }, + }); + + await expect( + storage.preferences.putNotifications(MOCK_NOTIFICATION_PREFERENCES), + ).rejects.toThrow(UserStorageError); + }); +}); + +describe('AuthenticatedUserStorage - authorization', () => { + it('passes the access token as a Bearer header', async () => { + const { storage, mockGetAccessToken } = + arrangeAuthenticatedUserStorage(); + handleMockListDelegations(); + + await storage.delegations.list(); + + expect(mockGetAccessToken).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/profile-sync-controller/src/sdk/authenticated-user-storage.ts b/packages/profile-sync-controller/src/sdk/authenticated-user-storage.ts new file mode 100644 index 00000000000..a861babf82c --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authenticated-user-storage.ts @@ -0,0 +1,251 @@ +import type { Env } from '../shared/env'; +import { getEnvUrls } from '../shared/env'; +import type { + AuthenticatedUserStorageConfig, + ClientType, + DelegationResponse, + DelegationSubmission, + NotificationPreferences, +} from './authenticated-user-storage-types'; +import { UserStorageError } from './errors'; + +export const AUTHENTICATED_STORAGE_URL = (env: Env) => + `${getEnvUrls(env).userStorageApiUrl}/api/v1`; + +type ErrorMessage = { + message: string; + error: string; +}; + +export class AuthenticatedUserStorage { + readonly #env: Env; + + readonly #getAccessToken: () => Promise; + + /** + * Domain accessor for delegation operations. + * + * Delegations are immutable signed records scoped to the authenticated user. + * Once a delegation is stored it cannot be modified -- it can only be revoked. + */ + public readonly delegations: { + /** + * Returns all delegation records belonging to the authenticated user. + * + * @returns An array of delegation records, or an empty array if none exist. + * @throws {UserStorageError} If the request fails. + */ + list: () => Promise; + /** + * Stores a signed delegation record for the authenticated user. + * Delegations are immutable; once stored they cannot be modified or replaced. + * + * @param submission - The signed delegation and its metadata. + * @param submission.signedDelegation - The EIP-712 signed delegation object. + * @param submission.metadata - Metadata including the delegation hash, chain, token, and type. + * @param clientType - Optional client type header (`'extension'`, `'mobile'`, or `'portfolio'`). + * @throws {UserStorageError} If the request fails. A 409 status indicates the delegation already exists. + */ + create: ( + submission: DelegationSubmission, + clientType?: ClientType, + ) => Promise; + /** + * Revokes (deletes) a delegation record. The caller must own the delegation. + * + * @param delegationHash - The unique hash identifying the delegation (hex string, 0x-prefixed). + * @throws {UserStorageError} If the request fails or the delegation is not found (404). + */ + revoke: (delegationHash: string) => Promise; + }; + + /** + * Domain accessor for user preference operations. + * + * Preferences are mutable structured records scoped to the authenticated user. + */ + public readonly preferences: { + /** + * Returns the notification preferences for the authenticated user. + * + * @returns The notification preferences object, or `null` if none have been set. + * @throws {UserStorageError} If the request fails. + */ + getNotifications: () => Promise; + /** + * Creates or updates the notification preferences for the authenticated user. + * On first call the record is created; subsequent calls update it. + * + * @param prefs - The full notification preferences object. + * @param clientType - Optional client type header (`'extension'`, `'mobile'`, or `'portfolio'`). + * @throws {UserStorageError} If the request fails. + */ + putNotifications: ( + prefs: NotificationPreferences, + clientType?: ClientType, + ) => Promise; + }; + + constructor(config: AuthenticatedUserStorageConfig) { + this.#env = config.env; + this.#getAccessToken = config.getAccessToken; + + this.delegations = { + list: this.#listDelegations.bind(this), + create: this.#createDelegation.bind(this), + revoke: this.#revokeDelegation.bind(this), + }; + + this.preferences = { + getNotifications: this.#getNotificationPreferences.bind(this), + putNotifications: this.#putNotificationPreferences.bind(this), + }; + } + + async #listDelegations(): Promise { + try { + const headers = await this.#getAuthorizationHeader(); + const url = `${AUTHENTICATED_STORAGE_URL(this.#env)}/delegations`; + + const response = await fetch(url, { + headers: { 'Content-Type': 'application/json', ...headers }, + }); + + if (!response.ok) { + throw await this.#buildHttpError(response); + } + + return (await response.json()) as DelegationResponse[]; + } catch (e) { + throw this.#wrapError('list delegations', e); + } + } + + async #createDelegation( + submission: DelegationSubmission, + clientType?: ClientType, + ): Promise { + try { + const headers = await this.#getAuthorizationHeader(); + const url = `${AUTHENTICATED_STORAGE_URL(this.#env)}/delegations`; + + const optionalHeaders: Record = {}; + if (clientType) { + optionalHeaders['X-Client-Type'] = clientType; + } + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...headers, + ...optionalHeaders, + }, + body: JSON.stringify(submission), + }); + + if (!response.ok) { + throw await this.#buildHttpError(response); + } + } catch (e) { + throw this.#wrapError('create delegation', e); + } + } + + async #revokeDelegation(delegationHash: string): Promise { + try { + const headers = await this.#getAuthorizationHeader(); + const url = `${AUTHENTICATED_STORAGE_URL(this.#env)}/delegations/${encodeURIComponent(delegationHash)}`; + + const response = await fetch(url, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json', ...headers }, + }); + + if (!response.ok) { + throw await this.#buildHttpError(response); + } + } catch (e) { + throw this.#wrapError('revoke delegation', e); + } + } + + async #getNotificationPreferences(): Promise { + try { + const headers = await this.#getAuthorizationHeader(); + const url = `${AUTHENTICATED_STORAGE_URL(this.#env)}/preferences/notifications`; + + const response = await fetch(url, { + headers: { 'Content-Type': 'application/json', ...headers }, + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + throw await this.#buildHttpError(response); + } + + return (await response.json()) as NotificationPreferences; + } catch (e) { + throw this.#wrapError('get notification preferences', e); + } + } + + async #putNotificationPreferences( + prefs: NotificationPreferences, + clientType?: ClientType, + ): Promise { + try { + const headers = await this.#getAuthorizationHeader(); + const url = `${AUTHENTICATED_STORAGE_URL(this.#env)}/preferences/notifications`; + + const optionalHeaders: Record = {}; + if (clientType) { + optionalHeaders['X-Client-Type'] = clientType; + } + + const response = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...headers, + ...optionalHeaders, + }, + body: JSON.stringify(prefs), + }); + + if (!response.ok) { + throw await this.#buildHttpError(response); + } + } catch (e) { + throw this.#wrapError('put notification preferences', e); + } + } + + async #getAuthorizationHeader(): Promise<{ Authorization: string }> { + const accessToken = await this.#getAccessToken(); + return { Authorization: `Bearer ${accessToken}` }; + } + + async #buildHttpError(response: Response): Promise { + const body: ErrorMessage = await response.json().catch(() => ({ + message: 'unknown', + error: 'unknown', + })); + return new Error( + `HTTP ${response.status} message: ${body.message}, error: ${body.error}`, + ); + } + + #wrapError(operation: string, e: unknown): UserStorageError { + if (e instanceof UserStorageError) { + return e; + } + const message = e instanceof Error ? e.message : JSON.stringify(e ?? ''); + return new UserStorageError( + `failed to ${operation}. ${message}`, + ); + } +} diff --git a/packages/profile-sync-controller/src/sdk/index.ts b/packages/profile-sync-controller/src/sdk/index.ts index 21bedd22a7e..b62b096bb0b 100644 --- a/packages/profile-sync-controller/src/sdk/index.ts +++ b/packages/profile-sync-controller/src/sdk/index.ts @@ -1,5 +1,7 @@ export * from './authentication'; export * from './user-storage'; +export * from './authenticated-user-storage'; +export * from './authenticated-user-storage-types'; export * from './errors'; export * from './utils/messaging-signing-snap-requests'; export * from '../shared/encryption'; diff --git a/packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts b/packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts new file mode 100644 index 00000000000..378e8ad770a --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts @@ -0,0 +1,59 @@ +import type { + DelegationResponse, + DelegationSubmission, + NotificationPreferences, +} from '../authenticated-user-storage-types'; +import { AUTHENTICATED_STORAGE_URL } from '../authenticated-user-storage'; +import { Env } from '../../shared/env'; + +export const MOCK_DELEGATIONS_URL = `${AUTHENTICATED_STORAGE_URL(Env.PRD)}/delegations`; +export const MOCK_NOTIFICATION_PREFERENCES_URL = `${AUTHENTICATED_STORAGE_URL(Env.PRD)}/preferences/notifications`; + +export const MOCK_DELEGATION_SUBMISSION: DelegationSubmission = { + signedDelegation: { + delegate: '0x1111111111111111111111111111111111111111', + delegator: '0x2222222222222222222222222222222222222222', + authority: + '0x0000000000000000000000000000000000000000000000000000000000000000', + caveats: [ + { + enforcer: '0x1234567890abcdef1234567890abcdef12345678', + terms: '0xabcdef', + args: '0x', + }, + ], + salt: '0x00000001', + signature: '0xaabbcc', + }, + metadata: { + delegationHash: + '0xdae6d132587770a2eb84411e125d9458a5fa3ec28615fee332f1947515041d10', + chainIdHex: '0x1', + allowance: '0xde0b6b3a7640000', + tokenSymbol: 'USDC', + tokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + type: 'spend', + }, +}; + +export const MOCK_DELEGATION_RESPONSE: DelegationResponse = + MOCK_DELEGATION_SUBMISSION; + +export const MOCK_NOTIFICATION_PREFERENCES: NotificationPreferences = { + wallet_activity: { + enabled: true, + accounts: [ + { + address: '0x1234567890abcdef1234567890abcdef12345678', + enabled: true, + }, + ], + }, + marketing: { enabled: false }, + perps: { enabled: true }, + socialAI: { + enabled: true, + txAmountLimit: 100, + tokens: ['ETH', 'USDC'], + }, +}; From 16163c3352db8b44529cf3b42d5ec2cf77773eda Mon Sep 17 00:00:00 2001 From: Dovydas Stankevicius Date: Fri, 20 Mar 2026 10:54:43 +0000 Subject: [PATCH 2/6] feat: updated changelog --- packages/profile-sync-controller/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 72a0faa079f..9d174b7e9ca 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AuthenticatedUserStorage` SDK class for authenticated (non-encrypted) user storage endpoints ([#TBD](https://github.com/MetaMask/core/pull/TBD)) + - Provides namespaced domain accessors: `delegations` (list, create, revoke) and `preferences` (getNotifications, putNotifications) + - Includes all TypeScript types for the delegation and notification preferences API schemas + ### Changed - Bump `@metamask/address-book-controller` from `^7.0.1` to `^7.1.0` ([#8225](https://github.com/MetaMask/core/pull/8225)) From 38b17aa1461f83e432b7928f0b9b940c264af1e9 Mon Sep 17 00:00:00 2001 From: Dovydas Stankevicius Date: Fri, 20 Mar 2026 10:59:44 +0000 Subject: [PATCH 3/6] feat: updated changelog --- packages/profile-sync-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 9d174b7e9ca..1d224d8f5af 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `AuthenticatedUserStorage` SDK class for authenticated (non-encrypted) user storage endpoints ([#TBD](https://github.com/MetaMask/core/pull/TBD)) +- Add `AuthenticatedUserStorage` SDK class for authenticated (non-encrypted) user storage endpoints ([#8260](https://github.com/MetaMask/core/pull/8260)) - Provides namespaced domain accessors: `delegations` (list, create, revoke) and `preferences` (getNotifications, putNotifications) - Includes all TypeScript types for the delegation and notification preferences API schemas From 2af31efb32be8d27741dfd121be3157695b7a93a Mon Sep 17 00:00:00 2001 From: Dovydas Stankevicius Date: Fri, 20 Mar 2026 11:11:27 +0000 Subject: [PATCH 4/6] feat: fixing linting errors --- .../__fixtures__/authenticated-userstorage.ts | 26 ++++----- .../sdk/authenticated-user-storage-types.ts | 1 + .../sdk/authenticated-user-storage.test.ts | 14 ++--- .../src/sdk/authenticated-user-storage.ts | 53 ++++++++++--------- .../profile-sync-controller/src/sdk/index.ts | 2 +- .../sdk/mocks/authenticated-userstorage.ts | 8 +-- 6 files changed, 54 insertions(+), 50 deletions(-) diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts index ecc17c193ce..46221af17cf 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts @@ -12,7 +12,7 @@ type MockReply = { body?: nock.Body; }; -export const handleMockListDelegations = (mockReply?: MockReply) => { +export function handleMockListDelegations(mockReply?: MockReply): nock.Scope { const reply = mockReply ?? { status: 200, body: [MOCK_DELEGATION_RESPONSE], @@ -21,12 +21,12 @@ export const handleMockListDelegations = (mockReply?: MockReply) => { .persist() .get('') .reply(reply.status, reply.body); -}; +} -export const handleMockCreateDelegation = ( +export function handleMockCreateDelegation( mockReply?: MockReply, callback?: (uri: string, requestBody: nock.Body) => Promise, -) => { +): nock.Scope { const reply = mockReply ?? { status: 200 }; return nock(MOCK_DELEGATIONS_URL) .persist() @@ -34,19 +34,19 @@ export const handleMockCreateDelegation = ( .reply(reply.status, async (uri, requestBody) => { await callback?.(uri, requestBody); }); -}; +} -export const handleMockRevokeDelegation = (mockReply?: MockReply) => { +export function handleMockRevokeDelegation(mockReply?: MockReply): nock.Scope { const reply = mockReply ?? { status: 204 }; return nock(MOCK_DELEGATIONS_URL) .persist() .delete(/.*/u) .reply(reply.status, reply.body); -}; +} -export const handleMockGetNotificationPreferences = ( +export function handleMockGetNotificationPreferences( mockReply?: MockReply, -) => { +): nock.Scope { const reply = mockReply ?? { status: 200, body: MOCK_NOTIFICATION_PREFERENCES, @@ -55,12 +55,12 @@ export const handleMockGetNotificationPreferences = ( .persist() .get('') .reply(reply.status, reply.body); -}; +} -export const handleMockPutNotificationPreferences = ( +export function handleMockPutNotificationPreferences( mockReply?: MockReply, callback?: (uri: string, requestBody: nock.Body) => Promise, -) => { +): nock.Scope { const reply = mockReply ?? { status: 200 }; return nock(MOCK_NOTIFICATION_PREFERENCES_URL) .persist() @@ -68,4 +68,4 @@ export const handleMockPutNotificationPreferences = ( .reply(reply.status, async (uri, requestBody) => { await callback?.(uri, requestBody); }); -}; +} diff --git a/packages/profile-sync-controller/src/sdk/authenticated-user-storage-types.ts b/packages/profile-sync-controller/src/sdk/authenticated-user-storage-types.ts index cff365d3386..d2d21dbfad2 100644 --- a/packages/profile-sync-controller/src/sdk/authenticated-user-storage-types.ts +++ b/packages/profile-sync-controller/src/sdk/authenticated-user-storage-types.ts @@ -99,6 +99,7 @@ export type SocialAIPreference = { /** Notification preferences for the authenticated user. */ export type NotificationPreferences = { + // eslint-disable-next-line @typescript-eslint/naming-convention wallet_activity: WalletActivityPreference; marketing: MarketingPreference; perps: PerpsPreference; diff --git a/packages/profile-sync-controller/src/sdk/authenticated-user-storage.test.ts b/packages/profile-sync-controller/src/sdk/authenticated-user-storage.test.ts index 2c0447eda9e..ae1260c161f 100644 --- a/packages/profile-sync-controller/src/sdk/authenticated-user-storage.test.ts +++ b/packages/profile-sync-controller/src/sdk/authenticated-user-storage.test.ts @@ -6,7 +6,7 @@ import { handleMockPutNotificationPreferences, } from './__fixtures__/authenticated-userstorage'; import { - AUTHENTICATED_STORAGE_URL, + authenticatedStorageUrl, AuthenticatedUserStorage, } from './authenticated-user-storage'; import { UserStorageError } from './errors'; @@ -19,7 +19,10 @@ import { Env } from '../shared/env'; const MOCK_ACCESS_TOKEN = 'mock-access-token'; -function arrangeAuthenticatedUserStorage() { +function arrangeAuthenticatedUserStorage(): { + storage: AuthenticatedUserStorage; + mockGetAccessToken: jest.Mock; +} { const mockGetAccessToken = jest.fn().mockResolvedValue(MOCK_ACCESS_TOKEN); const storage = new AuthenticatedUserStorage({ env: Env.PRD, @@ -28,9 +31,9 @@ function arrangeAuthenticatedUserStorage() { return { storage, mockGetAccessToken }; } -describe('AuthenticatedUserStorage - AUTHENTICATED_STORAGE_URL()', () => { +describe('AuthenticatedUserStorage - authenticatedStorageUrl()', () => { it('generates the base URL for a given environment', () => { - const result = AUTHENTICATED_STORAGE_URL(Env.PRD); + const result = authenticatedStorageUrl(Env.PRD); expect(result).toBe('https://user-storage.api.cx.metamask.io/api/v1'); }); }); @@ -211,8 +214,7 @@ describe('AuthenticatedUserStorage - preferences', () => { describe('AuthenticatedUserStorage - authorization', () => { it('passes the access token as a Bearer header', async () => { - const { storage, mockGetAccessToken } = - arrangeAuthenticatedUserStorage(); + const { storage, mockGetAccessToken } = arrangeAuthenticatedUserStorage(); handleMockListDelegations(); await storage.delegations.list(); diff --git a/packages/profile-sync-controller/src/sdk/authenticated-user-storage.ts b/packages/profile-sync-controller/src/sdk/authenticated-user-storage.ts index a861babf82c..aeb820e91a3 100644 --- a/packages/profile-sync-controller/src/sdk/authenticated-user-storage.ts +++ b/packages/profile-sync-controller/src/sdk/authenticated-user-storage.ts @@ -1,5 +1,3 @@ -import type { Env } from '../shared/env'; -import { getEnvUrls } from '../shared/env'; import type { AuthenticatedUserStorageConfig, ClientType, @@ -8,9 +6,12 @@ import type { NotificationPreferences, } from './authenticated-user-storage-types'; import { UserStorageError } from './errors'; +import type { Env } from '../shared/env'; +import { getEnvUrls } from '../shared/env'; -export const AUTHENTICATED_STORAGE_URL = (env: Env) => - `${getEnvUrls(env).userStorageApiUrl}/api/v1`; +export function authenticatedStorageUrl(env: Env): string { + return `${getEnvUrls(env).userStorageApiUrl}/api/v1`; +} type ErrorMessage = { message: string; @@ -105,7 +106,7 @@ export class AuthenticatedUserStorage { async #listDelegations(): Promise { try { const headers = await this.#getAuthorizationHeader(); - const url = `${AUTHENTICATED_STORAGE_URL(this.#env)}/delegations`; + const url = `${authenticatedStorageUrl(this.#env)}/delegations`; const response = await fetch(url, { headers: { 'Content-Type': 'application/json', ...headers }, @@ -116,8 +117,8 @@ export class AuthenticatedUserStorage { } return (await response.json()) as DelegationResponse[]; - } catch (e) { - throw this.#wrapError('list delegations', e); + } catch (error) { + throw this.#wrapError('list delegations', error); } } @@ -127,7 +128,7 @@ export class AuthenticatedUserStorage { ): Promise { try { const headers = await this.#getAuthorizationHeader(); - const url = `${AUTHENTICATED_STORAGE_URL(this.#env)}/delegations`; + const url = `${authenticatedStorageUrl(this.#env)}/delegations`; const optionalHeaders: Record = {}; if (clientType) { @@ -147,15 +148,15 @@ export class AuthenticatedUserStorage { if (!response.ok) { throw await this.#buildHttpError(response); } - } catch (e) { - throw this.#wrapError('create delegation', e); + } catch (error) { + throw this.#wrapError('create delegation', error); } } async #revokeDelegation(delegationHash: string): Promise { try { const headers = await this.#getAuthorizationHeader(); - const url = `${AUTHENTICATED_STORAGE_URL(this.#env)}/delegations/${encodeURIComponent(delegationHash)}`; + const url = `${authenticatedStorageUrl(this.#env)}/delegations/${encodeURIComponent(delegationHash)}`; const response = await fetch(url, { method: 'DELETE', @@ -165,15 +166,15 @@ export class AuthenticatedUserStorage { if (!response.ok) { throw await this.#buildHttpError(response); } - } catch (e) { - throw this.#wrapError('revoke delegation', e); + } catch (error) { + throw this.#wrapError('revoke delegation', error); } } async #getNotificationPreferences(): Promise { try { const headers = await this.#getAuthorizationHeader(); - const url = `${AUTHENTICATED_STORAGE_URL(this.#env)}/preferences/notifications`; + const url = `${authenticatedStorageUrl(this.#env)}/preferences/notifications`; const response = await fetch(url, { headers: { 'Content-Type': 'application/json', ...headers }, @@ -188,8 +189,8 @@ export class AuthenticatedUserStorage { } return (await response.json()) as NotificationPreferences; - } catch (e) { - throw this.#wrapError('get notification preferences', e); + } catch (error) { + throw this.#wrapError('get notification preferences', error); } } @@ -199,7 +200,7 @@ export class AuthenticatedUserStorage { ): Promise { try { const headers = await this.#getAuthorizationHeader(); - const url = `${AUTHENTICATED_STORAGE_URL(this.#env)}/preferences/notifications`; + const url = `${authenticatedStorageUrl(this.#env)}/preferences/notifications`; const optionalHeaders: Record = {}; if (clientType) { @@ -219,11 +220,12 @@ export class AuthenticatedUserStorage { if (!response.ok) { throw await this.#buildHttpError(response); } - } catch (e) { - throw this.#wrapError('put notification preferences', e); + } catch (error) { + throw this.#wrapError('put notification preferences', error); } } + // eslint-disable-next-line @typescript-eslint/naming-convention async #getAuthorizationHeader(): Promise<{ Authorization: string }> { const accessToken = await this.#getAccessToken(); return { Authorization: `Bearer ${accessToken}` }; @@ -239,13 +241,12 @@ export class AuthenticatedUserStorage { ); } - #wrapError(operation: string, e: unknown): UserStorageError { - if (e instanceof UserStorageError) { - return e; + #wrapError(operation: string, thrown: unknown): UserStorageError { + if (thrown instanceof UserStorageError) { + return thrown; } - const message = e instanceof Error ? e.message : JSON.stringify(e ?? ''); - return new UserStorageError( - `failed to ${operation}. ${message}`, - ); + const message = + thrown instanceof Error ? thrown.message : JSON.stringify(thrown ?? ''); + return new UserStorageError(`failed to ${operation}. ${message}`); } } diff --git a/packages/profile-sync-controller/src/sdk/index.ts b/packages/profile-sync-controller/src/sdk/index.ts index b62b096bb0b..22f308e3d33 100644 --- a/packages/profile-sync-controller/src/sdk/index.ts +++ b/packages/profile-sync-controller/src/sdk/index.ts @@ -1,7 +1,7 @@ export * from './authentication'; export * from './user-storage'; export * from './authenticated-user-storage'; -export * from './authenticated-user-storage-types'; +export type * from './authenticated-user-storage-types'; export * from './errors'; export * from './utils/messaging-signing-snap-requests'; export * from '../shared/encryption'; diff --git a/packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts b/packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts index 378e8ad770a..f89f3cd8802 100644 --- a/packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts +++ b/packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts @@ -1,13 +1,13 @@ +import { Env } from '../../shared/env'; +import { authenticatedStorageUrl } from '../authenticated-user-storage'; import type { DelegationResponse, DelegationSubmission, NotificationPreferences, } from '../authenticated-user-storage-types'; -import { AUTHENTICATED_STORAGE_URL } from '../authenticated-user-storage'; -import { Env } from '../../shared/env'; -export const MOCK_DELEGATIONS_URL = `${AUTHENTICATED_STORAGE_URL(Env.PRD)}/delegations`; -export const MOCK_NOTIFICATION_PREFERENCES_URL = `${AUTHENTICATED_STORAGE_URL(Env.PRD)}/preferences/notifications`; +export const MOCK_DELEGATIONS_URL = `${authenticatedStorageUrl(Env.PRD)}/delegations`; +export const MOCK_NOTIFICATION_PREFERENCES_URL = `${authenticatedStorageUrl(Env.PRD)}/preferences/notifications`; export const MOCK_DELEGATION_SUBMISSION: DelegationSubmission = { signedDelegation: { From 782b5ce092310ff3ab4e7611e7e490b5ccb0de00 Mon Sep 17 00:00:00 2001 From: Dovydas Stankevicius Date: Fri, 20 Mar 2026 11:23:45 +0000 Subject: [PATCH 5/6] feat: fixing linting errors --- .../__fixtures__/authenticated-userstorage.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts index 46221af17cf..db5db83b92b 100644 --- a/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts @@ -28,12 +28,14 @@ export function handleMockCreateDelegation( callback?: (uri: string, requestBody: nock.Body) => Promise, ): nock.Scope { const reply = mockReply ?? { status: 200 }; - return nock(MOCK_DELEGATIONS_URL) - .persist() - .post('') - .reply(reply.status, async (uri, requestBody) => { - await callback?.(uri, requestBody); + const interceptor = nock(MOCK_DELEGATIONS_URL).persist().post(''); + + if (callback) { + return interceptor.reply(reply.status, async (uri, requestBody) => { + await callback(uri, requestBody); }); + } + return interceptor.reply(reply.status, reply.body); } export function handleMockRevokeDelegation(mockReply?: MockReply): nock.Scope { @@ -62,10 +64,12 @@ export function handleMockPutNotificationPreferences( callback?: (uri: string, requestBody: nock.Body) => Promise, ): nock.Scope { const reply = mockReply ?? { status: 200 }; - return nock(MOCK_NOTIFICATION_PREFERENCES_URL) - .persist() - .put('') - .reply(reply.status, async (uri, requestBody) => { - await callback?.(uri, requestBody); + const interceptor = nock(MOCK_NOTIFICATION_PREFERENCES_URL).persist().put(''); + + if (callback) { + return interceptor.reply(reply.status, async (uri, requestBody) => { + await callback(uri, requestBody); }); + } + return interceptor.reply(reply.status, reply.body); } From 91701fc96832c563ba86b76ac205110a7a463bc6 Mon Sep 17 00:00:00 2001 From: Dovydas Stankevicius Date: Fri, 20 Mar 2026 12:00:53 +0000 Subject: [PATCH 6/6] feat: updated API contract --- .../profile-sync-controller/api.spec.yaml | 712 ++++++++++++++++++ .../sdk/authenticated-user-storage-types.ts | 3 +- .../sdk/mocks/authenticated-userstorage.ts | 2 +- 3 files changed, 714 insertions(+), 3 deletions(-) create mode 100644 packages/profile-sync-controller/api.spec.yaml diff --git a/packages/profile-sync-controller/api.spec.yaml b/packages/profile-sync-controller/api.spec.yaml new file mode 100644 index 00000000000..cd88d795c9b --- /dev/null +++ b/packages/profile-sync-controller/api.spec.yaml @@ -0,0 +1,712 @@ +openapi: 3.0.0 +info: + title: User Storage API + version: 1.0.0 + description: | + User Storage provides two storage models and an internal interface for service-to-service access. + + **End-to-End Encrypted User Storage** -- Data is encrypted client-side before it reaches the server. + The server stores opaque ciphertext and cannot read the contents. Used for syncing user + configuration (accounts, networks, preferences) across devices. + + **Authenticated User Storage** -- Data is stored as structured JSON, scoped to the authenticated user. + The server can read and validate the contents. Used for structured records that other services + need to consume (e.g. delegations, preferences, tokens). + + **Internal Endpoints** -- Service-to-service endpoints that are not exposed to end users. Access is + controlled by the `X-Service-Name` header. These endpoints allow whitelisted backend services to + read user data. +servers: + - url: 'https://user-storage.api.cx.metamask.io' + description: Main production server + - url: 'https://user-storage.uat-api.cx.metamask.io' + description: UAT server + - url: 'https://user-storage.dev-api.cx.metamask.io' + description: Dev server +tags: + - name: User Storage (End-to-End Encrypted) + description: | + Client-side encrypted key/value storage. All data is encrypted by the client before submission; + the server only stores and returns opaque ciphertext. + - name: Authenticated User Storage + description: | + Structured storage scoped to the authenticated user. Data is server-side encrypted, + allowing authorized services to access user preferences. + - name: Internal + description: | + Service-to-service endpoints. For internal use only. Callers must provide the `X-Service-Name` + header with a whitelisted service name. + +paths: + /docs-json: + get: + tags: + - Documentation + summary: Get OpenAPI specification as JSON + description: Returns the OpenAPI specification converted from YAML to JSON format. + responses: + 200: + description: OpenAPI specification in JSON format + content: + application/json: + schema: + type: object + description: OpenAPI 3.0.2 specification document + + # --------------------------------------------------------------------------- + # End-to-End Encrypted User Storage + # --------------------------------------------------------------------------- + + /api/v1/userstorage/{feature}: + get: + summary: Retrieve all keys in a feature for a specific user + operationId: getKeys + tags: + - User Storage (End-to-End Encrypted) + security: + - jwt: [] + parameters: + - name: feature + in: path + required: true + description: Feature name for the configuration + schema: + type: string + responses: + '200': + description: All keys within a feature for a specific user + content: + application/json: + schema: + type: array + items: + type: object + properties: + HashedKey: + type: string + Data: + type: string + '401': + description: Unauthorized request + '500': + description: Internal error + put: + summary: Batch write keys in a feature + operationId: putKeys + tags: + - User Storage (End-to-End Encrypted) + security: + - jwt: [] + parameters: + - name: feature + in: path + required: true + description: Feature name for the configuration + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + oneOf: + - required: ["data"] + properties: + data: + type: object + - required: ["batch_delete"] + properties: + batch_delete: + type: array + items: + type: string + responses: + '200': + description: Configuration keys updated successfully + '400': + description: Bad request - invalid input, empty request, or mixed update and delete + '401': + description: Unauthorized request + '413': + description: Request entity too large - too many keys or value exceeds 2MB limit + '423': + description: Locked - ongoing transaction + '500': + description: Internal error + delete: + summary: Delete all keys in a feature for a specific user + operationId: deleteKeys + tags: + - User Storage (End-to-End Encrypted) + security: + - jwt: [] + parameters: + - name: feature + in: path + required: true + description: Feature name for the configuration + schema: + type: string + responses: + '204': + description: Configuration keys deleted successfully + '401': + description: Unauthorized request + '500': + description: Internal error + + /api/v1/userstorage/{feature}/{key}: + get: + summary: Retrieve configuration key + operationId: getKey + tags: + - User Storage (End-to-End Encrypted) + security: + - jwt: [] + parameters: + - name: feature + in: path + required: true + description: Feature name for the configuration + schema: + type: string + - name: key + in: path + required: true + description: Key name for the configuration + schema: + type: string + responses: + '200': + description: Configuration key retrieved successfully + content: + application/json: + schema: + type: object + properties: + HashedKey: + type: string + Data: + type: string + examples: + exampleResponse: + value: + Data: "your data here" + HashedKey: "key" + '401': + description: Unauthorized request + '404': + description: Configuration not found + delete: + summary: Delete key by hash key + operationId: deleteKey + tags: + - User Storage (End-to-End Encrypted) + security: + - jwt: [] + parameters: + - name: feature + in: path + required: true + description: Feature name for the configuration + schema: + type: string + - name: key + in: path + required: true + description: Key name for the configuration + schema: + type: string + responses: + '204': + description: Configuration key has been successfully deleted + '401': + description: Unauthorized request + '404': + description: Configuration not found + put: + summary: Create or Update configuration key + operationId: putKey + tags: + - User Storage (End-to-End Encrypted) + security: + - jwt: [] + parameters: + - name: feature + in: path + required: true + description: Feature name for the configuration + schema: + type: string + - name: key + in: path + required: true + description: Key name for the configuration + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + data: + type: string + required: + - data + responses: + '200': + description: Configuration key updated successfully + '400': + description: Bad request - invalid body or empty data field + '401': + description: Unauthorized request + '413': + description: Request entity too large - body exceeds 2MB limit + + # --------------------------------------------------------------------------- + # Authenticated User Storage -- Delegations + # --------------------------------------------------------------------------- + + /api/v1/delegations: + get: + summary: List own delegations + description: | + Returns all delegation records belonging to the authenticated user. + operationId: listDelegations + tags: + - Authenticated User Storage + security: + - jwt: [] + responses: + '200': + description: List of delegations for the authenticated user + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DelegationResponse' + '401': + description: Unauthorized request + '500': + description: Internal error + post: + summary: Submit a signed delegation + description: | + Stores a signed delegation record for the authenticated user. + The delegation hash is provided in the metadata field. + Recorded delegations are immutable; once stored, a delegation cannot be modified or replaced. + operationId: createDelegation + tags: + - Authenticated User Storage + security: + - jwt: [] + parameters: + - name: X-Client-Type + in: header + required: false + description: The type of client submitting the delegation + schema: + $ref: '#/components/schemas/ClientType' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DelegationSubmission' + responses: + '200': + description: Delegation stored successfully + '400': + description: Bad request - invalid or missing fields + '401': + description: Unauthorized request + '409': + description: Conflict - delegation already exists and cannot be modified + '500': + description: Internal error + + /api/v1/delegations/{delegationHash}: + delete: + summary: Revoke a delegation + description: | + Removes a delegation record for the authenticated user. The caller must own the delegation. + operationId: revokeDelegation + tags: + - Authenticated User Storage + security: + - jwt: [] + parameters: + - name: delegationHash + in: path + required: true + description: The unique hash identifying the delegation (hex string, 0x-prefixed) + schema: + type: string + responses: + '204': + description: Delegation revoked successfully + '401': + description: Unauthorized request + '404': + description: Delegation not found + '500': + description: Internal error + + # --------------------------------------------------------------------------- + # Authenticated User Storage -- Preferences + # --------------------------------------------------------------------------- + + /api/v1/preferences/notifications: + get: + summary: Retrieve notification preferences + description: | + Returns the notification preferences for the authenticated user. + operationId: getNotificationPreferences + tags: + - Authenticated User Storage + security: + - jwt: [] + responses: + '200': + description: Notification preferences for the authenticated user + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationPreferences' + '401': + description: Unauthorized request + '404': + description: Notification preferences not found + '500': + description: Internal error + put: + summary: Create or update notification preferences + description: | + Upserts the notification preferences for the authenticated user. + On first call the record is created; subsequent calls update the stored preferences. + operationId: putNotificationPreferences + tags: + - Authenticated User Storage + security: + - jwt: [] + parameters: + - name: X-Client-Type + in: header + required: false + description: The type of client submitting the preferences + schema: + $ref: '#/components/schemas/ClientType' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationPreferences' + responses: + '200': + description: Notification preferences saved successfully + '400': + description: Bad request - invalid or missing fields + '401': + description: Unauthorized request + '500': + description: Internal error + + # --------------------------------------------------------------------------- + # Internal -- Service-to-Service + # --------------------------------------------------------------------------- + + /internal/api/v1/profiles/{profileId}/delegations: + get: + summary: Read delegations for a profile (service-to-service) + description: | + Internal endpoint for backend services to read delegation records for a given profile. + Requires the `X-Service-Name` header with a whitelisted service name. + operationId: getProfileDelegations + tags: + - Internal + parameters: + - name: profileId + in: path + required: true + description: The profile identifier to look up delegations for + schema: + type: string + - name: X-Service-Name + in: header + required: true + schema: + $ref: '#/components/schemas/ServiceName' + responses: + '200': + description: List of delegations for the requested profile + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DelegationResponse' + '403': + description: Forbidden - X-Service-Name is missing or not whitelisted + '404': + description: Profile not found + '500': + description: Internal error + + /internal/api/v1/profiles/{profileId}/preferences/notifications: + get: + summary: Read notification preferences for a profile (service-to-service) + description: | + Internal endpoint for backend services to read notification preferences for a given profile. + Requires the `X-Service-Name` header with a whitelisted service name. + operationId: getProfileNotificationPreferences + tags: + - Internal + parameters: + - name: profileId + in: path + required: true + description: The profile identifier to look up notification preferences for + schema: + type: string + - name: X-Service-Name + in: header + required: true + schema: + $ref: '#/components/schemas/ServiceName' + responses: + '200': + description: Notification preferences for the requested profile + content: + application/json: + schema: + $ref: '#/components/schemas/NotificationPreferences' + '403': + description: Forbidden - X-Service-Name is missing or not whitelisted + '404': + description: Notification preferences not found for profile + '500': + description: Internal error + +components: + schemas: + ServiceName: + type: string + enum: + - chomp-api + - chomp-delegation-executor + - notification-api + - trigger-api + - push-api + - perps-api + - notify-platform-api + + ClientType: + type: string + description: The type of client making the request + enum: + - extension + - mobile + - portfolio + + Caveat: + type: object + required: + - enforcer + - terms + - args + properties: + enforcer: + type: string + description: Address of the caveat enforcer contract + example: '0x1234567890abcdef1234567890abcdef12345678' + terms: + type: string + description: ABI-encoded caveat terms + example: '0xabcdef' + args: + type: string + description: ABI-encoded caveat arguments + example: '0x' + + SignedDelegation: + type: object + required: + - delegate + - delegator + - authority + - caveats + - salt + - signature + properties: + delegate: + type: string + description: Address the delegation is granted to + example: '0x1111111111111111111111111111111111111111' + delegator: + type: string + description: Address granting the delegation + example: '0x2222222222222222222222222222222222222222' + authority: + type: string + description: Root authority or parent delegation hash + example: '0x0000000000000000000000000000000000000000000000000000000000000000' + caveats: + type: array + items: + $ref: '#/components/schemas/Caveat' + salt: + type: string + description: Unique salt to prevent replay + example: '0x00000001' + signature: + type: string + description: EIP-712 signature over the delegation + example: '0xaabbcc...' + + DelegationMetadata: + type: object + description: Metadata associated with a delegation + required: + - delegationHash + - chainIdHex + - allowance + - tokenSymbol + - tokenAddress + - type + properties: + delegationHash: + type: string + description: Keccak-256 hash uniquely identifying the delegation (0x-prefixed) + example: '0xdae6d132587770a2eb84411e125d9458a5fa3ec28615fee332f1947515041d10' + chainIdHex: + type: string + description: Chain ID in hex format (0x-prefixed) + example: '0x1' + allowance: + type: string + description: Token allowance in hex format (0x-prefixed) + example: '0xde0b6b3a7640000' + tokenSymbol: + type: string + description: Symbol of the token + example: 'USDC' + tokenAddress: + type: string + description: Token contract address (0x-prefixed) + example: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' + type: + type: string + description: Type of delegation + + DelegationSubmission: + type: object + description: Request body for submitting a new delegation + required: + - signedDelegation + - metadata + properties: + signedDelegation: + $ref: '#/components/schemas/SignedDelegation' + metadata: + $ref: '#/components/schemas/DelegationMetadata' + + DelegationResponse: + type: object + description: A stored delegation record + required: + - signedDelegation + - metadata + properties: + signedDelegation: + $ref: '#/components/schemas/SignedDelegation' + metadata: + $ref: '#/components/schemas/DelegationMetadata' + + NotificationPreferences: + type: object + description: Notification preferences for the authenticated user + required: + - walletActivity + - marketing + - perps + - socialAI + properties: + walletActivity: + $ref: '#/components/schemas/WalletActivityPreference' + marketing: + $ref: '#/components/schemas/MarketingPreference' + perps: + $ref: '#/components/schemas/PerpsPreference' + socialAI: + $ref: '#/components/schemas/SocialAIPreference' + + WalletActivityPreference: + type: object + required: + - enabled + - accounts + properties: + enabled: + type: boolean + accounts: + type: array + items: + $ref: '#/components/schemas/WalletActivityAccount' + + WalletActivityAccount: + type: object + required: + - address + - enabled + properties: + address: + type: string + description: Wallet address to track activity for + example: '0x1234567890abcdef1234567890abcdef12345678' + enabled: + type: boolean + + MarketingPreference: + type: object + required: + - enabled + properties: + enabled: + type: boolean + + PerpsPreference: + type: object + required: + - enabled + properties: + enabled: + type: boolean + + SocialAIPreference: + type: object + required: + - enabled + - txAmountLimit + - tokens + properties: + enabled: + type: boolean + txAmountLimit: + type: integer + tokens: + type: array + items: + type: string + + securitySchemes: + jwt: + type: http + scheme: bearer + bearerFormat: JWT + description: | + User-facing authentication. The JWT `sub` claim identifies the user profile. diff --git a/packages/profile-sync-controller/src/sdk/authenticated-user-storage-types.ts b/packages/profile-sync-controller/src/sdk/authenticated-user-storage-types.ts index d2d21dbfad2..9a65e20b198 100644 --- a/packages/profile-sync-controller/src/sdk/authenticated-user-storage-types.ts +++ b/packages/profile-sync-controller/src/sdk/authenticated-user-storage-types.ts @@ -99,8 +99,7 @@ export type SocialAIPreference = { /** Notification preferences for the authenticated user. */ export type NotificationPreferences = { - // eslint-disable-next-line @typescript-eslint/naming-convention - wallet_activity: WalletActivityPreference; + walletActivity: WalletActivityPreference; marketing: MarketingPreference; perps: PerpsPreference; socialAI: SocialAIPreference; diff --git a/packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts b/packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts index f89f3cd8802..3de9b95237e 100644 --- a/packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts +++ b/packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts @@ -40,7 +40,7 @@ export const MOCK_DELEGATION_RESPONSE: DelegationResponse = MOCK_DELEGATION_SUBMISSION; export const MOCK_NOTIFICATION_PREFERENCES: NotificationPreferences = { - wallet_activity: { + walletActivity: { enabled: true, accounts: [ {