diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 72a0faa079f..1d224d8f5af 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 ([#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 + ### Changed - Bump `@metamask/address-book-controller` from `^7.0.1` to `^7.1.0` ([#8225](https://github.com/MetaMask/core/pull/8225)) 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/__fixtures__/authenticated-userstorage.ts b/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts new file mode 100644 index 00000000000..db5db83b92b --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/__fixtures__/authenticated-userstorage.ts @@ -0,0 +1,75 @@ +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 function handleMockListDelegations(mockReply?: MockReply): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: [MOCK_DELEGATION_RESPONSE], + }; + return nock(MOCK_DELEGATIONS_URL) + .persist() + .get('') + .reply(reply.status, reply.body); +} + +export function handleMockCreateDelegation( + mockReply?: MockReply, + callback?: (uri: string, requestBody: nock.Body) => Promise, +): nock.Scope { + const reply = mockReply ?? { status: 200 }; + 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 { + const reply = mockReply ?? { status: 204 }; + return nock(MOCK_DELEGATIONS_URL) + .persist() + .delete(/.*/u) + .reply(reply.status, reply.body); +} + +export function handleMockGetNotificationPreferences( + mockReply?: MockReply, +): nock.Scope { + const reply = mockReply ?? { + status: 200, + body: MOCK_NOTIFICATION_PREFERENCES, + }; + return nock(MOCK_NOTIFICATION_PREFERENCES_URL) + .persist() + .get('') + .reply(reply.status, reply.body); +} + +export function handleMockPutNotificationPreferences( + mockReply?: MockReply, + callback?: (uri: string, requestBody: nock.Body) => Promise, +): nock.Scope { + const reply = mockReply ?? { status: 200 }; + 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); +} 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..9a65e20b198 --- /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 = { + walletActivity: 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..ae1260c161f --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authenticated-user-storage.test.ts @@ -0,0 +1,224 @@ +import { + handleMockListDelegations, + handleMockCreateDelegation, + handleMockRevokeDelegation, + handleMockGetNotificationPreferences, + handleMockPutNotificationPreferences, +} from './__fixtures__/authenticated-userstorage'; +import { + authenticatedStorageUrl, + 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(): { + storage: AuthenticatedUserStorage; + mockGetAccessToken: jest.Mock; +} { + const mockGetAccessToken = jest.fn().mockResolvedValue(MOCK_ACCESS_TOKEN); + const storage = new AuthenticatedUserStorage({ + env: Env.PRD, + getAccessToken: mockGetAccessToken, + }); + return { storage, mockGetAccessToken }; +} + +describe('AuthenticatedUserStorage - authenticatedStorageUrl()', () => { + it('generates the base URL for a given environment', () => { + const result = authenticatedStorageUrl(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..aeb820e91a3 --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/authenticated-user-storage.ts @@ -0,0 +1,252 @@ +import type { + AuthenticatedUserStorageConfig, + ClientType, + DelegationResponse, + DelegationSubmission, + NotificationPreferences, +} from './authenticated-user-storage-types'; +import { UserStorageError } from './errors'; +import type { Env } from '../shared/env'; +import { getEnvUrls } from '../shared/env'; + +export function authenticatedStorageUrl(env: Env): string { + return `${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 = `${authenticatedStorageUrl(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 (error) { + throw this.#wrapError('list delegations', error); + } + } + + async #createDelegation( + submission: DelegationSubmission, + clientType?: ClientType, + ): Promise { + try { + const headers = await this.#getAuthorizationHeader(); + const url = `${authenticatedStorageUrl(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 (error) { + throw this.#wrapError('create delegation', error); + } + } + + async #revokeDelegation(delegationHash: string): Promise { + try { + const headers = await this.#getAuthorizationHeader(); + const url = `${authenticatedStorageUrl(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 (error) { + throw this.#wrapError('revoke delegation', error); + } + } + + async #getNotificationPreferences(): Promise { + try { + const headers = await this.#getAuthorizationHeader(); + const url = `${authenticatedStorageUrl(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 (error) { + throw this.#wrapError('get notification preferences', error); + } + } + + async #putNotificationPreferences( + prefs: NotificationPreferences, + clientType?: ClientType, + ): Promise { + try { + const headers = await this.#getAuthorizationHeader(); + const url = `${authenticatedStorageUrl(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 (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}` }; + } + + 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, thrown: unknown): UserStorageError { + if (thrown instanceof UserStorageError) { + return thrown; + } + 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 21bedd22a7e..22f308e3d33 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 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 new file mode 100644 index 00000000000..3de9b95237e --- /dev/null +++ b/packages/profile-sync-controller/src/sdk/mocks/authenticated-userstorage.ts @@ -0,0 +1,59 @@ +import { Env } from '../../shared/env'; +import { authenticatedStorageUrl } from '../authenticated-user-storage'; +import type { + DelegationResponse, + DelegationSubmission, + NotificationPreferences, +} from '../authenticated-user-storage-types'; + +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: { + 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 = { + walletActivity: { + enabled: true, + accounts: [ + { + address: '0x1234567890abcdef1234567890abcdef12345678', + enabled: true, + }, + ], + }, + marketing: { enabled: false }, + perps: { enabled: true }, + socialAI: { + enabled: true, + txAmountLimit: 100, + tokens: ['ETH', 'USDC'], + }, +};