From 84be18a1d147dd2ba5ec0663f5f30fa901e37898 Mon Sep 17 00:00:00 2001 From: Gabriel Pan Gantes Date: Sun, 4 Oct 2026 23:23:27 +0200 Subject: [PATCH] feat(item): add fetchItemsCursor and fetchAllItems for GET /v2/items Opt-in, paid plans only: teams without the feature enabled get 403 LIST_ITEMS_FEATURE_NOT_ENABLED. --- CLAUDE.md | 1 + README.md | 23 +++++ src/client.ts | 53 +++++++++++ src/types/item.ts | 21 +++++ tests/itemsCursor.test.ts | 191 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 289 insertions(+) create mode 100644 tests/itemsCursor.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index e084380..e5a2771 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ Current SDK methods in `client.ts` (PluggyClient): | Connectors | `fetchConnectors()`, `fetchConnector()` | GET /connectors | | Connector Validation | `validateParameters()` | POST /connectors/{id}/validate | | Items | `createItem()`, `fetchItem()`, `updateItem()`, `deleteItem()` | /items | +| Item listing (opt-in, paid plans; 403 `LIST_ITEMS_FEATURE_NOT_ENABLED` otherwise) | `fetchItemsCursor()` (single page) + `fetchAllItems()` (full sweep) | GET /v2/items | | Item MFA | `updateItemMFA()` | POST /items/{id}/mfa | | Item Resources | `fetchItemResources()` | GET /items/{id}/resources | | SCR (opt-in) | `fetchItemScr()` | GET /items/{id}/scr | diff --git a/README.md b/README.md index 6892071..9e8c035 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,29 @@ await client.updateItemMFA(item.id, { }) ``` +#### Listing items (opt-in, paid plans only) + +> ⚠️ **Opt-in, paid plans only.** Listing items is disabled by default and is only available to paid-plan teams that have explicitly requested it from Pluggy support. Teams without it enabled get `403 LIST_ITEMS_FEATURE_NOT_ENABLED`. For most integrations, store each `itemId` when it is created (Pluggy Connect `onSuccess` or the `item/created` webhook) and use `fetchItem(id)` instead. + +`GET /v2/items` returns the team's items newest first, with cursor-based pagination. The only accepted filters are `clientUserId`, `connectorId` and `after`. + +- `fetchItemsCursor(options)` — fetch a single page (`{ results, next }`). `next` is a query string such as `?connectorId=201&after=`, or `null` on the last page. +- `fetchAllItems(options)` — follow the cursor through every page and return the full list. + +```ts +// Single page +const page = await client.fetchItemsCursor({ clientUserId: 'user-123' }) + +// Next page: read `after` from the `next` query string +if (page.next) { + const after = new URLSearchParams(page.next).get('after')! + const nextPage = await client.fetchItemsCursor({ clientUserId: 'user-123', after }) +} + +// Or every item at once +const items = await client.fetchAllItems({ connectorId: 201 }) +``` + ### Accounts and transactions (with pagination) Transactions use **cursor-based pagination** against `GET /v2/transactions`. The SDK exposes two helpers: diff --git a/src/client.ts b/src/client.ts index 932168c..119db9e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -18,6 +18,7 @@ import { Item, ItemResource, ItemResourceFilters, + ItemCursorFilters, CursorPageResponse, PageResponse, Parameters, @@ -84,6 +85,58 @@ export class PluggyClient extends BaseApi { return await this.createGetRequest(`items/${id}`) } + /** + * Fetch a single page of the team's items using cursor-based pagination (`GET /v2/items`), + * newest first. + * + * ⚠️ Opt-in, paid plans only. Listing items is disabled by default and is only available to + * paid-plan teams that have explicitly requested it from Pluggy support. Teams without it + * enabled get `403 LIST_ITEMS_FEATURE_NOT_ENABLED`. For most integrations, store each `itemId` + * when it is created (Pluggy Connect `onSuccess` or the `item/created` webhook) and use + * {@link fetchItem} instead. + * + * @param {ItemCursorFilters} options Optional filters (clientUserId, connectorId, after cursor) + * @returns {CursorPageResponse} object with results and the `next` page query string + * (e.g. `?connectorId=1&after=`), or null on the last page + */ + async fetchItemsCursor(options: ItemCursorFilters = {}): Promise> { + // Only whitelisted params are sent: the endpoint rejects unknown query parameters. + const { clientUserId, connectorId, after } = options + return await this.createGetRequest('v2/items', { clientUserId, connectorId, after }) + } + + /** + * Fetch all of the team's items, following the cursor across every page (`GET /v2/items`), + * newest first. + * + * ⚠️ Opt-in, paid plans only. Listing items is disabled by default and is only available to + * paid-plan teams that have explicitly requested it from Pluggy support. Teams without it + * enabled get `403 LIST_ITEMS_FEATURE_NOT_ENABLED`. For most integrations, store each `itemId` + * when it is created (Pluggy Connect `onSuccess` or the `item/created` webhook) and use + * {@link fetchItem} instead. + * + * @param {ItemCursorFilters} options Optional filters (clientUserId, connectorId) + * @returns {Item[]} an array of all matching items + */ + async fetchAllItems(options: Omit = {}): Promise { + const firstPage = await this.fetchItemsCursor(options) + const items: Item[] = [...firstPage.results] + + let next = firstPage.next + + while (next !== null) { + const afterParam = new URL(next, this.baseUrl).searchParams.get('after') + if (!afterParam) { + break + } + const page = await this.fetchItemsCursor({ ...options, after: afterParam }) + items.push(...page.results) + next = page.next + } + + return items + } + /** * Check that connector parameters are valid * @param id The Connector ID diff --git a/src/types/item.ts b/src/types/item.ts index 2146feb..4f4cd83 100644 --- a/src/types/item.ts +++ b/src/types/item.ts @@ -200,3 +200,24 @@ export type ItemResourceFilters = PageFilters & { /** Only return resources with this status. */ status?: ItemResourceStatus } + +/** + * Filters for {@link PluggyClient.fetchItemsCursor} / {@link PluggyClient.fetchAllItems} + * (`GET /v2/items`). + * + * ⚠️ Opt-in, paid plans only. Listing items is disabled by default and is only available to + * paid-plan teams that have explicitly requested it from Pluggy support. Teams without it + * enabled get `403 LIST_ITEMS_FEATURE_NOT_ENABLED`. For most integrations, store each `itemId` + * when it is created (Pluggy Connect `onSuccess` or the `item/created` webhook) and use + * `fetchItem(id)` instead. + * + * Only these fields are sent; the endpoint rejects any other query parameter. + */ +export type ItemCursorFilters = { + /** Only return items created with this `clientUserId`. Max 255 characters. */ + clientUserId?: string + /** Only return items of this connector. Integer >= 0. */ + connectorId?: number + /** Opaque cursor taken from the `after` query parameter of the previous page's `next` field */ + after?: string +} diff --git a/tests/itemsCursor.test.ts b/tests/itemsCursor.test.ts new file mode 100644 index 0000000..13936e1 --- /dev/null +++ b/tests/itemsCursor.test.ts @@ -0,0 +1,191 @@ +import nock from 'nock' +import { setupAuth } from './utils' +import { PluggyClient } from '../src/client' +import { CursorPageResponse, Item } from '../src/types' + +const API_URL = process.env.PLUGGY_API_URL! + +const mockItem = (id: string): Item => + ({ + id, + clientUserId: 'user-1', + status: 'UPDATED', + createdAt: new Date('2024-01-15'), + updatedAt: new Date('2024-01-15'), + } as unknown as Item) + +const mockCursorPage = (ids: string[], next: string | null): CursorPageResponse => ({ + results: ids.map(mockItem), + next, +}) + +const forbiddenBody = { + code: 403, + codeDescription: 'LIST_ITEMS_FEATURE_NOT_ENABLED', + message: 'This client is not enabled to list its items.', +} + +describe('fetchItemsCursor', () => { + beforeEach(() => { + nock.cleanAll() + setupAuth() + }) + + it('fetches the first page without query params', async () => { + const mock = nock(API_URL) + .get('/v2/items') + .query(query => Object.keys(query).length === 0) + .reply(200, mockCursorPage(['item-1', 'item-2'], null)) + + const client = new PluggyClient({ clientId: '123', clientSecret: '456' }) + const result = await client.fetchItemsCursor() + + expect(result.results.map(i => i.id)).toEqual(['item-1', 'item-2']) + expect(result.next).toBeNull() + expect(mock.isDone()).toBeTruthy() + }) + + it('passes clientUserId and connectorId filters as query params', async () => { + const mock = nock(API_URL) + .get('/v2/items') + .query({ clientUserId: 'user 1+test@example.com', connectorId: '201' }) + .reply( + 200, + mockCursorPage(['item-3'], '?clientUserId=user-1&connectorId=201&after=cursor-xyz') + ) + + const client = new PluggyClient({ clientId: '123', clientSecret: '456' }) + const result = await client.fetchItemsCursor({ + clientUserId: 'user 1+test@example.com', + connectorId: 201, + }) + + expect(result.results).toHaveLength(1) + expect(result.next).toContain('after=cursor-xyz') + expect(mock.isDone()).toBeTruthy() + }) + + it('passes the after cursor as query param, preserving base64 characters', async () => { + const cursor = 'eyJpZCI6+abc/def==' + const mock = nock(API_URL) + .get('/v2/items') + .query({ after: cursor }) + .reply(200, mockCursorPage(['item-4'], null)) + + const client = new PluggyClient({ clientId: '123', clientSecret: '456' }) + const result = await client.fetchItemsCursor({ after: cursor }) + + expect(result.results).toHaveLength(1) + expect(mock.isDone()).toBeTruthy() + }) + + it('does not send query params outside the whitelist', async () => { + const mock = nock(API_URL) + .get('/v2/items') + .query({ connectorId: '1' }) + .reply(200, mockCursorPage([], null)) + + const client = new PluggyClient({ clientId: '123', clientSecret: '456' }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + await client.fetchItemsCursor({ connectorId: 1, pageSize: 50 } as any) + + expect(mock.isDone()).toBeTruthy() + }) + + it('rejects with the API error body when the feature is not enabled (403)', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + nock(API_URL).get('/v2/items').query(true).reply(403, forbiddenBody) + + const client = new PluggyClient({ clientId: '123', clientSecret: '456' }) + + await expect(client.fetchItemsCursor()).rejects.toMatchObject({ + code: 403, + codeDescription: 'LIST_ITEMS_FEATURE_NOT_ENABLED', + }) + consoleSpy.mockRestore() + }) +}) + +describe('fetchAllItems (cursor-based)', () => { + beforeEach(() => { + nock.cleanAll() + setupAuth() + }) + + it('returns all results when there is only one page', async () => { + const mock = nock(API_URL) + .get('/v2/items') + .query(query => Object.keys(query).length === 0) + .reply(200, mockCursorPage(['item-1', 'item-2'], null)) + + const client = new PluggyClient({ clientId: '123', clientSecret: '456' }) + const items = await client.fetchAllItems() + + expect(items.map(i => i.id)).toEqual(['item-1', 'item-2']) + expect(mock.isDone()).toBeTruthy() + }) + + it('follows the cursor across three pages forwarding filters on every request', async () => { + const filters = { clientUserId: 'user-1', connectorId: 201 } + const cursor1 = 'cursor+page2==' + const cursor2 = 'cursor/page3' + + const mock1 = nock(API_URL) + .get('/v2/items') + .query({ clientUserId: 'user-1', connectorId: '201' }) + .reply( + 200, + mockCursorPage( + ['item-1', 'item-2'], + `?clientUserId=user-1&connectorId=201&after=${encodeURIComponent(cursor1)}` + ) + ) + const mock2 = nock(API_URL) + .get('/v2/items') + .query({ clientUserId: 'user-1', connectorId: '201', after: cursor1 }) + .reply( + 200, + mockCursorPage( + ['item-3'], + `?clientUserId=user-1&connectorId=201&after=${encodeURIComponent(cursor2)}` + ) + ) + const mock3 = nock(API_URL) + .get('/v2/items') + .query({ clientUserId: 'user-1', connectorId: '201', after: cursor2 }) + .reply(200, mockCursorPage(['item-4'], null)) + + const client = new PluggyClient({ clientId: '123', clientSecret: '456' }) + const items = await client.fetchAllItems(filters) + + expect(items.map(i => i.id)).toEqual(['item-1', 'item-2', 'item-3', 'item-4']) + expect(mock1.isDone()).toBeTruthy() + expect(mock2.isDone()).toBeTruthy() + expect(mock3.isDone()).toBeTruthy() + }) + + it('stops when next has no after param', async () => { + const mock = nock(API_URL) + .get('/v2/items') + .query(true) + .reply(200, mockCursorPage(['item-1'], '?connectorId=1')) + + const client = new PluggyClient({ clientId: '123', clientSecret: '456' }) + const items = await client.fetchAllItems() + + expect(items.map(i => i.id)).toEqual(['item-1']) + expect(mock.isDone()).toBeTruthy() + }) + + it('propagates the 403 when the feature is not enabled', async () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined) + nock(API_URL).get('/v2/items').query(true).reply(403, forbiddenBody) + + const client = new PluggyClient({ clientId: '123', clientSecret: '456' }) + + await expect(client.fetchAllItems()).rejects.toMatchObject({ + codeDescription: 'LIST_ITEMS_FEATURE_NOT_ENABLED', + }) + consoleSpy.mockRestore() + }) +})