From 147618e91ca9f3f82a4bf1c7191ea42b9be5d944 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:40:31 +0200 Subject: [PATCH 1/4] Pin header-placed HMAC signing at a fixed clock Captured on main before any signing change: the exact headers, URL and query Kaufland and the generic header template put on the wire. --- .../engines/rest.engine.hmac-pin.spec.ts | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 packages/backend/src/connectors/engines/rest.engine.hmac-pin.spec.ts diff --git a/packages/backend/src/connectors/engines/rest.engine.hmac-pin.spec.ts b/packages/backend/src/connectors/engines/rest.engine.hmac-pin.spec.ts new file mode 100644 index 00000000..210ee590 --- /dev/null +++ b/packages/backend/src/connectors/engines/rest.engine.hmac-pin.spec.ts @@ -0,0 +1,178 @@ +import axios from 'axios'; +import { RestEngine } from './rest.engine'; +import { OAuth2TokenService } from './oauth2-token.service'; +import { LoginTokenService } from './login-token.service'; + +/** + * Pins what HMAC signing puts on the wire for the adapters that already use + * it, byte for byte, at a fixed clock. The expected values below were + * captured on main before the query-signing options were added, so any + * change to the header-placed path (Kaufland's) fails here, not in a + * seller's account. + */ +jest.mock('axios', () => { + const actual = jest.requireActual('axios'); + return { __esModule: true, default: jest.fn(), AxiosError: actual.AxiosError }; +}); +const mockedAxios = axios as unknown as jest.Mock; + +const kaufland = require('../../adapters/de/kaufland.json') as { + connector: { baseUrl: string; authType: string; authConfig: Record; headers: Record }; + tools: Array<{ name: string; endpointMapping: Record }>; +}; + +const FIXED_NOW = 1_760_000_000_000; // 2025-10-09T08:53:20Z + +function kauflandAuth(): Record { + return JSON.parse( + JSON.stringify(kaufland.connector.authConfig) + .replace('{{KAUFLAND_SECRET_KEY}}', 'pin-secret') + .replace('{{KAUFLAND_CLIENT_KEY}}', 'pin-client'), + ); +} + +/** Everything the engine handed to axios that reaches the wire. */ +function wire(cfg: Record) { + const realAxios = (jest.requireActual('axios') as { default: typeof axios }).default; + return { + method: cfg.method, + url: cfg.url, + sentUrl: realAxios.getUri(cfg as never), + headers: cfg.headers, + params: cfg.params, + data: cfg.data, + serializedQuery: typeof cfg.paramsSerializer === 'function' ? cfg.paramsSerializer(cfg.params ?? {}) : null, + }; +} + +describe('HMAC signing, pinned at a fixed clock (header placement)', () => { + let engine: RestEngine; + let now: jest.SpyInstance; + + beforeEach(() => { + mockedAxios.mockReset(); + mockedAxios.mockResolvedValue({ data: {}, headers: {} }); + engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService); + now = jest.spyOn(Date, 'now').mockReturnValue(FIXED_NOW); + }); + afterEach(() => now.mockRestore()); + + const run = async (toolName: string, params: Record) => { + const tool = kaufland.tools.find((t) => t.name === toolName)!; + await engine.execute( + { + baseUrl: kaufland.connector.baseUrl, + authType: kaufland.connector.authType, + authConfig: kauflandAuth(), + headers: kaufland.connector.headers, + }, + tool.endpointMapping as never, + params, + ); + return wire(mockedAxios.mock.calls[0][0]); + }; + + it('kaufland_list_warehouses', async () => { + expect(await run('kaufland_list_warehouses', {})).toEqual({ + method: 'GET', + url: 'https://sellerapi.kaufland.com/v2/warehouses', + sentUrl: 'https://sellerapi.kaufland.com/v2/warehouses', + headers: { + Accept: 'application/json', + 'Shop-Client-Key': 'pin-client', + 'Shop-Signature': '42976d71ac792c9257f1b9ac797f2c3a74a77022d5356e7a9d8af1689b657f66', + 'Shop-Timestamp': '1760000000', + }, + params: undefined, + data: undefined, + serializedQuery: null, + }); + }); + + it('kaufland_list_orders with a filter in the query', async () => { + expect( + await run('kaufland_list_orders', { + storefront: 'de', + ts_created_from: '2025-10-01T00:00:00Z', + limit: 20, + offset: 40, + }), + ).toEqual({ + method: 'GET', + url: 'https://sellerapi.kaufland.com/v2/orders', + sentUrl: 'https://sellerapi.kaufland.com/v2/orders?storefront=de&ts_created_from=2025-10-01T00:00:00Z&limit=20&offset=40', + headers: { + Accept: 'application/json', + 'Shop-Client-Key': 'pin-client', + 'Shop-Signature': '7dfc78692a18762f09404c1055ebbc18ba300004c97b4a615b5d8bc2f60a7bf3', + 'Shop-Timestamp': '1760000000', + }, + params: { storefront: 'de', ts_created_from: '2025-10-01T00:00:00Z', limit: 20, offset: 40 }, + data: undefined, + serializedQuery: 'storefront=de&ts_created_from=2025-10-01T00:00:00Z&limit=20&offset=40', + }); + }); + + it('kaufland_get_order with a path id', async () => { + expect(await run('kaufland_get_order', { id_order: 'AB12C', embedded: 'delivery' })).toEqual({ + method: 'GET', + url: 'https://sellerapi.kaufland.com/v2/orders/AB12C', + sentUrl: 'https://sellerapi.kaufland.com/v2/orders/AB12C?embedded=delivery', + headers: { + Accept: 'application/json', + 'Shop-Client-Key': 'pin-client', + 'Shop-Signature': '79c235e9556fe8592ca0afb93e7287c19801da3293c79d4647499cac85f7e0ad', + 'Shop-Timestamp': '1760000000', + }, + params: { embedded: 'delivery' }, + data: undefined, + serializedQuery: 'embedded=delivery', + }); + }); + + it('a POST body, a path template and a trailing newline', async () => { + await engine.execute( + { + baseUrl: 'https://api.test', + authType: 'HMAC', + authConfig: { + signature: { + secret: 's3cret', + template: '${method}\n${path}\n${body}\n${timestamp}\n', + headerName: 'X-Sig', + timestampHeader: 'X-Ts', + extraHeaders: { 'X-Key': 'k1' }, + }, + }, + }, + { method: 'POST', path: '/units', queryParams: { a: '$a' }, bodyMapping: { sku: '$sku', qty: '$qty' } } as never, + { sku: 'A-1', qty: 3, a: 'x y' }, + ); + expect(wire(mockedAxios.mock.calls[0][0])).toEqual({ + method: 'POST', + url: 'https://api.test/units', + sentUrl: 'https://api.test/units?a=x+y', + headers: { 'X-Key': 'k1', 'X-Sig': 'ab5d691029d50241941f3c2e6204c173222e2b18656a57424d8d371f631ccdf0', 'X-Ts': '1760000000' }, + params: { a: 'x y' }, + data: { sku: 'A-1', qty: 3 }, + serializedQuery: 'a=x+y', + }); + }); + + it('defaults: sha256, hex, header "Signature", the Kaufland template', async () => { + await engine.execute( + { baseUrl: 'https://api.test', authType: 'HMAC', authConfig: { signature: { secret: 'k' } } }, + { method: 'GET', path: '/x' } as never, + {}, + ); + expect(wire(mockedAxios.mock.calls[0][0])).toEqual({ + method: 'GET', + url: 'https://api.test/x', + sentUrl: 'https://api.test/x', + headers: { Signature: 'cab302b74a6a3d7959b966d7718d5d95ef257792a1a7febb10178a33f8576f98' }, + params: undefined, + data: undefined, + serializedQuery: null, + }); + }); +}); From 9adbf88e709ca3fec2b48ab50230194f98bdaf5e Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:29:43 +0200 Subject: [PATCH 2/4] Query-signed HMAC and Shopee shop authorization, opt-in - HMAC signing takes optional query placement (placement, paramName, timestampParam, queryParams) and two template variables (${pathname}, ${query.NAME}). Without those fields the original code path runs unchanged; the header-placed signatures pinned on main stay identical. - ShopeeTokenService: code exchange, renewal before expiry with the rotated refresh token stored encrypted on the connector, one renewal per connector (in process and under an advisory lock), needs_reauth and one email when Shopee refuses the refresh token. Only connectors with authType HMAC and authConfig.tokenProvider "shopee" reach it. - Shopee's own authorize, callback and complete endpoints under /api/shopee, on the same encrypted single-use attempt store as OAuth; the generic OAuth callback is untouched. - A Shopee connector without a shop authorization reports needs_authorization and skips the import probe. - Dashboard: "Authorize with Provider" card for Shopee connectors, the completion page and the setup page route Shopee to its endpoints. --- .../backend/src/adapters/adapters.service.ts | 3 + .../connectors/connector-setup-status.util.ts | 6 + .../src/connectors/connectors.module.ts | 3 +- .../engines/rest.engine.shopee.spec.ts | 247 +++++++ .../src/connectors/engines/rest.engine.ts | 165 ++++- .../engines/shopee-token.service.spec.ts | 347 +++++++++ .../engines/shopee-token.service.ts | 692 ++++++++++++++++++ .../src/connectors/mcp-oauth.service.ts | 5 + .../connectors/shopee-auth.controller.spec.ts | 164 +++++ .../src/connectors/shopee-auth.controller.ts | 240 ++++++ .../src/mcp-server/mcp-server.module.ts | 5 +- .../frontend/src/app/connectors/[id]/page.tsx | 32 + .../app/connectors/oauth/complete/page.tsx | 10 +- .../src/app/connectors/setup/[slug]/page.tsx | 11 + packages/frontend/src/lib/api.ts | 12 + 15 files changed, 1927 insertions(+), 15 deletions(-) create mode 100644 packages/backend/src/connectors/engines/rest.engine.shopee.spec.ts create mode 100644 packages/backend/src/connectors/engines/shopee-token.service.spec.ts create mode 100644 packages/backend/src/connectors/engines/shopee-token.service.ts create mode 100644 packages/backend/src/connectors/shopee-auth.controller.spec.ts create mode 100644 packages/backend/src/connectors/shopee-auth.controller.ts diff --git a/packages/backend/src/adapters/adapters.service.ts b/packages/backend/src/adapters/adapters.service.ts index 601dfd56..79c4e3dd 100644 --- a/packages/backend/src/adapters/adapters.service.ts +++ b/packages/backend/src/adapters/adapters.service.ts @@ -17,6 +17,7 @@ import { withoutOperatorProvided, } from './cloud-managed-env'; import { pickProbe } from './probe.util'; +import { shopeeNeedsAuthorization } from '../connectors/engines/shopee-token.service'; import { odooCheckAdvice } from './odoo-database-hint'; import { outboundRequest } from '../common/outbound-http'; import { interpolateString } from '../common/env-interpolation.util'; @@ -870,6 +871,8 @@ export class AdaptersService { ) { return null; } + // Same for a Shopee shop, authorized with "Authorize shop" after install. + if (shopeeNeedsAuthorization(adapter.connector.authType, auth)) return null; const call = pickProbe(adapter); if (!call) return null; const started = Date.now(); diff --git a/packages/backend/src/connectors/connector-setup-status.util.ts b/packages/backend/src/connectors/connector-setup-status.util.ts index bc402aca..cd15d9b9 100644 --- a/packages/backend/src/connectors/connector-setup-status.util.ts +++ b/packages/backend/src/connectors/connector-setup-status.util.ts @@ -1,5 +1,6 @@ import { getAdapter } from '../adapters/catalog'; import { isMachineGrant } from './oauth-grant.util'; +import { shopeeNeedsAuthorization } from './engines/shopee-token.service'; import { interpolateDeep, interpolateString } from '../common/env-interpolation.util'; import { CALLER_CONTEXT_PREFIX } from '../common/caller-context.util'; import { @@ -135,6 +136,11 @@ export function computeSetupState(input: SetupStatusInput): SetupState { if (isBrowserOAuth && !hasRealValue(authConfig.refreshToken) && !hasRealValue(authConfig.accessToken)) { return { status: 'needs_authorization', missing: [] }; } + // A Shopee shop (HMAC with tokenProvider "shopee") is authorized from the + // connector page too. False for every other connector. + if (shopeeNeedsAuthorization(input.authType, authConfig)) { + return { status: 'needs_authorization', missing: [] }; + } return { status: 'ready', missing: [] }; } diff --git a/packages/backend/src/connectors/connectors.module.ts b/packages/backend/src/connectors/connectors.module.ts index 51c3a4aa..9118dac0 100644 --- a/packages/backend/src/connectors/connectors.module.ts +++ b/packages/backend/src/connectors/connectors.module.ts @@ -18,6 +18,7 @@ import { CurlParser } from './parsers/curl.parser'; import { HarParser } from './parsers/har.parser'; import { McpOAuthService } from './mcp-oauth.service'; import { McpOAuthCallbackController } from './mcp-oauth-callback.controller'; +import { ShopeeAuthController } from './shopee-auth.controller'; import { CatalogResyncService } from './catalog-resync.service'; import { CatalogReconciler } from './catalog-reconciler.service'; import { LicenseModule } from '../license/license.module'; @@ -43,7 +44,7 @@ const PARSERS = [OpenApiParser, WsdlParser, GraphqlParser, PostmanParser, CurlPa // not each log in. @Module({ imports: [McpServerModule, McpServersModule, LicenseModule], - controllers: [ConnectorsController, McpOAuthCallbackController, ToolsController], + controllers: [ConnectorsController, McpOAuthCallbackController, ShopeeAuthController, ToolsController], providers: [ ConnectorsService, McpOAuthService, diff --git a/packages/backend/src/connectors/engines/rest.engine.shopee.spec.ts b/packages/backend/src/connectors/engines/rest.engine.shopee.spec.ts new file mode 100644 index 00000000..91d3b2c5 --- /dev/null +++ b/packages/backend/src/connectors/engines/rest.engine.shopee.spec.ts @@ -0,0 +1,247 @@ +import axios, { AxiosError } from 'axios'; +import { RestEngine, usesExtendedHmac, renderExtendedHmacTemplate } from './rest.engine'; +import { OAuth2TokenService } from './oauth2-token.service'; +import { LoginTokenService } from './login-token.service'; +import { ShopeeTokenService } from './shopee-token.service'; +import { listAdapters, getAdapter } from '../../adapters/catalog'; + +/** + * Query-placed HMAC signing (opt-in, Shopee Open Platform v2) through the + * real engine. Expected signatures are the vendor's documented base string + * (developer guide "API calls": partner_id + api_path + timestamp + + * access_token + shop_id) hashed independently with Python's hmac module, + * not values read back from the engine. + */ +jest.mock('axios', () => { + const actual = jest.requireActual('axios'); + return { __esModule: true, default: jest.fn(), AxiosError: actual.AxiosError }; +}); +const mockedAxios = axios as unknown as jest.Mock; + +const shopee = getAdapter('shopee')!; +const KEY = 'test_partner_key_123'; + +/** The adapter's authConfig with its variables filled in, as the tool path resolves it. */ +function shopeeAuth(): Record { + return JSON.parse( + JSON.stringify(shopee.connector.authConfig) + .split('{{SHOPEE_PARTNER_KEY}}') + .join(KEY) + .split('{{SHOPEE_PARTNER_ID}}') + .join('2001887'), + ); +} + +function fakeTokens(params = { access_token: '59777174636562737266615546704c6d', shop_id: '14701711' }) { + return { + getShopParams: jest.fn().mockResolvedValue(params), + renewAfterRejection: jest.fn(), + } as unknown as jest.Mocked; +} + +describe('RestEngine: query-signed HMAC (Shopee)', () => { + let now: jest.SpyInstance; + beforeEach(() => { + mockedAxios.mockReset(); + mockedAxios.mockResolvedValue({ data: { error: '', message: '', response: {} }, headers: {} }); + now = jest.spyOn(Date, 'now').mockReturnValue(1655714431 * 1000); + }); + afterEach(() => now.mockRestore()); + + const run = async ( + tokens: jest.Mocked, + toolName: string, + params: Record, + ) => { + const tool = shopee.tools.find((t) => t.name === toolName)!; + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService, undefined, tokens); + await engine.execute( + { + baseUrl: 'https://partner.shopeemobile.com', + authType: shopee.connector.authType, + authConfig: shopeeAuth(), + headers: shopee.connector.headers, + connectorId: 'conn-shopee', + }, + tool.endpointMapping as never, + params, + ); + return mockedAxios.mock.calls[mockedAxios.mock.calls.length - 1][0]; + }; + + it("reproduces the signature of Shopee's documented shop call", async () => { + const tokens = fakeTokens(); + const cfg = await run(tokens, 'shopee_get_shop_info', {}); + // Base string from the guide: 2001887/api/v2/shop/get_shop_info1655714431 + // 59777174636562737266615546704c6d14701711; hmac-sha256 with KEY, hex. + expect(cfg.url).toBe('https://partner.shopeemobile.com/api/v2/shop/get_shop_info'); + expect(cfg.params).toEqual({ + partner_id: '2001887', + access_token: '59777174636562737266615546704c6d', + shop_id: '14701711', + timestamp: '1655714431', + sign: '5467e060121e23b60d133a13b2972253695985bf3395b5c088537806fd835e72', + }); + // Nothing in a header: no Signature header, no key anywhere on the wire. + expect(Object.keys(cfg.headers)).toEqual(['Accept', 'Content-Type', 'User-Agent']); + expect(JSON.stringify(cfg)).not.toContain(KEY); + // The query axios sends is the one signed. + const realAxios = (jest.requireActual('axios') as { default: typeof axios }).default; + expect(realAxios.getUri(cfg)).toBe( + 'https://partner.shopeemobile.com/api/v2/shop/get_shop_info?partner_id=2001887' + + '&access_token=59777174636562737266615546704c6d&shop_id=14701711×tamp=1655714431' + + '&sign=5467e060121e23b60d133a13b2972253695985bf3395b5c088537806fd835e72', + ); + expect(tokens.getShopParams).toHaveBeenCalledWith(expect.objectContaining({ connectorId: 'conn-shopee' })); + }); + + it('signs the path only: tool parameters and the body are not part of the string', async () => { + const tokens = fakeTokens(); + const cfg = await run(tokens, 'shopee_list_items', { offset: 0, page_size: 20, item_status: ['NORMAL', 'UNLIST'] }); + // Independent: hmac(KEY, "2001887/api/v2/product/get_item_list1655714431") + const crypto = jest.requireActual('node:crypto') as typeof import('node:crypto'); + const expected = crypto + .createHmac('sha256', KEY) + .update('2001887/api/v2/product/get_item_list165571443159777174636562737266615546704c6d14701711') + .digest('hex'); + expect(cfg.params.sign).toBe(expected); + expect(cfg.paramsSerializer(cfg.params)).toBe( + 'offset=0&page_size=20&item_status=NORMAL&item_status=UNLIST&partner_id=2001887' + + '&access_token=59777174636562737266615546704c6d&shop_id=14701711×tamp=1655714431&sign=' + + expected, + ); + + mockedAxios.mockClear(); + const post = await run(tokens, 'shopee_update_stock', { + item_id: 800000001, + stock_list: [{ model_id: 0, seller_stock: [{ stock: 5 }] }], + }); + expect(post.method).toBe('POST'); + expect(post.data).toEqual({ item_id: 800000001, stock_list: [{ model_id: 0, seller_stock: [{ stock: 5 }] }] }); + expect(post.params.sign).toBe( + crypto + .createHmac('sha256', KEY) + .update('2001887/api/v2/product/update_stock165571443159777174636562737266615546704c6d14701711') + .digest('hex'), + ); + }); + + it("a tool's own parameter cannot replace a credential", async () => { + const tokens = fakeTokens(); + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService, undefined, tokens); + await engine.execute( + { baseUrl: 'https://partner.shopeemobile.com', authType: 'HMAC', authConfig: shopeeAuth(), connectorId: 'c' }, + { + method: 'GET', + path: '/api/v2/shop/get_shop_info', + queryParams: { shop_id: '$shop_id', partner_id: '$partner_id', sign: '$sign' }, + } as never, + { shop_id: '1', partner_id: '2', sign: 'x' }, + ); + const cfg = mockedAxios.mock.calls[0][0]; + expect(cfg.params.shop_id).toBe('14701711'); + expect(cfg.params.partner_id).toBe('2001887'); + expect(cfg.params.sign).toBe('5467e060121e23b60d133a13b2972253695985bf3395b5c088537806fd835e72'); + }); + + it('renews and signs again once when Shopee refuses the access token', async () => { + const tokens = fakeTokens(); + tokens.renewAfterRejection.mockResolvedValue({ access_token: 'fresh-token', shop_id: '14701711' }); + const refused = new AxiosError('Request failed with status code 403', 'ERR_BAD_REQUEST', undefined, undefined, { + status: 403, + data: { error: 'invalid_acceess_token', message: 'Invalid access_token, please have a check.' }, + } as never); + mockedAxios.mockRejectedValueOnce(refused).mockResolvedValueOnce({ data: { error: '', response: {} }, headers: {} }); + + await run(tokens, 'shopee_get_shop_info', {}); + expect(mockedAxios).toHaveBeenCalledTimes(2); + const retry = mockedAxios.mock.calls[1][0]; + expect(retry.params.access_token).toBe('fresh-token'); + const crypto = jest.requireActual('node:crypto') as typeof import('node:crypto'); + expect(retry.params.sign).toBe( + crypto.createHmac('sha256', KEY).update('2001887/api/v2/shop/get_shop_info1655714431fresh-token14701711').digest('hex'), + ); + expect(Object.keys(retry.params).filter((k) => k === 'sign')).toHaveLength(1); + expect(tokens.renewAfterRejection).toHaveBeenCalledTimes(1); + }); + + it('a 200 answer with an error code is an error, and a token error is retried the same way', async () => { + const tokens = fakeTokens(); + tokens.renewAfterRejection.mockResolvedValue(null); + mockedAxios.mockResolvedValue({ + data: { error: 'invalid_acceess_token', message: 'Invalid access_token, please have a check.', request_id: 'r' }, + headers: {}, + }); + const tool = shopee.tools.find((t) => t.name === 'shopee_get_shop_info')!; + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService, undefined, tokens); + await expect( + engine.execute( + { + baseUrl: 'https://partner.shopeemobile.com', + authType: 'HMAC', + authConfig: shopeeAuth(), + connectorId: 'c', + errorWhen: (shopee.connector.config as { errorWhen: unknown }).errorWhen, + }, + tool.endpointMapping as never, + {}, + ), + ).rejects.toMatchObject({ status: 401, message: expect.stringContaining('invalid_acceess_token') }); + expect(tokens.renewAfterRejection).toHaveBeenCalledTimes(1); + }); +}); + +describe('extended HMAC template', () => { + it('substitutes in one pass, so a value is never read as another variable', () => { + expect( + renderExtendedHmacTemplate('${query.a}|${pathname}|${path}|${timestamp}|${query.missing}|${method}\\n', { + method: 'GET', + url: 'https://h/p?a=1', + path: '/p?a=1', + pathname: '/p', + body: '', + timestamp: '9', + query: { a: '${body}${timestamp}' }, + }), + ).toBe('${body}${timestamp}|/p|/p?a=1|9||GET\n'); + }); +}); + +describe('opt-in: adapters signed before query signing keep the original path', () => { + const NEW_FIELDS = ['placement', 'paramName', 'timestampParam', 'queryParams']; + + it('every catalog HMAC adapter except shopee uses none of the new fields', () => { + const hmac = listAdapters() + .map((m) => getAdapter(m.slug)!) + .filter((a) => a.connector.authType === 'HMAC'); + expect(hmac.map((a) => a.slug).sort()).toEqual(['kaufland', 'shopee']); + for (const a of hmac.filter((x) => x.slug !== 'shopee')) { + const sig = (a.connector.authConfig as { signature: Record }).signature; + expect(NEW_FIELDS.filter((f) => f in sig)).toEqual([]); + expect(usesExtendedHmac(sig)).toBe(false); + expect((a.connector.authConfig as Record).tokenProvider).toBeUndefined(); + } + }); + + it('kaufland never asks a token provider and gets no query parameters', async () => { + const tokens = fakeTokens(); + mockedAxios.mockReset(); + mockedAxios.mockResolvedValue({ data: {}, headers: {} }); + const kaufland = getAdapter('kaufland')!; + const engine = new RestEngine({} as OAuth2TokenService, {} as LoginTokenService, undefined, tokens); + const auth = JSON.parse( + JSON.stringify(kaufland.connector.authConfig) + .replace('{{KAUFLAND_SECRET_KEY}}', 's') + .replace('{{KAUFLAND_CLIENT_KEY}}', 'c'), + ); + await engine.execute( + { baseUrl: 'https://sellerapi.kaufland.com/v2', authType: 'HMAC', authConfig: auth, connectorId: 'k' }, + kaufland.tools.find((t) => t.name === 'kaufland_list_warehouses')!.endpointMapping as never, + {}, + ); + expect(tokens.getShopParams).not.toHaveBeenCalled(); + const cfg = mockedAxios.mock.calls[0][0]; + expect(cfg.params).toBeUndefined(); + expect(cfg.headers['Shop-Signature']).toMatch(/^[0-9a-f]{64}$/); + }); +}); diff --git a/packages/backend/src/connectors/engines/rest.engine.ts b/packages/backend/src/connectors/engines/rest.engine.ts index 52710bcb..d1678ab0 100644 --- a/packages/backend/src/connectors/engines/rest.engine.ts +++ b/packages/backend/src/connectors/engines/rest.engine.ts @@ -11,6 +11,7 @@ import { createUnblockerProxyAgent } from './unblocker-proxy-agent'; import { buildOAuth1Header } from './oauth1-signer'; import { OAuth2TokenService } from './oauth2-token.service'; import { BearerTokenRenewalService } from './bearer-token-renewal.service'; +import { ShopeeTokenService, isShopeeAccessTokenRejected, isShopeeAuth } from './shopee-token.service'; import { canRenewToken } from '../oauth-grant.util'; import { LoginTokenService, @@ -90,6 +91,7 @@ export class RestEngine { private readonly oauth2TokenService: OAuth2TokenService, private readonly loginTokenService: LoginTokenService, @Optional() private readonly bearerTokens?: BearerTokenRenewalService, + @Optional() private readonly shopeeTokens?: ShopeeTokenService, ) {} /** @@ -457,8 +459,12 @@ export class RestEngine { // HMAC signing is here for the same reason: the canonical string most // of these APIs sign folds in the request body, which does not exist - // until the block above has run. - this.applyHmacSignature(axiosConfig, config); + // until the block above has run. A Shopee connector also signs the + // shop's access token and id (undefined for every other connector). + const hmacCredentials = isShopeeAuth(config.authType, config.authConfig) + ? await this.shopeeShopParams(config) + : undefined; + this.applyHmacSignature(axiosConfig, config, hmacCredentials); for (const h of headerNames(axiosConfig.headers)) { if (!headersBeforeSigning.includes(h)) credentialHeaders.add(h); } @@ -535,6 +541,16 @@ export class RestEngine { const retryResponse = await outboundRequest(axiosConfig, outbound); return withMeta(retryResponse); } + // Shopee refused the shop's access token (expired early, or replaced + // by another process's renewal): renew once, sign again, resend. + if (hmacCredentials && this.shopeeTokens && isShopeeAccessTokenRejected(error)) { + const renewed = await this.shopeeTokens.renewAfterRejection(config, sentAt); + if (renewed) { + this.applyHmacSignature(axiosConfig, config, renewed); + rebuildBody(axiosConfig, buildBody); + return withMeta(await outboundRequest(axiosConfig, outbound)); + } + } // A pasted token the provider ended for good (Meta 190 logged out, // expired, password changed): mark the connector, stop sending it, // and say what to do next to the vendor's own error. @@ -829,10 +845,26 @@ export class RestEngine { * signature.timestampHeader optional; receives the same timestamp that * went into the string, so the server can recompute it * signature.extraHeaders static headers sent alongside (e.g. a client key) + * + * Query signing, opt-in (Shopee Open Platform). None of these fields is set + * on an adapter written before them, and without them the code below runs + * exactly as it always has (rest.engine.hmac-pin.spec.ts pins it): + * signature.placement 'header' (default) | 'query': the digest goes + * into the query parameter `paramName` instead + * signature.paramName default 'sign' + * signature.timestampParam sends the timestamp as this query parameter + * signature.queryParams static query parameters added to every call + * (a partner id), signed like the others + * template variables `${pathname}` (path without the query) and + * `${query.NAME}` (the value of a query + * parameter as sent, '' when absent) + * `credentialParams` are query parameters a token provider adds (Shopee's + * access_token and shop_id); they win over a tool's own parameters. */ private applyHmacSignature( axiosConfig: AxiosRequestConfig, config: { authType: string; authConfig?: Record }, + credentialParams?: Record, ): void { if (config.authType !== 'HMAC' || !config.authConfig) return; const sig = config.authConfig.signature as Record | undefined; @@ -844,6 +876,26 @@ export class RestEngine { const headerName = String(sig.headerName ?? 'Signature'); const timestamp = String(Math.floor(Date.now() / 1000)); + const extended = usesExtendedHmac(sig, credentialParams); + if (extended) { + // Static and credential parameters first, so the query below (and the + // signature over it) includes them. A tool's own parameter of the same + // name cannot replace a credential. + const added: Record = {}; + const statics = sig.queryParams; + if (statics && typeof statics === 'object' && !Array.isArray(statics)) { + for (const [k, v] of Object.entries(statics as Record)) { + if (v !== undefined && v !== null) added[k] = String(v); + } + } + Object.assign(added, credentialParams ?? {}); + if (sig.timestampParam) added[String(sig.timestampParam)] = timestamp; + // A retry signs again: the previous signature is not part of the query. + const previous = { ...((axiosConfig.params as Record) ?? {}) }; + if (String(sig.placement ?? 'header') === 'query') delete previous[String(sig.paramName ?? 'sign')]; + axiosConfig.params = { ...previous, ...added }; + } + // The body as it will actually go out. An object body is serialized the // way axios will serialize it, so the bytes signed are the bytes sent — // signing a different rendering is the classic way an HMAC integration @@ -876,19 +928,38 @@ export class RestEngine { // Kaufland's documented string: the four parts joined by newlines, with // no newline after the timestamp. - const canonical = String(sig.template ?? '${method}\n${url}\n${body}\n${timestamp}') - .replace(/\\n/g, '\n') - .replace(/\$\{method\}/g, String(axiosConfig.method ?? 'GET').toUpperCase()) - .replace(/\$\{url\}/g, url) - .replace(/\$\{path\}/g, path) - .replace(/\$\{body\}/g, body) - .replace(/\$\{timestamp\}/g, timestamp); + const canonical = extended + ? renderExtendedHmacTemplate(String(sig.template ?? '${method}\n${url}\n${body}\n${timestamp}'), { + method: String(axiosConfig.method ?? 'GET').toUpperCase(), + url, + path, + pathname: pathnameOf(url), + body, + timestamp, + query: params, + }) + : String(sig.template ?? '${method}\n${url}\n${body}\n${timestamp}') + .replace(/\\n/g, '\n') + .replace(/\$\{method\}/g, String(axiosConfig.method ?? 'GET').toUpperCase()) + .replace(/\$\{url\}/g, url) + .replace(/\$\{path\}/g, path) + .replace(/\$\{body\}/g, body) + .replace(/\$\{timestamp\}/g, timestamp); const digest = createHmac(algorithm, secret) .update(canonical, 'utf8') .digest(encoding as 'hex' | 'base64'); const extra = (sig.extraHeaders as Record | undefined) ?? {}; + if (extended && String(sig.placement ?? 'header') === 'query') { + axiosConfig.params = { ...params, [String(sig.paramName ?? 'sign')]: digest }; + axiosConfig.paramsSerializer = serializeRepeatedParams; + axiosConfig.headers = { ...axiosConfig.headers, ...extra }; + if (sig.timestampHeader) { + (axiosConfig.headers as Record)[String(sig.timestampHeader)] = timestamp; + } + return; + } axiosConfig.headers = { ...axiosConfig.headers, ...extra, @@ -901,6 +972,25 @@ export class RestEngine { } } + /** + * The shop parameters a Shopee call signs and sends (`access_token`, + * `shop_id`), renewed first when the token is about to expire. Undefined + * for every connector that is not `authType: HMAC` with + * `authConfig.tokenProvider: "shopee"`. + */ + private async shopeeShopParams(config: { + baseUrl: string; + authType: string; + authConfig?: Record; + connectorId?: string; + }): Promise | undefined> { + if (!isShopeeAuth(config.authType, config.authConfig)) return undefined; + if (!this.shopeeTokens) { + throw new Error('Shopee connectors need the Shopee token service, which is not available here.'); + } + return this.shopeeTokens.getShopParams(config); + } + private applyOAuth1Signature( axiosConfig: AxiosRequestConfig, config: { authType: string; authConfig?: Record }, @@ -1077,6 +1167,63 @@ function encodeQueryComponent(value: string): string { .replace(/%20/g, '+'); } +/** + * Whether an HMAC signature uses the query-signing options (see + * applyHmacSignature). False for every adapter written before them, which + * keeps them on the original code path. + */ +export function usesExtendedHmac( + sig: Record, + credentialParams?: Record, +): boolean { + return ( + sig.placement !== undefined || + sig.paramName !== undefined || + sig.timestampParam !== undefined || + sig.queryParams !== undefined || + credentialParams !== undefined + ); +} + +/** The path of a URL without its query; the input as given when it is relative. */ +function pathnameOf(url: string): string { + try { + return new URL(url).pathname; + } catch { + const q = url.indexOf('?'); + return q === -1 ? url : url.slice(0, q); + } +} + +/** + * The canonical string of an extended HMAC template, in one pass: a value + * substituted for one variable is never read again as another (an access + * token cannot smuggle in `${body}`). + */ +export function renderExtendedHmacTemplate( + template: string, + vars: { + method: string; + url: string; + path: string; + pathname: string; + body: string; + timestamp: string; + query: Record; + }, +): string { + return template + .replace(/\\n/g, '\n') + .replace(/\$\{(method|url|path|pathname|body|timestamp|query\.([^{}]+))\}/g, (_m, name: string, q?: string) => { + if (q !== undefined) { + const v = vars.query[q]; + if (v === undefined || v === null) return ''; + return Array.isArray(v) ? v.map(String).join(',') : String(v); + } + return String(vars[name as Exclude]); + }); +} + /** * Serialize query params with the repeated-key form for arrays * (`columns=a&columns=b`), which is OpenAPI's `explode: true` default, rather diff --git a/packages/backend/src/connectors/engines/shopee-token.service.spec.ts b/packages/backend/src/connectors/engines/shopee-token.service.spec.ts new file mode 100644 index 00000000..e49aa035 --- /dev/null +++ b/packages/backend/src/connectors/engines/shopee-token.service.spec.ts @@ -0,0 +1,347 @@ +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { + ShopeeTokenService, + buildShopeeAuthorizationUrl, + isShopeeAccessTokenRejected, + isShopeeRefreshRefused, + shopeeBaseString, + shopeeNeedsAuthorization, + shopeeSign, +} from './shopee-token.service'; +import { credentialRef } from './connector-auth-status.service'; +import { decrypt, encrypt } from '../../common/crypto/encryption.util'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; +jest.mock('../../common/ssrf.util', () => ({ + ...jest.requireActual('../../common/ssrf.util'), + assertSafeOutboundUrl: jest.fn().mockResolvedValue(undefined), +})); + +const KEY = 'test-encryption-key-32-chars!!!!'; +const PARTNER_KEY = 'test_partner_key_123'; +const HOST = 'https://partner.shopeemobile.com'; +const HOUR = 60 * 60 * 1000; + +/** + * Expected signatures were computed independently (Python hmac/hashlib) from + * the base strings in Shopee's developer guide ("API calls", "Authorization + * and Authentication"), with a test partner key: the guide prints its example + * signature without the key that produced it. + */ +describe('Shopee signing helpers', () => { + it("builds the guide's shop base string and signs it", () => { + const base = shopeeBaseString({ + partnerId: 2001887, + path: '/api/v2/shop/get_shop_info', + timestamp: 1655714431, + accessToken: '59777174636562737266615546704c6d', + shopId: 14701711, + }); + expect(base).toBe('2001887/api/v2/shop/get_shop_info165571443159777174636562737266615546704c6d14701711'); + expect(shopeeSign(PARTNER_KEY, base)).toBe('5467e060121e23b60d133a13b2972253695985bf3395b5c088537806fd835e72'); + }); + + it('signs public calls over partner_id + path + timestamp', () => { + const base = shopeeBaseString({ partnerId: '2001887', path: '/api/v2/auth/token/get', timestamp: 1655714431 }); + expect(base).toBe('2001887/api/v2/auth/token/get1655714431'); + expect(shopeeSign(PARTNER_KEY, base)).toBe('19b2b86451df7a176d9a9fd09771e390ef255a61660572a2f62b35a7f6dde0fe'); + }); + + it('builds the signed shop authorization link', () => { + const url = new URL( + buildShopeeAuthorizationUrl( + { host: HOST, partnerId: '2001887', partnerKey: PARTNER_KEY }, + 'https://cloud.anythingmcp.com/api/shopee/callback/abc', + 1655714431 * 1000, + ), + ); + expect(url.origin + url.pathname).toBe(`${HOST}/api/v2/shop/auth_partner`); + expect(Object.fromEntries(url.searchParams)).toEqual({ + partner_id: '2001887', + timestamp: '1655714431', + sign: '46fae32b9b7e3c436ee4a1a05b5e583665b4de68f3d4749431c016d845e78171', + redirect: 'https://cloud.anythingmcp.com/api/shopee/callback/abc', + }); + expect(url.toString()).not.toContain(PARTNER_KEY); + }); + + it('tells a refused refresh token from other failures', () => { + const err = (status: number, error: string, message = '') => ({ response: { status, data: { error, message } } }); + for (const code of ['error_shop_refresh_token', 'refresh_token_expired', 'shop_access_expired', 'shop_no_linked']) { + expect(isShopeeRefreshRefused(err(403, code))).toBe(true); + expect(isShopeeRefreshRefused(err(200, code))).toBe(true); + } + expect(isShopeeRefreshRefused(err(403, 'error_auth', 'Invalid refresh_token.'))).toBe(true); + expect(isShopeeRefreshRefused(err(403, 'error_sign', 'Wrong sign.'))).toBe(false); + expect(isShopeeRefreshRefused(err(403, 'invalid_partner_id'))).toBe(false); + expect(isShopeeRefreshRefused(err(429, 'error_rate_limit'))).toBe(false); + expect(isShopeeRefreshRefused(err(500, 'refresh_token_expired'))).toBe(false); + expect(isShopeeRefreshRefused(new Error('socket hang up'))).toBe(false); + + expect(isShopeeAccessTokenRejected(err(403, 'invalid_acceess_token'))).toBe(true); + expect(isShopeeAccessTokenRejected(err(403, 'invalid_access_token'))).toBe(true); + expect(isShopeeAccessTokenRejected(err(403, 'error_auth', 'Invalid access_token.'))).toBe(true); + expect(isShopeeAccessTokenRejected(err(403, 'error_param', 'Invalid timestamp.'))).toBe(false); + expect(isShopeeAccessTokenRejected({ status: 401, responseBody: { error: 'invalid_acceess_token' } })).toBe(true); + }); + + it('only a Shopee HMAC connector without tokens needs authorization', () => { + expect(shopeeNeedsAuthorization('HMAC', { tokenProvider: 'shopee' })).toBe(true); + expect(shopeeNeedsAuthorization('HMAC', { tokenProvider: 'shopee', refreshToken: 'rt' })).toBe(false); + expect(shopeeNeedsAuthorization('HMAC', { tokenProvider: 'shopee', refreshToken: '{{X}}' })).toBe(true); + expect(shopeeNeedsAuthorization('HMAC', { signature: { secret: 's' } })).toBe(false); + expect(shopeeNeedsAuthorization('OAUTH2', { tokenProvider: 'shopee' })).toBe(false); + }); +}); + +describe('ShopeeTokenService', () => { + let row: Record | null; + let prisma: any; + let authStatus: { mark: jest.Mock; clear: jest.Mock }; + let service: ShopeeTokenService; + + const baseAuth = { + tokenProvider: 'shopee', + partnerId: '2001887', + signature: { secret: PARTNER_KEY, queryParams: { partner_id: '2001887' } }, + }; + const setRow = (authConfig: Record, extra: Record = {}) => { + row = { + authConfig: encrypt(JSON.stringify(authConfig), KEY), + envVars: {}, + authStatus: null, + authStatusRef: null, + authStatusMessage: null, + ...extra, + }; + }; + const stored = () => JSON.parse(decrypt(row!.authConfig, KEY)); + const config = (authConfig: Record) => ({ baseUrl: HOST, authConfig, connectorId: 'conn-1' }); + const answer = (data: Record) => ({ data, status: 200 }); + + beforeEach(() => { + jest.clearAllMocks(); + row = null; + prisma = { + connector: { + findUnique: jest.fn(async () => row), + update: jest.fn(async ({ data }: any) => { + row = { ...row, ...data }; + return row; + }), + }, + }; + authStatus = { mark: jest.fn(async () => undefined), clear: jest.fn(async () => undefined) }; + const cfg = { get: jest.fn().mockReturnValue(KEY) } as unknown as ConfigService; + service = new ShopeeTokenService(prisma, cfg, authStatus as any); + }); + + it('refuses to call before the shop is authorized, without a request', async () => { + setRow(baseAuth); + await expect(service.getShopParams(config(baseAuth))).rejects.toMatchObject({ + status: 401, + message: expect.stringMatching(/not been authorized yet.*Authorize with Provider/), + }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it('uses a stored token that is still valid, without renewing', async () => { + const auth = { ...baseAuth, accessToken: 'at-1', refreshToken: 'rt-1', shopId: '14701711', expiresAt: Date.now() + 3 * HOUR, expiresIn: 14400 }; + setRow(auth); + expect(await service.getShopParams(config(auth))).toEqual({ access_token: 'at-1', shop_id: '14701711' }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it('renews before expiry, signs the public call, and stores the rotated refresh token encrypted', async () => { + const auth = { ...baseAuth, accessToken: 'at-1', refreshToken: 'rt-1', shopId: '14701711', expiresAt: Date.now() + 60_000, expiresIn: 14400 }; + setRow(auth); + mockedAxios.post.mockResolvedValueOnce( + answer({ error: '', message: '', access_token: 'at-2', refresh_token: 'rt-2', expire_in: 14400, shop_id: 14701711 }), + ); + const t0 = Math.floor(Date.now() / 1000) * 1000; + const now = jest.spyOn(Date, 'now').mockReturnValue(t0); + auth.expiresAt = t0 + 60_000; + setRow(auth); + try { + expect(await service.getShopParams(config(auth))).toEqual({ access_token: 'at-2', shop_id: '14701711' }); + } finally { + now.mockRestore(); + } + + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + const [url, body, opts] = mockedAxios.post.mock.calls[0] as [string, any, any]; + expect(url).toBe(`${HOST}/api/v2/auth/access_token/get`); + expect(body).toEqual({ refresh_token: 'rt-1', shop_id: 14701711, partner_id: 2001887 }); + expect(opts.params).toEqual({ + partner_id: '2001887', + timestamp: t0 / 1000, + sign: shopeeSign(PARTNER_KEY, `2001887/api/v2/auth/access_token/get${t0 / 1000}`), + }); + + // Rotation: the new refresh token replaced the spent one, encrypted at rest. + expect(stored()).toMatchObject({ accessToken: 'at-2', refreshToken: 'rt-2', expiresIn: 14400, shopId: '14701711' }); + expect(row!.authConfig).not.toContain('rt-2'); + expect(authStatus.clear).toHaveBeenCalledWith('conn-1'); + + // Cached: the next call asks nobody. + expect(await service.getShopParams(config(auth))).toEqual({ access_token: 'at-2', shop_id: '14701711' }); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('spends each refresh token once: the next renewal reads the rotated one from the database', async () => { + const auth = { ...baseAuth, accessToken: 'at-1', refreshToken: 'rt-1', shopId: '1', expiresAt: Date.now() - 1000, expiresIn: 14400 }; + setRow(auth); + mockedAxios.post + .mockResolvedValueOnce(answer({ error: '', access_token: 'at-2', refresh_token: 'rt-2', expire_in: 14400 })) + .mockResolvedValueOnce(answer({ error: '', access_token: 'at-3', refresh_token: 'rt-3', expire_in: 14400 })); + await service.getShopParams(config(auth)); + + // Four hours later, with the registry's stale snapshot (still rt-1). + const now = jest.spyOn(Date, 'now').mockReturnValue(Date.now() + 5 * HOUR); + try { + expect(await service.getShopParams(config(auth))).toEqual({ access_token: 'at-3', shop_id: '1' }); + } finally { + now.mockRestore(); + } + expect((mockedAxios.post.mock.calls[1][1] as any).refresh_token).toBe('rt-2'); + expect(stored().refreshToken).toBe('rt-3'); + }); + + it('renews once for a burst of calls (single flight)', async () => { + const auth = { ...baseAuth, accessToken: 'at-1', refreshToken: 'rt-1', shopId: '1', expiresAt: Date.now() - 1000 }; + setRow(auth); + let release!: () => void; + mockedAxios.post.mockImplementationOnce( + () => + new Promise((resolve) => { + release = () => resolve(answer({ error: '', access_token: 'at-2', refresh_token: 'rt-2', expire_in: 14400 })); + }) as any, + ); + const calls = Array.from({ length: 8 }, () => service.getShopParams(config(auth))); + await new Promise((r) => setImmediate(r)); + release(); + const out = await Promise.all(calls); + expect(out.every((o) => o.access_token === 'at-2')).toBe(true); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('takes a token another process renewed meanwhile instead of spending the refresh token', async () => { + const snapshot = { ...baseAuth, accessToken: 'at-1', refreshToken: 'rt-1', shopId: '1', expiresAt: Date.now() - 1000, expiresIn: 14400 }; + setRow({ + ...snapshot, + accessToken: 'at-other', + refreshToken: 'rt-other', + expiresAt: Date.now() + 4 * HOUR, + lastRefreshedAt: new Date().toISOString(), + }); + expect(await service.getShopParams(config(snapshot))).toEqual({ access_token: 'at-other', shop_id: '1' }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it('marks the connector needs_reauth when Shopee refuses the refresh token, and stops asking', async () => { + const auth = { ...baseAuth, accessToken: 'at-1', refreshToken: 'rt-dead', shopId: '1', expiresAt: Date.now() - 1000 }; + setRow(auth); + mockedAxios.post.mockRejectedValue({ + response: { status: 403, data: { error: 'refresh_token_expired', message: 'Refresh token is expired.' } }, + }); + const first = service.getShopParams(config(auth)); + await expect(first).rejects.toMatchObject({ status: 401 }); + await expect(first).rejects.toThrow(/refused the shop's refresh token.*Authorize with Provider again/); + expect(authStatus.mark).toHaveBeenCalledWith('conn-1', { + status: 'needs_reauth', + code: 'refresh_refused', + message: expect.stringContaining('refresh_token_expired'), + ref: credentialRef('rt-dead'), + }); + for (let i = 0; i < 5; i++) { + await expect(service.getShopParams(config(auth))).rejects.toMatchObject({ status: 401 }); + } + expect(await service.renewAfterRejection(config(auth), Date.now())).toBeNull(); + expect(mockedAxios.post).toHaveBeenCalledTimes(1); + }); + + it('a refusal answered with HTTP 200 and an error code counts the same', async () => { + const auth = { ...baseAuth, accessToken: 'at-1', refreshToken: 'rt-x', shopId: '1', expiresAt: Date.now() - 1000 }; + setRow(auth); + mockedAxios.post.mockResolvedValueOnce(answer({ error: 'error_shop_refresh_token', message: 'Your refresh token is error' })); + await expect(service.getShopParams(config(auth))).rejects.toMatchObject({ status: 401 }); + expect(authStatus.mark).toHaveBeenCalledWith('conn-1', expect.objectContaining({ status: 'needs_reauth', ref: credentialRef('rt-x') })); + }); + + it('does not ask again after a refusal recorded by another process, until the shop is authorized again', async () => { + const auth = { ...baseAuth, accessToken: 'at-1', refreshToken: 'rt-dead', shopId: '1', expiresAt: Date.now() - 1000 }; + setRow(auth, { authStatus: 'needs_reauth', authStatusRef: credentialRef('rt-dead'), authStatusMessage: 'Authorize again.' }); + await expect(service.getShopParams(config(auth))).rejects.toMatchObject({ status: 401, hint: 'Authorize again.' }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it('a 5xx or network failure does not mark the connector, and a still valid token is used', async () => { + const auth = { ...baseAuth, accessToken: 'at-1', refreshToken: 'rt-1', shopId: '1', expiresAt: Date.now() + 60_000, expiresIn: 14400 }; + setRow(auth); + mockedAxios.post.mockRejectedValue({ response: { status: 502, data: 'Bad gateway' } }); + expect(await service.getShopParams(config(auth))).toEqual({ access_token: 'at-1', shop_id: '1' }); + expect(authStatus.mark).not.toHaveBeenCalled(); + }); + + it('stores the rotated token again outside the lock when the transaction fails after Shopee answered', async () => { + const auth = { ...baseAuth, accessToken: 'at-1', refreshToken: 'rt-1', shopId: '1', expiresAt: Date.now() - 1000 }; + setRow(auth); + const tx = { + $executeRaw: jest.fn(async () => 1), + connector: { + findUnique: prisma.connector.findUnique, + update: jest.fn(async () => { + throw new Error('could not serialize access'); + }), + }, + }; + prisma.$transaction = jest.fn(async (fn: any) => fn(tx)); + mockedAxios.post.mockResolvedValueOnce(answer({ error: '', access_token: 'at-2', refresh_token: 'rt-2', expire_in: 14400 })); + expect(await service.getShopParams(config(auth))).toEqual({ access_token: 'at-2', shop_id: '1' }); + expect(tx.$executeRaw).toHaveBeenCalled(); + expect(stored().refreshToken).toBe('rt-2'); + }); + + it('exchanges the authorization code and stores the shop tokens', async () => { + setRow(baseAuth); + mockedAxios.post.mockResolvedValueOnce( + answer({ error: '', message: '', access_token: 'at-new', refresh_token: 'rt-new', expire_in: 14400, request_id: 'r' }), + ); + await service.exchangeCode({ + connectorId: 'conn-1', + app: { host: HOST, partnerId: '2001887', partnerKey: PARTNER_KEY }, + code: 'code-1', + shopId: '14701711', + }); + const [url, body, opts] = mockedAxios.post.mock.calls[0] as [string, any, any]; + expect(url).toBe(`${HOST}/api/v2/auth/token/get`); + expect(body).toEqual({ code: 'code-1', shop_id: 14701711, partner_id: 2001887 }); + expect(opts.params.sign).toBe(shopeeSign(PARTNER_KEY, `2001887/api/v2/auth/token/get${opts.params.timestamp}`)); + expect(stored()).toMatchObject({ + tokenProvider: 'shopee', + accessToken: 'at-new', + refreshToken: 'rt-new', + shopId: '14701711', + expiresIn: 14400, + }); + expect(authStatus.clear).toHaveBeenCalledWith('conn-1'); + // Ready at once, from the cache. + expect(await service.getShopParams(config(baseAuth))).toEqual({ access_token: 'at-new', shop_id: '14701711' }); + }); + + it('a refused code exchange stores nothing', async () => { + setRow(baseAuth); + mockedAxios.post.mockResolvedValueOnce(answer({ error: 'invalid_code', message: 'Invalid code.' })); + await expect( + service.exchangeCode({ + connectorId: 'conn-1', + app: { host: HOST, partnerId: '2001887', partnerKey: PARTNER_KEY }, + code: 'used', + shopId: '1', + }), + ).rejects.toThrow(/invalid_code/); + expect(prisma.connector.update).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/connectors/engines/shopee-token.service.ts b/packages/backend/src/connectors/engines/shopee-token.service.ts new file mode 100644 index 00000000..11491844 --- /dev/null +++ b/packages/backend/src/connectors/engines/shopee-token.service.ts @@ -0,0 +1,692 @@ +import { Injectable, Logger, Optional } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { createHmac } from 'node:crypto'; +import { PrismaService } from '../../common/prisma.service'; +import { decrypt, encrypt } from '../../common/crypto/encryption.util'; +import { getRequiredSecret } from '../../common/secrets.util'; +import { assertSafeOutboundUrl } from '../../common/ssrf.util'; +import { interpolateDeep } from '../../common/env-interpolation.util'; +import { outboundAxiosOptions } from '../../common/outbound-http'; +import { connectorPageUrl } from '../../common/url.util'; +import { ConnectorAuthStatusService, credentialRef } from './connector-auth-status.service'; + +/** + * Shopee Open Platform (API v2) shop tokens. + * + * WHY. Shopee signs every call with the partner key (HMAC-SHA256 over the + * partner id, the API path, a timestamp and, for shop calls, the access token + * and shop id) and authorizes a shop with its own flow rather than OAuth 2: + * the seller opens a signed `auth_partner` link, Shopee sends the browser back + * with `code` and `shop_id`, and the code is exchanged at + * `/api/v2/auth/token/get` for an access token (about 4 hours) and a refresh + * token (about 30 days). `/api/v2/auth/access_token/get` renews both, and the + * refresh token rotates: the one just spent is no good again, so the new one + * has to be stored before anything else happens. + * + * A connector opts in with `authType: "HMAC"` and `authConfig.tokenProvider: + * "shopee"`. Nothing else uses this service. + * + * What it keeps, in the connector's encrypted authConfig (the same place and + * field names as OAuth2 tokens, so the credential status and the setup check + * read it the same way): accessToken, refreshToken, expiresAt (ms epoch), + * expiresIn, shopId, authorizedAt, lastRefreshedAt. + * + * Like OAuth2TokenService: renewed before it expires, one renewal at a time + * per connector (in this process, and across processes under a Postgres + * advisory lock, re-reading the stored refresh token inside it), and a refresh + * token Shopee refuses marks the connector needs_reauth, with one email to + * the owner, until the shop is authorized again. + * + * Never logs a token, the partner key or a signed URL. + */ + +export const SHOPEE_TOKEN_PROVIDER = 'shopee'; + +export const SHOPEE_AUTH_PARTNER_PATH = '/api/v2/shop/auth_partner'; +export const SHOPEE_TOKEN_GET_PATH = '/api/v2/auth/token/get'; +export const SHOPEE_REFRESH_PATH = '/api/v2/auth/access_token/get'; + +/** Renew when fewer than this remain (or half the lifetime, if shorter). */ +const REFRESH_WINDOW_MS = 5 * 60 * 1000; +const DEFAULT_ACCESS_LIFETIME_S = 4 * 60 * 60; +const HTTP_TIMEOUT_MS = 10_000; +const LOCK_NAMESPACE = 'amcp:shopee-refresh:'; +const LOCK_MAX_WAIT_MS = 3_000; +const LOCK_TIMEOUT_MS = 30_000; +/** After a refusal, calls fail at once for this long before the database is read again. */ +const REFUSED_RECHECK_MS = 10 * 60 * 1000; + +/** Whether a connector's auth is a Shopee shop authorization. */ +export function isShopeeAuth(authType: string | undefined, authConfig: unknown): boolean { + return ( + authType === 'HMAC' && + !!authConfig && + typeof authConfig === 'object' && + (authConfig as { tokenProvider?: unknown }).tokenProvider === SHOPEE_TOKEN_PROVIDER + ); +} + +/** Whether a Shopee connector still needs its shop authorized (no usable token). */ +export function shopeeNeedsAuthorization(authType: string | undefined, authConfig: unknown): boolean { + if (!isShopeeAuth(authType, authConfig)) return false; + const ac = authConfig as Record; + const usable = (v: unknown) => typeof v === 'string' && v.trim() !== '' && !/\{\{/.test(v); + return !usable(ac.refreshToken) && !usable(ac.accessToken); +} + +/** hex(HMAC-SHA256(partner_key, base)), as Shopee signs everything. */ +export function shopeeSign(partnerKey: string, base: string): string { + return createHmac('sha256', partnerKey).update(base, 'utf8').digest('hex'); +} + +/** + * The base string Shopee signs: partner_id + api_path + timestamp, then + * access_token + shop_id for shop calls. `path` is the path only + * (`/api/v2/shop/get_shop_info`), never the host or the query. + */ +export function shopeeBaseString(input: { + partnerId: string | number; + path: string; + timestamp: number | string; + accessToken?: string; + shopId?: string | number; +}): string { + return ( + `${input.partnerId}${input.path}${input.timestamp}` + + (input.accessToken !== undefined ? `${input.accessToken}${input.shopId ?? ''}` : '') + ); +} + +/** What the service needs to talk to Shopee for one connector. */ +export interface ShopeeApp { + /** e.g. https://partner.shopeemobile.com (no path). */ + host: string; + partnerId: string; + partnerKey: string; +} + +/** + * The app settings of a Shopee connector from its resolved authConfig and + * base URL: the partner id (authConfig.partnerId, else the signed + * partner_id query parameter), the partner key (the signature secret) and the + * host (the base URL's origin). Undefined when one is missing. + */ +export function shopeeAppFrom( + authConfig: Record | undefined, + baseUrl: string | undefined, +): ShopeeApp | undefined { + if (!authConfig) return undefined; + const sig = (authConfig.signature ?? {}) as Record; + const query = (sig.queryParams ?? {}) as Record; + const partnerId = String(authConfig.partnerId ?? query.partner_id ?? '').trim(); + const partnerKey = String(sig.secret ?? '').trim(); + let host = ''; + try { + host = new URL(String(baseUrl ?? '')).origin; + } catch { + host = ''; + } + const unresolved = (v: string) => !v || /\{\{/.test(v); + if (unresolved(partnerId) || unresolved(partnerKey) || !host || host === 'null') return undefined; + return { host, partnerId, partnerKey }; +} + +/** + * The link a seller opens to authorize the shop. Signed as a public call over + * `/api/v2/shop/auth_partner`; Shopee sends the browser to `redirect` with + * `code` and `shop_id`. + */ +export function buildShopeeAuthorizationUrl(app: ShopeeApp, redirect: string, now = Date.now()): string { + const timestamp = Math.floor(now / 1000); + const sign = shopeeSign( + app.partnerKey, + shopeeBaseString({ partnerId: app.partnerId, path: SHOPEE_AUTH_PARTNER_PATH, timestamp }), + ); + const q = new URLSearchParams({ + partner_id: app.partnerId, + timestamp: String(timestamp), + sign, + redirect, + }); + return `${app.host}${SHOPEE_AUTH_PARTNER_PATH}?${q.toString()}`; +} + +/** A Shopee API answer that is an error: HTTP 4xx/5xx, or 2xx with a non-empty `error`. */ +export function shopeeError(err: unknown): { status?: number; error?: string; message?: string } | undefined { + const res = (err as { response?: { status?: unknown; data?: unknown } })?.response; + const data = (res?.data ?? (err as { responseBody?: unknown })?.responseBody) as + | { error?: unknown; message?: unknown; msg?: unknown } + | undefined; + const status = typeof res?.status === 'number' ? res.status : (err as { status?: unknown })?.status; + if (!data || typeof data !== 'object') { + return typeof status === 'number' ? { status } : undefined; + } + return { + status: typeof status === 'number' ? status : undefined, + error: typeof data.error === 'string' && data.error ? data.error.slice(0, 80) : undefined, + message: + typeof data.message === 'string' && data.message + ? data.message.slice(0, 200) + : typeof data.msg === 'string' + ? data.msg.slice(0, 200) + : undefined, + }; +} + +/** + * Shopee refused the access token of a shop call (expired, or replaced by a + * renewal elsewhere), so a fresh one may fix it: `invalid_acceess_token` + * (Shopee's spelling; `invalid_access_token` accepted too), the older + * `error_auth` "Invalid access_token", and an expired shop authorization, + * which the renewal then reports as refused. + */ +export function isShopeeAccessTokenRejected(err: unknown): boolean { + const e = shopeeError(err); + if (!e?.error) return false; + if (/^invalid_ac+e+s+_token$/i.test(e.error)) return true; + if (e.error === 'shop_access_expired') return true; + return e.error === 'error_auth' && /access[_ ]?token/i.test(e.message ?? ''); +} + +/** + * Error codes with which Shopee refuses a refresh token for good: wrong or + * already rotated (`error_shop_refresh_token`), expired after 30 days + * (`refresh_token_expired`), the shop's authorization expired or was + * cancelled (`shop_access_expired`, `shop_no_linked`), or the shop is banned. + * Shopee is replacing the generic `error_auth` with these; the old form is + * read from its message. + */ +const SHOPEE_REFRESH_REFUSED = new Set([ + 'error_shop_refresh_token', + 'error_merchant_refresh_token', + 'refresh_token_expired', + 'shop_access_expired', + 'merchant_access_expired', + 'shop_no_linked', + 'merchant_no_linked', + 'shop_banned', +]); + +/** + * Shopee refused the refresh token itself: no retry can work until the shop + * is authorized again. A 5xx, a network failure, a rate limit or a wrong + * partner key (`error_sign`, `invalid_partner_id`) is not this. + */ +export function isShopeeRefreshRefused(err: unknown): boolean { + const e = shopeeError(err); + if (!e?.error) return false; + if (typeof e.status === 'number' && e.status >= 500) return false; + if (SHOPEE_REFRESH_REFUSED.has(e.error)) return true; + return e.error === 'error_auth' && /refresh[_ ]?token/i.test(e.message ?? ''); +} + +/** A Shopee failure in words safe to log and to show: status, Shopee's code and message. */ +export function describeShopeeError(err: unknown): string { + const e = shopeeError(err); + if (e && (e.status !== undefined || e.error)) { + return [e.status !== undefined ? `HTTP ${e.status}` : undefined, e.error, e.message].filter(Boolean).join(': '); + } + const code = (err as { code?: unknown })?.code; + if (typeof code === 'string') return code; + const msg = (err as { message?: unknown })?.message; + return typeof msg === 'string' && !/token|sign=|partner_key/i.test(msg) ? msg.slice(0, 200) : 'network error'; +} + +interface ShopToken { + accessToken: string; + shopId: string; + expiresAt: number; + lifetimeMs: number; + obtainedAt: number; +} + +/** The stored authorization of a connector, read from its authConfig. */ +interface StoredAuth { + accessToken?: string; + refreshToken?: string; + shopId?: string; + expiresAt?: number; + expiresIn?: number; + obtainedAt?: number; +} + +type Db = Pick; + +function unauthorized(message: string, hint?: string): Error & { status: number; hint?: string } { + const err = new Error(message) as Error & { status: number; hint?: string }; + err.status = 401; + if (hint) err.hint = hint; + return err; +} + +function windowFor(lifetimeMs: number): number { + return lifetimeMs > 0 ? Math.min(REFRESH_WINDOW_MS, lifetimeMs / 2) : REFRESH_WINDOW_MS; +} + +function readStored(authConfig: Record | undefined): StoredAuth { + const ac = authConfig ?? {}; + const str = (v: unknown) => (typeof v === 'string' && v.trim() && !/\{\{/.test(v) ? v.trim() : undefined); + const num = (v: unknown) => (Number.isFinite(Number(v)) && Number(v) > 0 ? Number(v) : undefined); + const shop = ac.shopId ?? ac.shop_id; + return { + accessToken: str(ac.accessToken), + refreshToken: str(ac.refreshToken), + shopId: shop !== undefined && shop !== null && String(shop).trim() ? String(shop).trim() : undefined, + expiresAt: num(ac.expiresAt), + expiresIn: num(ac.expiresIn), + obtainedAt: Date.parse(String(ac.lastRefreshedAt ?? ac.authorizedAt ?? '')) || undefined, + }; +} + +/** What Shopee's token endpoints answer, checked. Throws on an error answer. */ +function tokensFrom(data: unknown): { accessToken: string; refreshToken: string; expiresIn: number } { + const d = (data ?? {}) as Record; + if (typeof d.error === 'string' && d.error) { + // Shaped like an axios error so the classifiers above read it the same way. + const err = new Error(`Shopee refused the token request (${d.error})`) as Error & { + response: { status: number; data: unknown }; + }; + err.response = { status: 200, data: d }; + throw err; + } + const accessToken = typeof d.access_token === 'string' ? d.access_token : ''; + const refreshToken = typeof d.refresh_token === 'string' ? d.refresh_token : ''; + if (!accessToken || !refreshToken) throw new Error('the answer carried no access_token or refresh_token'); + const expiresIn = Number(d.expire_in ?? d.expires_in); + return { accessToken, refreshToken, expiresIn: expiresIn > 0 ? expiresIn : DEFAULT_ACCESS_LIFETIME_S }; +} + +function authorizeAgainHint(connectorId: string | undefined, reason?: string): string { + const page = connectorPageUrl(connectorId); + return ( + `Shopee refused the shop's refresh token${reason ? ` (${reason})` : ''}: it expired (it lasts about 30 days ` + + 'without a call), was already used, or the shop cancelled or let its authorization expire. Retrying will not ' + + `help. Open the connector in AnythingMCP${page ? ` (${page})` : ''} and click Authorize with Provider again.` + ); +} + +@Injectable() +export class ShopeeTokenService { + private readonly logger = new Logger(ShopeeTokenService.name); + private readonly encryptionKey: string; + + private cache = new Map(); + private inFlight = new Map>(); + private refused = new Map(); + + constructor( + private readonly prisma: PrismaService, + private readonly configService: ConfigService, + @Optional() private readonly authStatus?: ConnectorAuthStatusService, + ) { + this.encryptionKey = getRequiredSecret('ENCRYPTION_KEY', this.configService.get('ENCRYPTION_KEY')); + } + + /** + * The query parameters a shop call carries besides the partner id, + * timestamp and sign: `access_token` and `shop_id`. Renews the token first + * when it is about to expire. Throws (status 401) when the shop has not + * been authorized, or its refresh token was refused. + */ + async getShopParams( + config: { baseUrl: string; authConfig?: Record; connectorId?: string }, + ): Promise<{ access_token: string; shop_id: string }> { + const token = await this.getToken(config); + return { access_token: token.accessToken, shop_id: token.shopId }; + } + + private async getToken(config: { + baseUrl: string; + authConfig?: Record; + connectorId?: string; + }): Promise { + const { connectorId } = config; + const now = Date.now(); + if (connectorId) { + const cached = this.cache.get(connectorId); + if (cached && cached.expiresAt > now + windowFor(cached.lifetimeMs)) return cached; + const refused = this.refusedError(connectorId); + if (refused) throw refused; + } + + const stored = readStored(config.authConfig); + if (!stored.shopId || (!stored.accessToken && !stored.refreshToken)) { + const page = connectorPageUrl(connectorId); + throw unauthorized( + 'Shopee: this shop has not been authorized yet. No request was sent to Shopee. ' + + `Open the connector in AnythingMCP${page ? ` (${page})` : ''} and click Authorize with Provider.`, + ); + } + const lifetimeMs = (stored.expiresIn ?? DEFAULT_ACCESS_LIFETIME_S) * 1000; + const snapshot: ShopToken | undefined = + stored.accessToken && stored.expiresAt + ? { + accessToken: stored.accessToken, + shopId: stored.shopId, + expiresAt: stored.expiresAt, + lifetimeMs, + obtainedAt: stored.obtainedAt ?? stored.expiresAt - lifetimeMs, + } + : undefined; + if (snapshot && snapshot.expiresAt > now + windowFor(lifetimeMs)) { + if (connectorId) this.cache.set(connectorId, snapshot); + return snapshot; + } + // Without a saved connector (a call whose credentials depend on the + // caller), nothing can be stored, so nothing is renewed either. + if (!connectorId || !stored.refreshToken) { + if (snapshot && snapshot.expiresAt > now) return snapshot; + throw unauthorized( + 'Shopee: the access token has expired and this connector cannot renew it. Authorize the shop again.', + ); + } + return this.singleFlight(connectorId, () => this.refresh(config, connectorId)).catch((err) => { + // Shopee was unreachable or answered 5xx: an access token that is + // still valid for a few minutes is better than none. + if (snapshot && snapshot.expiresAt > Date.now() && !this.refused.has(connectorId)) return snapshot; + throw err; + }); + } + + /** + * A new token after Shopee refused the one sent at `sentAt`. Takes a token + * another call obtained since, else renews (one renewal for a burst). + */ + async renewAfterRejection( + config: { baseUrl: string; authConfig?: Record; connectorId?: string }, + sentAt: number, + ): Promise<{ access_token: string; shop_id: string } | null> { + const { connectorId } = config; + if (!connectorId) return null; + // Refused a moment ago: not again. + if (this.refusedError(connectorId)) return null; + const cached = this.cache.get(connectorId); + if (cached && cached.obtainedAt >= sentAt && cached.expiresAt > Date.now()) { + return { access_token: cached.accessToken, shop_id: cached.shopId }; + } + this.cache.delete(connectorId); + try { + const token = await this.singleFlight(connectorId, () => this.refresh(config, connectorId, sentAt)); + return { access_token: token.accessToken, shop_id: token.shopId }; + } catch { + return null; + } + } + + /** + * Exchange the code Shopee sent back after the seller authorized the shop, + * and store the tokens on the connector. Throws with Shopee's answer when + * it refuses. + */ + async exchangeCode(input: { connectorId: string; app: ShopeeApp; code: string; shopId: string }): Promise { + const shopId = Number(input.shopId); + if (!Number.isSafeInteger(shopId) || shopId <= 0) throw new Error('Shopee sent back no valid shop_id.'); + const data = await this.post(input.app, SHOPEE_TOKEN_GET_PATH, { + code: input.code, + shop_id: shopId, + partner_id: Number(input.app.partnerId), + }); + const tokens = tokensFrom(data); + const now = Date.now(); + await this.withLock(input.connectorId, async (db) => { + const saved = await this.persist(db, input.connectorId, { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn: tokens.expiresIn, + expiresAt: now + tokens.expiresIn * 1000, + shopId: String(shopId), + authorizedAt: new Date(now).toISOString(), + }); + if (!saved) throw new Error('the shop tokens could not be stored'); + }); + this.forget(input.connectorId); + this.cache.set(input.connectorId, { + accessToken: tokens.accessToken, + shopId: String(shopId), + expiresAt: now + tokens.expiresIn * 1000, + lifetimeMs: tokens.expiresIn * 1000, + obtainedAt: now, + }); + await this.authStatus?.clear(input.connectorId); + this.logger.log(`Connector ${input.connectorId}: Shopee shop authorized`); + } + + /** The credentials changed (a new authorization, new app keys): start over. */ + forget(connectorId: string): void { + this.cache.delete(connectorId); + this.refused.delete(connectorId); + } + + // ── internals ────────────────────────────────────────────────────────── + + private singleFlight(connectorId: string, fn: () => Promise): Promise { + const running = this.inFlight.get(connectorId); + if (running) return running; + const p = fn().finally(() => this.inFlight.delete(connectorId)); + this.inFlight.set(connectorId, p); + return p; + } + + private refusedError(connectorId: string): (Error & { status: number }) | undefined { + const entry = this.refused.get(connectorId); + if (!entry) return undefined; + if (Date.now() >= entry.recheckAt) { + this.refused.delete(connectorId); + return undefined; + } + return unauthorized( + `Shopee: the shop's refresh token was refused, so no new access token can be obtained. No request was sent to Shopee. ${entry.message}`, + entry.message, + ); + } + + /** + * Renew under the connector's lock. The refresh token is read from the + * database inside the lock: another process may have rotated it a moment + * ago, and then its fresh access token is taken instead of spending one. + */ + private async refresh( + config: { baseUrl: string; authConfig?: Record }, + connectorId: string, + rejectedAt?: number, + ): Promise { + // A rotation Shopee already made, and the write that keeps it: if the + // transaction fails after Shopee answered, it is stored again outside, + // or the connector is left holding a refresh token Shopee revoked. + let pending: { token: ShopToken; update: Record } | undefined; + try { + return await this.refreshLocked(config, connectorId, rejectedAt, (p) => (pending = p)); + } catch (err) { + if (!pending) throw err; + this.logger.warn(`Connector ${connectorId}: the renewed Shopee token was not stored under the lock; storing it again`); + await this.persist(this.prisma, connectorId, pending.update); + this.cache.set(connectorId, pending.token); + return pending.token; + } + } + + private async refreshLocked( + config: { baseUrl: string; authConfig?: Record }, + connectorId: string, + rejectedAt: number | undefined, + onRotated: (p: { token: ShopToken; update: Record }) => void, + ): Promise { + return this.withLock(connectorId, async (db) => { + const fresh = await this.loadFromDb(db, connectorId); + const authConfig = fresh?.authConfig ?? config.authConfig; + const stored = readStored(authConfig); + const now = Date.now(); + const lifetimeMs = (stored.expiresIn ?? DEFAULT_ACCESS_LIFETIME_S) * 1000; + + // Renewed by another process (or authorized again) meanwhile. + if (stored.accessToken && stored.expiresAt && stored.shopId) { + const obtainedAt = stored.obtainedAt ?? 0; + const usable = + rejectedAt !== undefined + ? obtainedAt > rejectedAt && stored.expiresAt > now + : stored.expiresAt > now + windowFor(lifetimeMs); + if (usable) { + const token = { accessToken: stored.accessToken, shopId: stored.shopId, expiresAt: stored.expiresAt, lifetimeMs, obtainedAt }; + this.cache.set(connectorId, token); + return token; + } + } + + // Refused before (by this process or another), and not authorized since. + const status = fresh?.status; + if ( + status?.authStatus === 'needs_reauth' && + status.authStatusRef && + status.authStatusRef === credentialRef(stored.refreshToken ?? '') + ) { + const message = status.authStatusMessage || authorizeAgainHint(connectorId); + this.refused.set(connectorId, { message, recheckAt: now + REFUSED_RECHECK_MS }); + throw unauthorized(message, message); + } + + const app = shopeeAppFrom(authConfig, config.baseUrl); + if (!app || !stored.refreshToken || !stored.shopId) { + throw unauthorized('Shopee: the partner id, partner key or the shop authorization is missing on this connector.'); + } + + let tokens: ReturnType; + try { + tokens = tokensFrom( + await this.post(app, SHOPEE_REFRESH_PATH, { + refresh_token: stored.refreshToken, + shop_id: Number(stored.shopId), + partner_id: Number(app.partnerId), + }), + ); + } catch (err) { + const reason = describeShopeeError(err); + this.logger.warn(`Connector ${connectorId}: Shopee token refresh failed (${reason})`); + if (isShopeeRefreshRefused(err)) { + const message = authorizeAgainHint(connectorId, reason); + this.refused.set(connectorId, { message, recheckAt: Date.now() + REFUSED_RECHECK_MS }); + this.cache.delete(connectorId); + await this.authStatus?.mark(connectorId, { + status: 'needs_reauth', + code: 'refresh_refused', + message, + ref: credentialRef(stored.refreshToken), + }); + throw unauthorized(message, message); + } + throw unauthorized(`Shopee: could not renew the shop's access token (${reason}). Try again in a few minutes.`); + } + + const obtainedAt = Date.now(); + const token: ShopToken = { + accessToken: tokens.accessToken, + shopId: stored.shopId, + expiresAt: obtainedAt + tokens.expiresIn * 1000, + lifetimeMs: tokens.expiresIn * 1000, + obtainedAt, + }; + // The refresh token just spent is dead: store the new one before the + // lock is released, and again outside it if that write failed. + const update = { + accessToken: tokens.accessToken, + refreshToken: tokens.refreshToken, + expiresIn: tokens.expiresIn, + expiresAt: token.expiresAt, + lastRefreshedAt: new Date(obtainedAt).toISOString(), + }; + onRotated({ token, update }); + const saved = await this.persist(db, connectorId, update); + if (!saved) throw new Error('the renewed Shopee token could not be stored'); + this.cache.set(connectorId, token); + this.refused.delete(connectorId); + await this.authStatus?.clear(connectorId); + this.logger.debug(`Connector ${connectorId}: Shopee access token renewed`); + return token; + }); + } + + /** A signed public call (token endpoints): partner_id, timestamp and sign in the query, JSON body. */ + private async post(app: ShopeeApp, path: string, body: Record): Promise { + const timestamp = Math.floor(Date.now() / 1000); + const sign = shopeeSign(app.partnerKey, shopeeBaseString({ partnerId: app.partnerId, path, timestamp })); + const url = `${app.host}${path}`; + await assertSafeOutboundUrl(url); + const res = await axios.post(url, body, { + params: { partner_id: app.partnerId, timestamp, sign }, + headers: { 'Content-Type': 'application/json' }, + timeout: HTTP_TIMEOUT_MS, + ...outboundAxiosOptions({ credentialsInBody: true }), + }); + return res.data; + } + + private async loadFromDb( + db: Db, + connectorId: string, + ): Promise< + | { + authConfig: Record; + status: { authStatus: string | null; authStatusRef: string | null; authStatusMessage: string | null }; + } + | undefined + > { + try { + const row = await db.connector.findUnique({ + where: { id: connectorId }, + select: { authConfig: true, envVars: true, authStatus: true, authStatusRef: true, authStatusMessage: true }, + }); + if (!row?.authConfig) return undefined; + const envVars = (row.envVars as Record | null) ?? {}; + return { + authConfig: interpolateDeep(JSON.parse(decrypt(row.authConfig, this.encryptionKey)), envVars), + status: { + authStatus: row.authStatus ?? null, + authStatusRef: row.authStatusRef ?? null, + authStatusMessage: row.authStatusMessage ?? null, + }, + }; + } catch (err: any) { + this.logger.warn(`Connector ${connectorId}: could not read the stored Shopee authorization (${err?.message ?? err})`); + return undefined; + } + } + + /** Merge token fields into the stored (encrypted) authConfig. False when the write failed. */ + private async persist(db: Db, connectorId: string, patch: Record): Promise { + try { + const row = await db.connector.findUnique({ where: { id: connectorId }, select: { authConfig: true } }); + if (!row) return true; // deleted meanwhile + const current = row.authConfig ? JSON.parse(decrypt(row.authConfig, this.encryptionKey)) : {}; + await db.connector.update({ + where: { id: connectorId }, + data: { authConfig: encrypt(JSON.stringify({ ...current, ...patch }), this.encryptionKey) }, + }); + return true; + } catch (err: any) { + this.logger.warn(`Connector ${connectorId}: could not store the Shopee tokens (${err?.message ?? err})`); + return false; + } + } + + /** `fn` under a Postgres advisory lock on the connector; unlocked when no transaction can be had. */ + private async withLock(connectorId: string, fn: (db: Db) => Promise): Promise { + const prisma = this.prisma as unknown as { + $transaction?: (run: (tx: any) => Promise, opts: { maxWait: number; timeout: number }) => Promise; + }; + if (typeof prisma.$transaction !== 'function') return fn(this.prisma); + let started = false; + try { + return await prisma.$transaction( + async (tx) => { + started = true; + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${LOCK_NAMESPACE + connectorId}))`; + return fn(tx); + }, + { maxWait: LOCK_MAX_WAIT_MS, timeout: LOCK_TIMEOUT_MS }, + ); + } catch (err) { + if (started) throw err; + return fn(this.prisma); + } + } +} diff --git a/packages/backend/src/connectors/mcp-oauth.service.ts b/packages/backend/src/connectors/mcp-oauth.service.ts index 0d3b2550..19011dde 100644 --- a/packages/backend/src/connectors/mcp-oauth.service.ts +++ b/packages/backend/src/connectors/mcp-oauth.service.ts @@ -80,6 +80,11 @@ export interface PendingOAuthFlow { * next authorization registers a fresh one. */ dynamicClient?: boolean; + /** + * Set on a Shopee shop authorization (shopee-auth.controller.ts), which is + * completed by its own endpoint; unset on every OAuth flow. + */ + provider?: 'shopee'; createdAt: number; } diff --git a/packages/backend/src/connectors/shopee-auth.controller.spec.ts b/packages/backend/src/connectors/shopee-auth.controller.spec.ts new file mode 100644 index 00000000..6f92d15e --- /dev/null +++ b/packages/backend/src/connectors/shopee-auth.controller.spec.ts @@ -0,0 +1,164 @@ +import { BadRequestException, ForbiddenException, GoneException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { ShopeeAuthController } from './shopee-auth.controller'; +import { McpOAuthService } from './mcp-oauth.service'; +import { encrypt } from '../common/crypto/encryption.util'; +import { shopeeBaseString, shopeeSign } from './engines/shopee-token.service'; + +const KEY = 'test-encryption-key-32-chars!!!!'; + +describe('ShopeeAuthController', () => { + const prevKey = process.env.ENCRYPTION_KEY; + beforeAll(() => { + process.env.ENCRYPTION_KEY = KEY; + }); + afterAll(() => { + process.env.ENCRYPTION_KEY = prevKey; + }); + + const authConfig = { + tokenProvider: 'shopee', + partnerId: '{{SHOPEE_PARTNER_ID}}', + signature: { secret: '{{SHOPEE_PARTNER_KEY}}', queryParams: { partner_id: '{{SHOPEE_PARTNER_ID}}' } }, + }; + let connector: Record; + let connectorsService: { findById: jest.Mock; findByIdInternal: jest.Mock }; + let tokens: { exchangeCode: jest.Mock }; + let mcpServer: { reloadConnectorTools: jest.Mock }; + let flows: McpOAuthService; + let controller: ShopeeAuthController; + const admin = { user: { sub: 'user-1', organizationId: 'org-1', role: 'ADMIN' } }; + + beforeEach(() => { + connector = { + id: 'conn-1', + organizationId: 'org-1', + userId: 'user-1', + authType: 'HMAC', + baseUrl: '{{SHOPEE_HOST}}', + authConfig: encrypt(JSON.stringify(authConfig), KEY), + envVars: { SHOPEE_HOST: 'https://partner.shopeemobile.com', SHOPEE_PARTNER_ID: '2001887', SHOPEE_PARTNER_KEY: 'pk-test-0001' }, + }; + connectorsService = { + findById: jest.fn(async () => connector), + findByIdInternal: jest.fn(async () => connector), + }; + tokens = { exchangeCode: jest.fn(async () => undefined) }; + mcpServer = { reloadConnectorTools: jest.fn(async () => undefined) }; + flows = new McpOAuthService(); + const config = { + get: jest.fn((k: string) => + ({ ENCRYPTION_KEY: KEY, SERVER_URL: 'https://cloud.anythingmcp.com', FRONTEND_URL: 'https://app.test' })[k], + ), + } as unknown as ConfigService; + controller = new ShopeeAuthController(connectorsService as any, flows, tokens as any, mcpServer as any, config); + }); + + const redirectOf = async (state: string, query: Record) => { + const res = { redirect: jest.fn() }; + await controller.callback(state, query.code, query.shop_id, query.main_account_id, res as any); + return new URL(res.redirect.mock.calls[0][0]); + }; + + it('returns the signed auth_partner link, with the attempt state in the redirect path', async () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(1655714431000); + let out: { authorizationUrl?: string }; + let stored: unknown; + try { + out = await controller.authorize(admin, 'conn-1'); + const state = new URL(new URL(out.authorizationUrl!).searchParams.get('redirect')!).pathname.split('/').pop()!; + stored = (await flows.getPendingFlow(state))?.flow; + } finally { + now.mockRestore(); + } + expect(stored).toMatchObject({ provider: 'shopee', connectorId: 'conn-1', userId: 'user-1' }); + const url = new URL(out!.authorizationUrl!); + expect(url.origin + url.pathname).toBe('https://partner.shopeemobile.com/api/v2/shop/auth_partner'); + expect(url.searchParams.get('partner_id')).toBe('2001887'); + expect(url.searchParams.get('timestamp')).toBe('1655714431'); + expect(url.searchParams.get('sign')).toBe( + shopeeSign('pk-test-0001', shopeeBaseString({ partnerId: '2001887', path: '/api/v2/shop/auth_partner', timestamp: 1655714431 })), + ); + const redirect = url.searchParams.get('redirect')!; + expect(redirect).toMatch(/^https:\/\/cloud\.anythingmcp\.com\/api\/shopee\/callback\/[0-9a-f]{32}$/); + expect(out!.authorizationUrl).not.toContain('pk-test-0001'); + }); + + it('only lets the owner or an admin of the workspace start it, and asks for the keys first', async () => { + await expect(controller.authorize({ user: { ...admin.user, organizationId: 'org-2' } }, 'conn-1')).rejects.toBeInstanceOf(ForbiddenException); + await expect(controller.authorize({ user: { ...admin.user, role: 'VIEWER' } }, 'conn-1')).rejects.toBeInstanceOf(ForbiddenException); + await expect(controller.authorize({ user: { sub: 'user-2', organizationId: 'org-1', role: 'EDITOR' } }, 'conn-1')).rejects.toBeInstanceOf(ForbiddenException); + connector.envVars = { SHOPEE_HOST: 'https://partner.shopeemobile.com' }; + expect(await controller.authorize(admin, 'conn-1')).toEqual({ error: expect.stringContaining('SHOPEE_PARTNER_KEY') }); + connector.authType = 'API_KEY'; + expect(await controller.authorize(admin, 'conn-1')).toEqual({ error: 'This connector does not authorize a Shopee shop.' }); + }); + + it("forwards Shopee's redirect to the dashboard, and refuses unknown states and main accounts", async () => { + const out = await controller.authorize(admin, 'conn-1'); + const state = new URL(new URL(out.authorizationUrl!).searchParams.get('redirect')!).pathname.split('/').pop()!; + + const ok = await redirectOf(state, { code: 'c-1', shop_id: '14701711' }); + expect(ok.origin + ok.pathname).toBe('https://app.test/connectors/oauth/complete'); + expect(Object.fromEntries(ok.searchParams)).toEqual({ provider: 'shopee', state, code: 'c-1', shop_id: '14701711' }); + // Peeked, not taken: the dashboard completes it. + expect(await flows.getPendingFlow(state)).toBeDefined(); + + const unknown = await redirectOf('nope', { code: 'c', shop_id: '1' }); + expect(unknown.searchParams.get('error')).toMatch(/expired or was already used/); + + const main = await redirectOf(state, { code: 'c', main_account_id: '9' }); + expect(main.searchParams.get('error')).toMatch(/main \(merchant\) account/); + expect(await flows.getPendingFlow(state)).toBeUndefined(); + }); + + it('completes only for the user who started it, then stores the tokens and reloads the tools', async () => { + const start = async () => { + const out = await controller.authorize(admin, 'conn-1', { returnTo: '/connectors/conn-1' }); + return new URL(new URL(out.authorizationUrl!).searchParams.get('redirect')!).pathname.split('/').pop()!; + }; + const s1 = await start(); + await expect( + controller.complete({ user: { sub: 'user-2' } }, { state: s1, code: 'c', shopId: '14701711' }), + ).rejects.toBeInstanceOf(ForbiddenException); + // The link someone else started is dead now. + await expect(controller.complete(admin, { state: s1, code: 'c', shopId: '14701711' })).rejects.toBeInstanceOf(GoneException); + expect(tokens.exchangeCode).not.toHaveBeenCalled(); + + const s2 = await start(); + expect(await controller.complete(admin, { state: s2, code: 'c-2', shopId: '14701711' })).toEqual({ + connectorId: 'conn-1', + toolsImported: 0, + returnTo: '/connectors/conn-1', + }); + expect(tokens.exchangeCode).toHaveBeenCalledWith({ + connectorId: 'conn-1', + app: { host: 'https://partner.shopeemobile.com', partnerId: '2001887', partnerKey: 'pk-test-0001' }, + code: 'c-2', + shopId: '14701711', + }); + expect(mcpServer.reloadConnectorTools).toHaveBeenCalledWith('conn-1'); + }); + + it("a generic OAuth flow's state is not accepted here", async () => { + await flows.storePendingFlow('generic', { + connectorId: 'conn-1', + userId: 'user-1', + redirectUri: 'x', + clientId: 'c', + tokenUrl: 'https://t', + createdAt: Date.now(), + }); + await expect(controller.complete(admin, { state: 'generic', code: 'c', shopId: '1' })).rejects.toBeInstanceOf(GoneException); + expect((await redirectOf('generic', { code: 'c', shop_id: '1' })).searchParams.get('error')).toBeTruthy(); + }); + + it("says what Shopee refused when the code exchange fails", async () => { + const out = await controller.authorize(admin, 'conn-1'); + const state = new URL(new URL(out.authorizationUrl!).searchParams.get('redirect')!).pathname.split('/').pop()!; + tokens.exchangeCode.mockRejectedValueOnce({ response: { status: 200, data: { error: 'invalid_code', message: 'Invalid code.' } } }); + const err = await controller.complete(admin, { state, code: 'c', shopId: '1' }).catch((e) => e); + expect(err).toBeInstanceOf(BadRequestException); + expect(err.getResponse()).toMatchObject({ connectorId: 'conn-1', message: expect.stringContaining('invalid_code') }); + }); +}); diff --git a/packages/backend/src/connectors/shopee-auth.controller.ts b/packages/backend/src/connectors/shopee-auth.controller.ts new file mode 100644 index 00000000..84961e29 --- /dev/null +++ b/packages/backend/src/connectors/shopee-auth.controller.ts @@ -0,0 +1,240 @@ +import { + BadGatewayException, + BadRequestException, + Body, + Controller, + ForbiddenException, + Get, + GoneException, + HttpCode, + Logger, + Param, + Post, + Query, + Req, + Res, + UseGuards, +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { ConfigService } from '@nestjs/config'; +import type { Response } from 'express'; +import { ConnectorsService } from './connectors.service'; +import { McpOAuthService } from './mcp-oauth.service'; +import { McpServerService } from '../mcp-server/mcp-server.service'; +import { decrypt } from '../common/crypto/encryption.util'; +import { getRequiredSecret } from '../common/secrets.util'; +import { interpolateDeep, interpolateString } from '../common/env-interpolation.util'; +import { + SHOPEE_TOKEN_GET_PATH, + ShopeeApp, + ShopeeTokenService, + buildShopeeAuthorizationUrl, + describeShopeeError, + isShopeeAuth, + shopeeAppFrom, + shopeeError, +} from './engines/shopee-token.service'; + +/** + * Authorizing a Shopee shop from the connector page ("Authorize with Provider"). + * + * Shopee's shop authorization is not OAuth 2: the link is signed with the + * partner key, and Shopee sends the browser back with `code` and `shop_id` + * (no `state`). So it has its own three steps here, kept apart from the + * generic OAuth callback, which is not touched: + * + * 1. POST /api/shopee/connectors/:id/authorize (signed in, owner or admin): + * stores a pending attempt (the same encrypted, single-use store the + * OAuth flows use) and returns the signed auth_partner link, whose + * redirect carries the attempt's state in its path. + * 2. GET /api/shopee/callback/:state: Shopee's redirect. Only checks that + * the state is one we issued and forwards code and shop_id to the + * dashboard, like the OAuth callback does. + * 3. POST /api/shopee/complete (signed in): exchanges the code, but only + * for the user who started the attempt, and stores the tokens. + */ +@ApiTags('Shopee') +@Controller('api/shopee') +export class ShopeeAuthController { + private readonly logger = new Logger(ShopeeAuthController.name); + private readonly encryptionKey: string; + + constructor( + private readonly connectorsService: ConnectorsService, + private readonly oauthFlows: McpOAuthService, + private readonly shopeeTokens: ShopeeTokenService, + private readonly mcpServer: McpServerService, + private readonly configService: ConfigService, + ) { + this.encryptionKey = getRequiredSecret('ENCRYPTION_KEY', this.configService.get('ENCRYPTION_KEY')); + } + + private serverUrl(): string { + return (this.configService.get('SERVER_URL') || 'http://localhost:4000').replace(/\/+$/, ''); + } + + private completePage(params: Record): string { + const base = (this.configService.get('FRONTEND_URL') || 'http://localhost:3000').replace(/\/+$/, ''); + return `${base}/connectors/oauth/complete?${new URLSearchParams(params).toString()}`; + } + + /** The connector's Shopee app settings, resolved from its variables. */ + private shopeeApp(connector: { + authType: string; + authConfig: string | null; + baseUrl: string; + envVars?: unknown; + }): { app?: ShopeeApp; isShopee: boolean } { + let authConfig: Record = {}; + try { + authConfig = connector.authConfig ? JSON.parse(decrypt(connector.authConfig, this.encryptionKey)) : {}; + } catch { + authConfig = {}; + } + if (!isShopeeAuth(connector.authType, authConfig)) return { isShopee: false }; + const envVars = (connector.envVars as Record | null) ?? {}; + return { + isShopee: true, + app: shopeeAppFrom(interpolateDeep(authConfig, envVars), interpolateString(connector.baseUrl, envVars)), + }; + } + + @Post('connectors/:id/authorize') + @UseGuards(AuthGuard('jwt')) + @ApiOperation({ summary: 'Start the authorization of a Shopee shop for a connector' }) + async authorize( + @Req() req: any, + @Param('id') id: string, + @Body() body?: { returnTo?: string }, + ): Promise<{ authorizationUrl?: string; error?: string }> { + const connector = await this.connectorsService.findById(id); + if (connector.organizationId !== req.user?.organizationId) throw new ForbiddenException('Resource not found'); + if (req.user.role === 'VIEWER') throw new ForbiddenException('Viewers cannot modify connectors'); + if (connector.userId !== req.user.sub && req.user.role !== 'ADMIN') { + throw new ForbiddenException('Only the connector owner or an admin can modify this connector'); + } + const { app, isShopee } = this.shopeeApp(connector); + if (!isShopee) return { error: 'This connector does not authorize a Shopee shop.' }; + if (!app) { + return { + error: + "Set SHOPEE_PARTNER_ID and SHOPEE_PARTNER_KEY in this connector's environment variables first, " + + 'and check that SHOPEE_HOST is a URL such as https://partner.shopeemobile.com.', + }; + } + const state = this.oauthFlows.generateState(); + const redirect = `${this.serverUrl()}/api/shopee/callback/${state}`; + await this.oauthFlows.storePendingFlow( + state, + { + provider: 'shopee', + connectorId: connector.id, + userId: req.user.sub, + redirectUri: redirect, + clientId: app.partnerId, + tokenUrl: `${app.host}${SHOPEE_TOKEN_GET_PATH}`, + createdAt: Date.now(), + }, + { returnTo: body?.returnTo }, + ); + return { authorizationUrl: buildShopeeAuthorizationUrl(app, redirect) }; + } + + @Get('callback/:state') + @ApiOperation({ summary: "Shopee's redirect after a shop authorization; forwards to the dashboard" }) + async callback( + @Param('state') state: string, + @Query('code') rawCode: unknown, + @Query('shop_id') rawShopId: unknown, + @Query('main_account_id') rawMainAccount: unknown, + @Res() res: Response, + ) { + const code = typeof rawCode === 'string' ? rawCode : undefined; + const shopId = typeof rawShopId === 'string' ? rawShopId : undefined; + const record = await this.oauthFlows.getPendingFlow(String(state || '')); + if (!record || record.flow.provider !== 'shopee') { + return res.redirect( + this.completePage({ error: 'This authorization expired or was already used. Start it again from the connector.' }), + ); + } + const connectorId = record.flow.connectorId; + if (!code) { + await this.oauthFlows.deletePendingFlow(state); + return res.redirect( + this.completePage({ + error: 'Shopee came back without an authorization code. The authorization was cancelled or refused; start it again.', + connectorId, + }), + ); + } + if (!shopId || !/^\d{1,20}$/.test(shopId)) { + await this.oauthFlows.deletePendingFlow(state); + return res.redirect( + this.completePage({ + error: + typeof rawMainAccount === 'string' + ? 'Shopee authorized a main (merchant) account. This connector works with one shop: authorize again and sign in with the shop account.' + : 'Shopee came back without a shop id. Start the authorization again and pick a shop.', + connectorId, + }), + ); + } + return res.redirect(this.completePage({ provider: 'shopee', state, code, shop_id: shopId })); + } + + @Post('complete') + @UseGuards(AuthGuard('jwt')) + @HttpCode(200) + @ApiOperation({ summary: 'Complete a Shopee shop authorization (dashboard, signed in)' }) + async complete( + @Req() req: any, + @Body() body: { state?: string; code?: string; shopId?: string }, + ): Promise<{ connectorId: string; toolsImported: number; returnTo?: string }> { + const record = await this.oauthFlows.takePendingFlow(String(body?.state || '')); + if (!record || record.flow.provider !== 'shopee') { + throw new GoneException('This authorization expired or was already used. Start it again from the connector.'); + } + const { flow, returnTo } = record; + if (flow.userId !== req.user?.sub) { + this.logger.warn(`Refused Shopee completion: connector ${flow.connectorId} was authorized by another user`); + throw new ForbiddenException( + 'This authorization was started by another account. Sign in as that account, or start it again yourself.', + ); + } + const code = typeof body?.code === 'string' ? body.code : ''; + const shopId = typeof body?.shopId === 'string' ? body.shopId : ''; + if (!code || !/^\d{1,20}$/.test(shopId)) { + throw new GoneException('Shopee came back without a code or shop id. Start the authorization again.'); + } + const connector = await this.connectorsService.findByIdInternal(flow.connectorId); + const { app } = this.shopeeApp(connector as never); + if (!app) { + throw new BadRequestException({ + message: 'The connector has no Shopee partner id and key any more. Set them and authorize again.', + connectorId: flow.connectorId, + }); + } + try { + await this.shopeeTokens.exchangeCode({ connectorId: flow.connectorId, app, code, shopId }); + } catch (err) { + const said = describeShopeeError(err); + const status = shopeeError(err)?.status; + this.logger.warn(`Shopee code exchange failed for connector ${flow.connectorId}: ${said}`); + const message = + typeof status === 'number' && status >= 500 + ? `Shopee could not complete the authorization (${said}). Try again in a few minutes.` + : `Shopee refused the authorization (${said}). Check that SHOPEE_PARTNER_ID and SHOPEE_PARTNER_KEY belong ` + + 'to the same app, that SHOPEE_HOST matches it (live or sandbox), and authorize again: a code works once ' + + 'and only for a few minutes.'; + const payload = { message, connectorId: flow.connectorId }; + throw typeof status === 'number' && status >= 500 ? new BadGatewayException(payload) : new BadRequestException(payload); + } + try { + await this.mcpServer.reloadConnectorTools(flow.connectorId); + } catch (err: any) { + this.logger.warn(`Failed to reload tools after the Shopee authorization of ${flow.connectorId}: ${err?.message}`); + } + return { connectorId: flow.connectorId, toolsImported: 0, ...(returnTo ? { returnTo } : {}) }; + } +} diff --git a/packages/backend/src/mcp-server/mcp-server.module.ts b/packages/backend/src/mcp-server/mcp-server.module.ts index 969de812..91782a93 100644 --- a/packages/backend/src/mcp-server/mcp-server.module.ts +++ b/packages/backend/src/mcp-server/mcp-server.module.ts @@ -17,6 +17,7 @@ import { OAuth2TokenService } from '../connectors/engines/oauth2-token.service'; import { LoginTokenService } from '../connectors/engines/login-token.service'; import { ConnectorAuthStatusService } from '../connectors/engines/connector-auth-status.service'; import { BearerTokenRenewalService } from '../connectors/engines/bearer-token-renewal.service'; +import { ShopeeTokenService } from '../connectors/engines/shopee-token.service'; import { SettingsModule } from '../settings/settings.module'; import { GraphqlSchemaService } from '../connectors/engines/graphql-schema.service'; import { McpServersModule } from '../mcp-servers/mcp-servers.module'; @@ -35,7 +36,7 @@ const ENGINES = [ @Module({ imports: [McpServersModule, LicenseModule, SettingsModule], controllers: [McpEndpointController, WellKnownOAuthController, RegistryCatchUpController], - providers: [SharedSetupRegistry, McpServerService, ToolRegistry, DynamicMcpTools, McpCombinedAuthGuard, McpPrincipalRateLimitGuard, OAuth2TokenService, LoginTokenService, ConnectorAuthStatusService, BearerTokenRenewalService, GraphqlSchemaService, ...ENGINES], - exports: [McpServerService, ToolRegistry, SharedSetupRegistry, OAuth2TokenService, LoginTokenService, ConnectorAuthStatusService, BearerTokenRenewalService], + providers: [SharedSetupRegistry, McpServerService, ToolRegistry, DynamicMcpTools, McpCombinedAuthGuard, McpPrincipalRateLimitGuard, OAuth2TokenService, LoginTokenService, ConnectorAuthStatusService, BearerTokenRenewalService, ShopeeTokenService, GraphqlSchemaService, ...ENGINES], + exports: [McpServerService, ToolRegistry, SharedSetupRegistry, OAuth2TokenService, LoginTokenService, ConnectorAuthStatusService, BearerTokenRenewalService, ShopeeTokenService], }) export class McpServerModule {} diff --git a/packages/frontend/src/app/connectors/[id]/page.tsx b/packages/frontend/src/app/connectors/[id]/page.tsx index ae421399..3241e118 100644 --- a/packages/frontend/src/app/connectors/[id]/page.tsx +++ b/packages/frontend/src/app/connectors/[id]/page.tsx @@ -894,6 +894,24 @@ export default function ConnectorDetailPage() { } }; + // Shopee shops are authorized through Shopee's own signed link, not OAuth2. + const handleShopeeAuthorize = async () => { + if (!token) return; + setAuthorizing(true); + try { + const result = await connectors.shopeeAuthorize(id, token); + if (result.authorizationUrl) { + window.location.href = result.authorizationUrl; + } else if (result.error) { + setMsg(result.error); + } + } catch (err: any) { + setMsg(`Authorization failed: ${err.message}`); + } finally { + setAuthorizing(false); + } + }; + const handleDiscoverTools = async () => { if (!token) return; setDiscovering(true); @@ -1705,6 +1723,20 @@ export default function ConnectorDetailPage() { )} + {/* Shopee shop authorization (HMAC-signed, Shopee's own flow) */} + {connector.authType === 'HMAC' && (connector.config as any)?.adapterSlug === 'shopee' && ( + +

Shopee shop authorization

+

+ Sign in with the shop account and approve this app. The tokens are stored encrypted and renewed + automatically. Authorize again when the authorization expires or the connector asks for it. +

+ +
+ )} + {/* Environment Variables */}
diff --git a/packages/frontend/src/app/connectors/oauth/complete/page.tsx b/packages/frontend/src/app/connectors/oauth/complete/page.tsx index 86e28786..7ffb0eec 100644 --- a/packages/frontend/src/app/connectors/oauth/complete/page.tsx +++ b/packages/frontend/src/app/connectors/oauth/complete/page.tsx @@ -24,6 +24,9 @@ function CompleteContent() { const state = params.get('state'); const code = params.get('code'); + // A Shopee shop authorization comes back through its own callback, with the + // shop id next to the code, and is completed by its own endpoint. + const shopeeShopId = params.get('provider') === 'shopee' ? params.get('shop_id') : null; // From the provider redirect, or from the answer when the exchange is refused. const [connectorId, setConnectorId] = useState(params.get('connectorId')); @@ -39,8 +42,9 @@ function CompleteContent() { // The code is single-use and bound to a verifier the server keeps, but it // still has no business staying in the address bar or the history. window.history.replaceState({}, '', '/connectors/oauth/complete'); - connectors - .oauthComplete(state, code, token) + (shopeeShopId !== null + ? connectors.shopeeComplete(state, code, shopeeShopId, token) + : connectors.oauthComplete(state, code, token)) .then((out) => { const fallback = `/connectors/${out.connectorId}?oauth=success&tools=${out.toolsImported}`; router.replace(out.returnTo || fallback); @@ -51,7 +55,7 @@ function CompleteContent() { } setError(err.message || 'The authorization could not be completed.'); }); - }, [error, state, code, token, isLoading, router]); + }, [error, state, code, shopeeShopId, token, isLoading, router]); if (!error && !state) { return

Nothing to complete here.

; diff --git a/packages/frontend/src/app/connectors/setup/[slug]/page.tsx b/packages/frontend/src/app/connectors/setup/[slug]/page.tsx index 1fc8ff67..7b75e956 100644 --- a/packages/frontend/src/app/connectors/setup/[slug]/page.tsx +++ b/packages/frontend/src/app/connectors/setup/[slug]/page.tsx @@ -211,6 +211,17 @@ function SetupContent() { setPhase('form'); return; } + // A Shopee shop is authorized right after the keys are saved, through + // Shopee's own signed link. + if (slug === 'shopee') { + const id = await save(); + const returnTo = `/connectors/setup/${slug}?connector=${id}&step=done${back ? `&from=${back.id}` : ''}`; + const auth = await connectors.shopeeAuthorize(id, token, returnTo); + if (!auth.authorizationUrl) throw new Error(auth.error || 'Could not start the authorization.'); + productEvents.track('oauth_started', token, { adapterSlug: slug }); + window.location.href = auth.authorizationUrl; + return; + } if (info.setupKind === 'oauth_browser') { const id = await save(); const returnTo = `/connectors/setup/${slug}?connector=${id}&step=done${back ? `&from=${back.id}` : ''}`; diff --git a/packages/frontend/src/lib/api.ts b/packages/frontend/src/lib/api.ts index bc2a5ba6..780924df 100644 --- a/packages/frontend/src/lib/api.ts +++ b/packages/frontend/src/lib/api.ts @@ -566,6 +566,18 @@ export const connectors = { '/api/mcp-oauth/complete', { method: 'POST', token, body: { state, code } }, ), + /** "Authorize with Provider" for a Shopee shop: the signed shop authorization link. */ + shopeeAuthorize: (id: string, token: string, returnTo?: string) => + request<{ authorizationUrl?: string; error?: string }>( + `/api/shopee/connectors/${id}/authorize`, + { method: 'POST', token, body: returnTo ? { returnTo } : undefined }, + ), + /** Second half of a Shopee shop authorization: exchange the code Shopee sent back. */ + shopeeComplete: (state: string, code: string, shopId: string, token: string) => + request<{ connectorId: string; toolsImported: number; returnTo?: string }>( + '/api/shopee/complete', + { method: 'POST', token, body: { state, code, shopId } }, + ), oauthRedirectUri: (token: string) => request<{ redirectUri: string }>('/api/connectors/oauth/redirect-uri', { token }), discoverTools: (id: string, token: string) => From 34a82d5b3e8ac614c664e6a2b4ae592362a1afa3 Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:29:43 +0200 Subject: [PATCH 3/4] Shopee Seller connector 22 tools on the Shopee Open Platform API v2: shop info and profile, listings (list, search, details, performance, variations), orders and order details, orders to ship, shipping parameters, tracking number and history, logistics channels, returns, order income, discounts, Ads balance and daily performance. Stock and price updates install switched off. Host, partner id and partner key are variables; the shop is authorized from the connector page. Logo, catalog and quoted adapter counts (487). --- CITATION.cff | 2 +- README.de.md | 6 +- README.ja.md | 6 +- README.md | 6 +- README.zh-CN.md | 6 +- glama.json | 2 +- package.json | 2 +- packages/backend/src/adapters/catalog.ts | 2 + .../backend/src/adapters/intl/shopee.json | 960 ++++++++++++++++++ .../src/adapters/intl/shopee.live.spec.ts | 372 +++++++ .../public/logos/connectors/shopee.svg | 1 + server.json | 2 +- 12 files changed, 1351 insertions(+), 16 deletions(-) create mode 100644 packages/backend/src/adapters/intl/shopee.json create mode 100644 packages/backend/src/adapters/intl/shopee.live.spec.ts create mode 100644 packages/frontend/public/logos/connectors/shopee.svg diff --git a/CITATION.cff b/CITATION.cff index 595b5d34..30f9282f 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -13,7 +13,7 @@ abstract: > server that turns REST/OpenAPI, SOAP/WSDL and GraphQL APIs, SQL/NoSQL databases and other MCP servers into tools for AI clients such as Claude, ChatGPT, Google Gemini, GitHub Copilot and Cursor, without writing code. - It ships 486 pre-built adapters, with a focus on ERP and e-commerce + It ships 487 pre-built adapters, with a focus on ERP and e-commerce systems (SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland, OTTO, etc.), a per-workspace knowledge graph served over MCP, per-tool response mapping, diff --git a/README.de.md b/README.de.md index d4d4392f..665ec32d 100644 --- a/README.de.md +++ b/README.de.md @@ -1,5 +1,5 @@

- AnythingMCP macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 486 Connectors, 21 davon ohne API-Schlüssel. + AnythingMCP macht ERP-, E-Commerce-, REST-, SOAP- und SQL-Systeme zu MCP-Tools für Claude und ChatGPT: 487 Connectors, 21 davon ohne API-Schlüssel.

AnythingMCP: selbst gehostetes MCP-Gateway

@@ -19,7 +19,7 @@

AnythingMCP ist ein quelloffenes, selbst gehostetes MCP-Gateway, das jedes REST-/OpenAPI-, SOAP-, GraphQL-, OData- oder SQL-System in MCP-Tools für Claude, ChatGPT und Copilot verwandelt, ohne dass du einen MCP-Server programmierst.
- Es bringt 486 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 21 davon kommen ohne API-Schlüssel aus. + Es bringt 487 fertige Adapter mit, darunter SAP, Etsy, weclapp und Amazon Seller; 21 davon kommen ohne API-Schlüssel aus.

@@ -111,7 +111,7 @@ Tools werden zur Laufzeit registriert, ohne Neustart. `{{VAR}}`-Werte pro Connec ## Connector-Katalog -486 Adapter mit über 2.400 Tools. Zu jedem gibt es eine Einrichtungsanleitung auf [anythingmcp.com/de/guides](https://anythingmcp.com/de/guides), in sieben Sprachen. +487 Adapter mit über 2.400 Tools. Zu jedem gibt es eine Einrichtungsanleitung auf [anythingmcp.com/de/guides](https://anythingmcp.com/de/guides), in sieben Sprachen. | Kategorie | Beispiele | |---|---| diff --git a/README.ja.md b/README.ja.md index 222ecbbf..b773f2fc 100644 --- a/README.ja.md +++ b/README.ja.md @@ -1,5 +1,5 @@

- AnythingMCP は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。486 のコネクター、うち 21 は API キー不要。 + AnythingMCP は ERP、E コマース、REST、SOAP、SQL の各システムを Claude と ChatGPT 用の MCP ツールに変換します。487 のコネクター、うち 21 は API キー不要。

AnythingMCP:セルフホスト型 MCP ゲートウェイ

@@ -19,7 +19,7 @@

AnythingMCP は、オープンソースのセルフホスト型 MCP ゲートウェイです。MCP サーバーを書かずに、REST/OpenAPI、SOAP、GraphQL、OData、SQL のあらゆるシステムを Claude、ChatGPT、Copilot 用の MCP ツールに変換します。
- SAP、Etsy、weclapp、Amazon Seller などを含む 486 種類の既製アダプターを同梱しており、うち 21 は API キー不要です。 + SAP、Etsy、weclapp、Amazon Seller などを含む 487 種類の既製アダプターを同梱しており、うち 21 は API キー不要です。

@@ -111,7 +111,7 @@ amd64 ではイメージの取得に約 30 秒、その 24 秒後に API が利 ## コネクターカタログ -486 個のアダプターで 2,400 以上のツールを提供しています。どのアダプターにも [anythingmcp.com/ja/guides](https://anythingmcp.com/ja/guides) に 7 言語の設定ガイドがあります。 +487 個のアダプターで 2,400 以上のツールを提供しています。どのアダプターにも [anythingmcp.com/ja/guides](https://anythingmcp.com/ja/guides) に 7 言語の設定ガイドがあります。 | カテゴリー | 例 | |---|---| diff --git a/README.md b/README.md index 7d9f4251..359211f0 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- AnythingMCP turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 486 connectors, 21 of them with no API key. + AnythingMCP turns ERP, e-commerce, REST, SOAP and SQL systems into MCP tools for Claude and ChatGPT: 487 connectors, 21 of them with no API key.

AnythingMCP: self-hosted MCP gateway

@@ -19,7 +19,7 @@

AnythingMCP is an open-source, self-hosted MCP gateway that turns any REST/OpenAPI, SOAP, GraphQL, OData or SQL system into MCP tools for Claude, ChatGPT and Copilot, without writing an MCP server.
- It ships 486 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 21 of them need no API key. + It ships 487 ready connectors, among them SAP, Etsy, weclapp and Amazon Seller, and 21 of them need no API key.

@@ -103,7 +103,7 @@ Tools register at runtime, without a restart. Per-connector `{{VAR}}` values are ## Connector catalog -486 adapters, exposing 2,400+ tools. Every one has a setup guide on [anythingmcp.com/guides](https://anythingmcp.com/guides), in seven languages. +487 adapters, exposing 2,400+ tools. Every one has a setup guide on [anythingmcp.com/guides](https://anythingmcp.com/guides), in seven languages. | Category | Examples | |---|---| diff --git a/README.zh-CN.md b/README.zh-CN.md index b633e4cf..a23dfcab 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,5 +1,5 @@

- AnythingMCP 将 ERP、电子商务、REST、SOAP 和 SQL 系统转化为 Claude 和 ChatGPT 可用的 MCP 工具:486 个连接器,其中 21 个无需 API 密钥。 + AnythingMCP 将 ERP、电子商务、REST、SOAP 和 SQL 系统转化为 Claude 和 ChatGPT 可用的 MCP 工具:487 个连接器,其中 21 个无需 API 密钥。

AnythingMCP:自行托管的 MCP 网关

@@ -19,7 +19,7 @@

AnythingMCP 是一个开源、可自行托管的 MCP 网关,无需编写 MCP 服务器,即可将任意 REST/OpenAPI、SOAP、GraphQL、OData 或 SQL 系统转化为 Claude、ChatGPT 和 Copilot 可用的 MCP 工具。
- 它自带 486 个现成适配器,涵盖 SAP、Etsy、weclapp 和 Amazon Seller 等,其中 21 个无需 API 密钥。 + 它自带 487 个现成适配器,涵盖 SAP、Etsy、weclapp 和 Amazon Seller 等,其中 21 个无需 API 密钥。

@@ -111,7 +111,7 @@ docker compose up -d ## 连接器目录 -共 486 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 +共 487 个适配器,提供 2,400 多个工具。每个适配器都在 [anythingmcp.com/zh/guides](https://anythingmcp.com/zh/guides) 上提供七种语言的配置指南。 | 类别 | 示例 | |---|---| diff --git a/glama.json b/glama.json index d0206bdf..8a690f5a 100644 --- a/glama.json +++ b/glama.json @@ -3,5 +3,5 @@ "maintainers": [ "helpcodeai" ], - "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 486 pre-built adapters for ERP and e-commerce (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland, OTTO\u2026) and more. Self-hosted, knowledge graph, per-tool response mapping, OAuth2/RBAC/SSO/audit. Open source under AGPL-3.0." + "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 487 pre-built adapters for ERP and e-commerce (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, weclapp, Shopware, WooCommerce, Amazon Seller, Kaufland, OTTO\u2026) and more. Self-hosted, knowledge graph, per-tool response mapping, OAuth2/RBAC/SSO/audit. Open source under AGPL-3.0." } diff --git a/package.json b/package.json index 97f6c5f8..42cd1d84 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "anythingmcp", "version": "0.38.0", - "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 486 pre-built adapters for ERP, e-commerce and more (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, Shopware, WooCommerce, Amazon Seller). Self-hosted, open source (AGPL-3.0).", + "description": "Turn any REST/OpenAPI, SOAP/WSDL, GraphQL, OData or SQL API into MCP tools for Claude, ChatGPT and Copilot, no code. 487 pre-built adapters for ERP, e-commerce and more (SAP S/4HANA, SAP Business One, Odoo, Xentral, JTL-Wawi, Shopware, WooCommerce, Amazon Seller). Self-hosted, open source (AGPL-3.0).", "private": true, "license": "AGPL-3.0-only", "engines": { diff --git a/packages/backend/src/adapters/catalog.ts b/packages/backend/src/adapters/catalog.ts index a9f85650..510c26f6 100644 --- a/packages/backend/src/adapters/catalog.ts +++ b/packages/backend/src/adapters/catalog.ts @@ -387,6 +387,7 @@ import * as serply from './intl/serply.json'; import * as servicenow from './intl/servicenow.json'; import * as shippo from './intl/shippo.json'; import * as shipstation from './intl/shipstation.json'; +import * as shopee from './intl/shopee.json'; import * as shopify from './intl/shopify.json'; import * as shortcut from './intl/shortcut.json'; import * as signwell from './intl/signwell.json'; @@ -1107,6 +1108,7 @@ const RAW_ADAPTERS: AdapterDefinition[] = [ servicenow as unknown as AdapterDefinition, shippo as unknown as AdapterDefinition, shipstation as unknown as AdapterDefinition, + shopee as unknown as AdapterDefinition, shopify as unknown as AdapterDefinition, shortcut as unknown as AdapterDefinition, signwell as unknown as AdapterDefinition, diff --git a/packages/backend/src/adapters/intl/shopee.json b/packages/backend/src/adapters/intl/shopee.json new file mode 100644 index 00000000..9a72afa2 --- /dev/null +++ b/packages/backend/src/adapters/intl/shopee.json @@ -0,0 +1,960 @@ +{ + "slug": "shopee", + "name": "Shopee Seller", + "aliases": [ + "Shopee Open Platform", + "Shopee Partner API" + ], + "description": "Let your AI work with your Shopee shop: listings and variations, orders, shipping and tracking, returns, order income, discounts and Ads figures, with stock and price updates available on request.", + "region": "intl", + "category": "e-commerce", + "icon": "shopee", + "docsUrl": "https://open.shopee.com/developer-guide/16", + "instructions": "## Shopee Seller\nThis connector calls the **Shopee Open Platform API v2** for one Shopee shop: shop details, listings and their variations, orders, shipping and tracking, returns, order income, discount promotions and Shopee Ads figures. Stock and price updates are included but installed switched off.\n\n**What you need**: a Shopee Open Platform developer account and an app of your own, with its **Partner ID** and **Partner Key**, and the seller account of the shop. About 15 minutes the first time, longer if Shopee still has to approve the app.\n\n**Setup**\n1. Sign in at https://open.shopee.com, complete the developer registration, then in the **Console** open **App List** and create an app for your own shop (for example the Seller In House System type). Shopee reviews new apps before they can go live.\n2. On the app's edit page, declare the **Test Redirect URL Domain** and the **Live Redirect URL Domain**: the address of your AnythingMCP backend, `https://cloud.anythingmcp.com` on AnythingMCP Cloud, or your own `SERVER_URL` when self-hosted. Shopee refuses an authorization whose redirect is on another domain. AnythingMCP sends Shopee back to `/api/shopee/callback/...`.\n3. Copy the app's **Partner ID** into `SHOPEE_PARTNER_ID` and its **Partner Key** into `SHOPEE_PARTNER_KEY`. Live and test keys are different: use the pair that matches `SHOPEE_HOST`.\n4. Set `SHOPEE_HOST`: `https://partner.shopeemobile.com` for live shops (Southeast Asia, Taiwan, Latin America and the other markets), `https://openplatform.shopee.com.br` if your app is served from Brazil, `https://openplatform.shopee.cn` for apps of developers in China mainland, and `https://openplatform.sandbox.test-stable.shopee.sg` (or `https://partner.test-stable.shopeemobile.com`) with the test keys and a sandbox test shop.\n5. Install the connector, open it in AnythingMCP and click **Authorize with Provider**. Shopee asks you to sign in: use the **shop account** (a main or merchant account is not supported), pick the shop and how long the authorization lasts (up to 365 days), and confirm. You come back to AnythingMCP and the connector is ready.\n\n**How it signs**: every call carries `partner_id`, `timestamp`, `access_token`, `shop_id` and `sign` in the query, where `sign` is an HMAC-SHA256 over the partner id, the API path, the timestamp, the access token and the shop id, keyed with the partner key. The partner key itself never leaves AnythingMCP. A signature is only valid for 5 minutes, so a host clock that is off gives `error_sign` or a timestamp error.\n\n**Tokens**: the access token lasts about 4 hours and is renewed automatically before it expires; the refresh token rotates on every renewal and is stored encrypted on the connector. If the connector is not used for 30 days, or the shop's authorization ends or is cancelled in Shopee Seller Centre, Shopee refuses the refresh token: the connector then shows that it needs a new authorization, its owner gets one email, and calls stop until someone clicks **Authorize with Provider** again. `shopee_get_shop_info` shows when the current authorization expires (`expire_time`).\n\n**Where to start**: `shopee_get_shop_info` checks everything at once. Listings: `shopee_list_items` or `shopee_search_items` give item ids, `shopee_get_items` the details, `shopee_get_item_models` the variations with their own price and stock, `shopee_get_items_extra_info` sales and views. Orders: `shopee_list_orders` (a window of at most 15 days, Unix seconds), then `shopee_get_order_details` for up to 50 order numbers; `shopee_list_orders_to_ship` lists what still has to go out. Shipping: `shopee_get_shipping_parameter`, `shopee_get_tracking_number`, `shopee_get_tracking_info`, `shopee_list_logistics_channels`. After-sales: `shopee_list_returns` and `shopee_get_return`. Money in: `shopee_get_order_income`. Marketing: `shopee_list_discounts`, `shopee_get_ads_balance`, `shopee_get_ads_daily_performance`.\n\n**Times and paging**: times are Unix seconds and windows are limited (15 days for orders and returns). Lists page with `offset`/`next_offset`, `cursor`/`next_cursor` or `page_no`, each tool says which; keep going while `has_next_page` or `more` is true.\n\n**Errors**: Shopee answers with an `error` code and a `message`. `error_sign` or `invalid_partner_id`: the partner id, key or host do not belong together (live keys on the sandbox host, or the reverse). `error_permission`: the app lacks the permission for that API (Ads, for example). `error_param`: a parameter is wrong or a time window is too long. `error_rate_limit` or HTTP 429: wait and try again. If your app declares an IP allowlist in the Console, calls from AnythingMCP's address fail with `source_ip_undeclared`.\n\n**Writes**: `shopee_update_stock` and `shopee_update_price` change the live shop and are installed switched off; turn them on per connector if you want the AI to make such changes, and confirm every change with the user first. Nothing here moves money, ships, cancels or answers buyers.", + "prerequisites": "A Shopee Open Platform app of your own (Partner ID and Partner Key) whose Redirect URL Domain is your AnythingMCP address, and the seller account of the shop to authorize. About 15 minutes, plus Shopee's review of a new app.", + "appRegistrationUrl": "https://open.shopee.com/console", + "requiredEnvVars": [ + "SHOPEE_HOST", + "SHOPEE_PARTNER_ID", + "SHOPEE_PARTNER_KEY" + ], + "envVarMeta": { + "SHOPEE_HOST": { + "label": "Shopee API host", + "kind": "address", + "help": "https://partner.shopeemobile.com for live shops; https://openplatform.shopee.com.br (Brazil) or https://openplatform.shopee.cn (China mainland developers) when your app is served there; https://openplatform.sandbox.test-stable.shopee.sg for the sandbox with test keys.", + "example": "https://partner.shopeemobile.com", + "pattern": "^https://(partner\\.shopeemobile\\.com|partner\\.test-stable\\.shopeemobile\\.com|openplatform\\.shopee\\.(cn|com\\.br)|openplatform\\.(sandbox\\.)?test-stable\\.shopee\\.(sg|cn))$", + "patternMessage": "Use one of the Shopee API hosts, with https:// and without a path, e.g. https://partner.shopeemobile.com." + }, + "SHOPEE_PARTNER_ID": { + "label": "Partner ID", + "kind": "setting", + "help": "Shopee Open Platform Console, App List, your app: the Partner ID (a number). Live and test apps have different ids.", + "example": "2001887", + "link": "https://open.shopee.com/console", + "pattern": "^[0-9]{1,12}$", + "patternMessage": "The Partner ID is a number." + }, + "SHOPEE_PARTNER_KEY": { + "label": "Partner Key", + "kind": "credential", + "secret": true, + "help": "Same page as the Partner ID: the Partner Key (live or test, matching the host). It signs every request and is never sent.", + "link": "https://open.shopee.com/console", + "pattern": "^\\S{16,256}$", + "patternMessage": "Paste the Partner Key alone, without spaces or quotes." + } + }, + "verifyHints": { + "401": "Shopee refused the signature or the shop authorization. Check that SHOPEE_PARTNER_ID and SHOPEE_PARTNER_KEY belong to the same app and match SHOPEE_HOST (live keys on the live host, test keys on the sandbox), then click Authorize with Provider again.", + "429": "Shopee is rate limiting this app. Wait a minute and try again." + }, + "probe": { + "tool": "shopee_get_shop_info" + }, + "connector": { + "name": "Shopee Open Platform v2", + "type": "REST", + "baseUrl": "{{SHOPEE_HOST}}", + "authType": "HMAC", + "authConfig": { + "tokenProvider": "shopee", + "partnerId": "{{SHOPEE_PARTNER_ID}}", + "signature": { + "algorithm": "sha256", + "encoding": "hex", + "secret": "{{SHOPEE_PARTNER_KEY}}", + "placement": "query", + "paramName": "sign", + "timestampParam": "timestamp", + "queryParams": { + "partner_id": "{{SHOPEE_PARTNER_ID}}" + }, + "template": "${query.partner_id}${pathname}${timestamp}${query.access_token}${query.shop_id}" + } + }, + "headers": { + "Accept": "application/json", + "Content-Type": "application/json", + "User-Agent": "AnythingMCP" + }, + "healthcheckPath": "/api/v2/shop/get_shop_info", + "config": { + "errorWhen": [ + { + "path": "error", + "matches": "^(invalid_ac+e+s+_token|error_auth|error_sign|invalid_partner_id|shop_access_expired|shop_no_linked|refresh_token_expired|error_shop_refresh_token|shop_banned)$", + "messagePath": [ + "error", + "message" + ], + "status": 401 + }, + { + "path": "error", + "matches": "^(error_permission|source_ip_undeclared)$", + "messagePath": [ + "error", + "message" + ], + "status": 403 + }, + { + "path": "error", + "matches": "^(error_rate_limit|error_limit|ads\\.rate_limit\\..+)$", + "messagePath": [ + "error", + "message" + ], + "status": 429 + }, + { + "path": "error", + "matches": ".+", + "messagePath": [ + "error", + "message" + ], + "status": 400 + } + ] + } + }, + "tools": [ + { + "name": "shopee_get_shop_info", + "description": "Read the authorized shop: shop_name, region, status (NORMAL, BANNED, FROZEN), whether it is cross-border (is_cb), auth_time and expire_time of the authorization (Unix seconds). No parameters; the lightest call, and the check that the partner key, the host and the shop authorization line up.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/shop/get_shop_info" + } + }, + { + "name": "shopee_get_shop_profile", + "description": "Read the shop's public profile: name, logo, description and invoice issuer.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/shop/get_profile" + } + }, + { + "name": "shopee_list_items", + "description": "List the shop's listings (item ids with status and update_time), 100 per page at most. Page with offset: pass next_offset from the answer while has_next_page is true. Then read details with shopee_get_items and variations with shopee_get_item_models.", + "parameters": { + "type": "object", + "properties": { + "offset": { + "type": "integer", + "description": "Where to start, 0 for the first page; then next_offset from the previous answer.", + "default": 0, + "minimum": 0 + }, + "page_size": { + "type": "integer", + "description": "Items per page, 1 to 100.", + "default": 50, + "minimum": 1, + "maximum": 100 + }, + "item_status": { + "type": "array", + "description": "Listing statuses to include; several are allowed. NORMAL is live, UNLIST is hidden by the seller.", + "items": { + "type": "string", + "enum": [ + "NORMAL", + "BANNED", + "UNLIST", + "REVIEWING", + "SELLER_DELETE", + "SHOPEE_DELETE" + ] + }, + "default": [ + "NORMAL" + ] + }, + "update_time_from": { + "type": "integer", + "description": "Only items updated at or after this time. Unix time in seconds." + }, + "update_time_to": { + "type": "integer", + "description": "Only items updated at or before this time. Unix time in seconds." + } + }, + "required": [ + "offset", + "page_size", + "item_status" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/product/get_item_list", + "queryParams": { + "offset": "$offset", + "page_size": "$page_size", + "item_status": "$item_status", + "update_time_from": "$update_time_from", + "update_time_to": "$update_time_to" + } + } + }, + { + "name": "shopee_search_items", + "description": "Search the shop's listings by name or SKU (an SKU match wins over a name match), or find listings missing attributes. Returns item ids; read them with shopee_get_items. Page with offset (a string) from next_offset.", + "parameters": { + "type": "object", + "properties": { + "item_name": { + "type": "string", + "description": "Words in the listing name." + }, + "item_sku": { + "type": "string", + "description": "Parent SKU of the listing." + }, + "item_status": { + "type": "array", + "description": "Listing statuses to include.", + "items": { + "type": "string", + "enum": [ + "NORMAL", + "BANNED", + "UNLIST", + "REVIEWING", + "SELLER_DELETE", + "SHOPEE_DELETE" + ] + } + }, + "attribute_status": { + "type": "integer", + "description": "1: listings missing required attributes, 2: listings missing optional attributes.", + "enum": [ + 1, + 2 + ] + }, + "page_size": { + "type": "integer", + "description": "Results per page, 1 to 100.", + "default": 50, + "minimum": 1, + "maximum": 100 + }, + "offset": { + "type": "string", + "description": "Leave out for the first page; then next_offset from the previous answer." + } + }, + "required": [ + "page_size" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/product/search_item", + "queryParams": { + "item_name": "$item_name", + "item_sku": "$item_sku", + "item_status": "$item_status", + "attribute_status": "$attribute_status", + "page_size": "$page_size", + "offset": "$offset" + } + } + }, + { + "name": "shopee_get_items", + "description": "Read up to 50 listings by item id: name, SKU, category, status, price info, stock info (when the listing has no variations), images, weight and dimensions, logistics, and has_model. For a listing with variations, prices and stock are per model: use shopee_get_item_models.", + "parameters": { + "type": "object", + "properties": { + "item_id_list": { + "type": "array", + "description": "Item ids, 1 to 50, from shopee_list_items or shopee_search_items.", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 50 + }, + "need_tax_info": { + "type": "boolean", + "description": "Also return tax_info." + }, + "need_complaint_policy": { + "type": "boolean", + "description": "Also return complaint_policy (PL only)." + } + }, + "required": [ + "item_id_list" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/product/get_item_base_info", + "queryParams": { + "item_id_list": "$item_id_list", + "need_tax_info": "$need_tax_info", + "need_complaint_policy": "$need_complaint_policy" + } + }, + "responseMapping": { + "transform": { + "maxBytes": 200000 + } + } + }, + { + "name": "shopee_get_items_extra_info", + "description": "Read performance figures for up to 50 listings: sales, views, likes, rating_star and comment_count.", + "parameters": { + "type": "object", + "properties": { + "item_id_list": { + "type": "array", + "description": "Item ids, 1 to 50.", + "items": { + "type": "integer" + }, + "minItems": 1, + "maxItems": 50 + } + }, + "required": [ + "item_id_list" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/product/get_item_extra_info", + "queryParams": { + "item_id_list": "$item_id_list" + } + } + }, + { + "name": "shopee_get_item_models", + "description": "Read the variations (models) of one listing: tier_variation (the option names, e.g. colour and size) and each model with model_id, model_sku, price_info and stock_info_v2 (seller and Shopee stock). The model_id values are what shopee_update_stock and shopee_update_price take.", + "parameters": { + "type": "object", + "properties": { + "item_id": { + "type": "integer", + "description": "Item id of a listing with has_model true." + } + }, + "required": [ + "item_id" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/product/get_model_list", + "queryParams": { + "item_id": "$item_id" + } + } + }, + { + "name": "shopee_update_stock", + "description": "Set the seller stock of a listing or of its models (up to 50 entries). Overwrites the current figure; it cannot be lower than stock already reserved by orders. Installed switched off. Confirm with the user first, showing item, model and new stock.", + "parameters": { + "type": "object", + "properties": { + "item_id": { + "type": "integer", + "description": "Item id." + }, + "stock_list": { + "type": "array", + "description": "One entry per model; model_id 0 for a listing without variations.", + "minItems": 1, + "maxItems": 50, + "items": { + "type": "object", + "properties": { + "model_id": { + "type": "integer", + "description": "Model id from shopee_get_item_models, 0 when the listing has no variations." + }, + "seller_stock": { + "type": "array", + "description": "New stock; one entry, or one per warehouse location when the seller has several.", + "minItems": 1, + "items": { + "type": "object", + "properties": { + "location_id": { + "type": "string", + "description": "Warehouse location id; leave out when the seller has no warehouses." + }, + "stock": { + "type": "integer", + "description": "New stock quantity.", + "minimum": 0 + } + }, + "required": [ + "stock" + ] + } + } + }, + "required": [ + "seller_stock" + ] + } + } + }, + "required": [ + "item_id", + "stock_list" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v2/product/update_stock", + "bodyMapping": { + "item_id": "$item_id", + "stock_list": "$stock_list" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + }, + "enabled": false + }, + { + "name": "shopee_update_price", + "description": "Set the original price of a listing or of its models (up to 50 entries), in the shop's currency. SG, MY, BR, MX, PL, ES and AR take two decimals, other markets whole numbers. Running promotions keep their own price. Installed switched off. Confirm with the user first, showing item, model, old and new price.", + "parameters": { + "type": "object", + "properties": { + "item_id": { + "type": "integer", + "description": "Item id." + }, + "price_list": { + "type": "array", + "description": "One entry per model; model_id 0 for a listing without variations.", + "minItems": 1, + "maxItems": 50, + "items": { + "type": "object", + "properties": { + "model_id": { + "type": "integer", + "description": "Model id from shopee_get_item_models, 0 when the listing has no variations." + }, + "original_price": { + "type": "number", + "description": "New original price.", + "exclusiveMinimum": 0 + } + }, + "required": [ + "original_price" + ] + } + } + }, + "required": [ + "item_id", + "price_list" + ] + }, + "endpointMapping": { + "method": "POST", + "path": "/api/v2/product/update_price", + "bodyMapping": { + "item_id": "$item_id", + "price_list": "$price_list" + } + }, + "annotations": { + "readOnlyHint": false, + "destructiveHint": true, + "idempotentHint": true + }, + "enabled": false + }, + { + "name": "shopee_list_orders", + "description": "List orders created (or updated) in a time window of at most 15 days, newest first, up to 100 per page. Returns order_sn and order_status; read the orders with shopee_get_order_details. Page with cursor: pass next_cursor while more is true. For longer periods, call once per 15-day window.", + "parameters": { + "type": "object", + "properties": { + "time_range_field": { + "type": "string", + "description": "Which time the window applies to.", + "enum": [ + "create_time", + "update_time" + ], + "default": "create_time" + }, + "time_from": { + "type": "integer", + "description": "Start of the window. Unix time in seconds." + }, + "time_to": { + "type": "integer", + "description": "End of the window, at most 15 days after time_from. Unix time in seconds." + }, + "order_status": { + "type": "string", + "description": "Only orders in this status (one value).", + "enum": [ + "UNPAID", + "READY_TO_SHIP", + "PROCESSED", + "SHIPPED", + "COMPLETED", + "IN_CANCEL", + "CANCELLED", + "INVOICE_PENDING" + ] + }, + "page_size": { + "type": "integer", + "description": "Orders per page, 1 to 100.", + "default": 50, + "minimum": 1, + "maximum": 100 + }, + "cursor": { + "type": "string", + "description": "Leave out for the first page; then next_cursor from the previous answer." + }, + "response_optional_fields": { + "type": "string", + "description": "Extra fields per order; order_status is the only one.", + "default": "order_status" + } + }, + "required": [ + "time_range_field", + "time_from", + "time_to", + "page_size" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/order/get_order_list", + "queryParams": { + "time_range_field": "$time_range_field", + "time_from": "$time_from", + "time_to": "$time_to", + "order_status": "$order_status", + "page_size": "$page_size", + "cursor": "$cursor", + "response_optional_fields": "$response_optional_fields" + } + } + }, + { + "name": "shopee_get_order_details", + "description": "Read up to 50 orders: status, buyer, items, amounts, payment method, shipping carrier, packages, recipient address and cancellation details. The default response_optional_fields ask for all of these; Shopee returns only the base fields otherwise.", + "parameters": { + "type": "object", + "properties": { + "order_sn_list": { + "type": "string", + "description": "Order numbers separated by commas, 1 to 50, e.g. 201214JASXYXY6,201214JAJXU6G7." + }, + "response_optional_fields": { + "type": "string", + "description": "Comma-separated extra fields to return (Shopee names, e.g. item_list,total_amount,package_list).", + "default": "buyer_user_id,buyer_username,estimated_shipping_fee,recipient_address,actual_shipping_fee,note,item_list,pay_time,package_list,shipping_carrier,payment_method,total_amount,buyer_cancel_reason,cancel_by,cancel_reason,fulfillment_flag,pickup_done_time" + } + }, + "required": [ + "order_sn_list" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/order/get_order_detail", + "queryParams": { + "order_sn_list": "$order_sn_list", + "response_optional_fields": "$response_optional_fields" + } + }, + "responseMapping": { + "transform": { + "maxBytes": 200000 + } + } + }, + { + "name": "shopee_list_orders_to_ship", + "description": "List the orders waiting to be shipped (status READY_TO_SHIP with a shipping document to arrange), up to 100 per page. Page with cursor while more is true.", + "parameters": { + "type": "object", + "properties": { + "page_size": { + "type": "integer", + "description": "Orders per page, 1 to 100.", + "default": 50, + "minimum": 1, + "maximum": 100 + }, + "cursor": { + "type": "string", + "description": "Leave out for the first page; then next_cursor from the previous answer." + } + }, + "required": [ + "page_size" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/order/get_shipment_list", + "queryParams": { + "page_size": "$page_size", + "cursor": "$cursor" + } + } + }, + { + "name": "shopee_get_shipping_parameter", + "description": "Read how an order can be handed over: info_needed per method, the pickup addresses and time slots, and the drop-off branches. Read only; arranging the shipment is not offered by this connector.", + "parameters": { + "type": "object", + "properties": { + "order_sn": { + "type": "string", + "description": "Order number (order_sn), e.g. 201214JASXYXY6, as returned by shopee_list_orders." + }, + "package_number": { + "type": "string", + "description": "Package number, for an order split into several parcels (package_list in shopee_get_order_details). Leave it out for a single-parcel order." + } + }, + "required": [ + "order_sn" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/logistics/get_shipping_parameter", + "queryParams": { + "order_sn": "$order_sn", + "package_number": "$package_number" + } + } + }, + { + "name": "shopee_get_tracking_number", + "description": "Read the tracking number of a shipped order (available once the shipment is arranged), optionally with first and last mile numbers.", + "parameters": { + "type": "object", + "properties": { + "order_sn": { + "type": "string", + "description": "Order number (order_sn), e.g. 201214JASXYXY6, as returned by shopee_list_orders." + }, + "package_number": { + "type": "string", + "description": "Package number, for an order split into several parcels (package_list in shopee_get_order_details). Leave it out for a single-parcel order." + }, + "response_optional_fields": { + "type": "string", + "description": "Comma-separated extras: plp_number, first_mile_tracking_number, last_mile_tracking_number." + } + }, + "required": [ + "order_sn" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/logistics/get_tracking_number", + "queryParams": { + "order_sn": "$order_sn", + "package_number": "$package_number", + "response_optional_fields": "$response_optional_fields" + } + } + }, + { + "name": "shopee_get_tracking_info", + "description": "Read the logistics status and the tracking history (time, description) of an order or one of its packages.", + "parameters": { + "type": "object", + "properties": { + "order_sn": { + "type": "string", + "description": "Order number (order_sn), e.g. 201214JASXYXY6, as returned by shopee_list_orders." + }, + "package_number": { + "type": "string", + "description": "Package number, for an order split into several parcels (package_list in shopee_get_order_details). Leave it out for a single-parcel order." + } + }, + "required": [ + "order_sn" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/logistics/get_tracking_info", + "queryParams": { + "order_sn": "$order_sn", + "package_number": "$package_number" + } + } + }, + { + "name": "shopee_list_logistics_channels", + "description": "List the shop's logistics channels: id, name, whether each is enabled, fee type, size and weight limits.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/logistics/get_channel_list" + } + }, + { + "name": "shopee_list_returns", + "description": "List return and refund requests, newest first, up to 100 per page, optionally within a creation or update window of at most 15 days. Page with page_no while more is true. Read one with shopee_get_return.", + "parameters": { + "type": "object", + "properties": { + "page_no": { + "type": "integer", + "description": "Page number, starting at 1 as in Shopee's own samples.", + "default": 1, + "minimum": 0 + }, + "page_size": { + "type": "integer", + "description": "Returns per page, up to 100.", + "default": 40, + "minimum": 1, + "maximum": 100 + }, + "create_time_from": { + "type": "integer", + "description": "Only returns created at or after this time. Unix time in seconds." + }, + "create_time_to": { + "type": "integer", + "description": "Only returns created at or before this time, at most 15 days after create_time_from. Unix time in seconds." + }, + "update_time_from": { + "type": "integer", + "description": "Only returns updated at or after this time. Unix time in seconds." + }, + "update_time_to": { + "type": "integer", + "description": "Only returns updated at or before this time, at most 15 days after update_time_from. Unix time in seconds." + }, + "status": { + "type": "string", + "description": "Only returns in this status.", + "enum": [ + "REQUESTED", + "ACCEPTED", + "CANCELLED", + "JUDGING", + "CLOSED", + "PROCESSING", + "SELLER_DISPUTE" + ] + }, + "seller_proof_status": { + "type": "string", + "description": "Only returns whose seller proof is in this state.", + "enum": [ + "NOT_NEEDED", + "PENDING", + "UPLOADED", + "OVERDUE" + ] + } + }, + "required": [ + "page_no", + "page_size" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/returns/get_return_list", + "queryParams": { + "page_no": "$page_no", + "page_size": "$page_size", + "create_time_from": "$create_time_from", + "create_time_to": "$create_time_to", + "update_time_from": "$update_time_from", + "update_time_to": "$update_time_to", + "status": "$status", + "seller_proof_status": "$seller_proof_status" + } + }, + "responseMapping": { + "transform": { + "maxBytes": 200000 + } + } + }, + { + "name": "shopee_get_return", + "description": "Read one return request: reason, buyer text and images, items, refund amount, status, due dates and the linked order_sn.", + "parameters": { + "type": "object", + "properties": { + "return_sn": { + "type": "string", + "description": "Return number (return_sn) from shopee_list_returns." + } + }, + "required": [ + "return_sn" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/returns/get_return_detail", + "queryParams": { + "return_sn": "$return_sn" + } + } + }, + { + "name": "shopee_get_order_income", + "description": "Read what an order earns the seller (escrow detail): buyer payment, Shopee commission and service fees, shipping fee and rebates, vouchers, and the final escrow amount. Read only; final once the order is completed.", + "parameters": { + "type": "object", + "properties": { + "order_sn": { + "type": "string", + "description": "Order number (order_sn), e.g. 201214JASXYXY6, as returned by shopee_list_orders." + } + }, + "required": [ + "order_sn" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/payment/get_escrow_detail", + "queryParams": { + "order_sn": "$order_sn" + } + } + }, + { + "name": "shopee_list_discounts", + "description": "List the shop's discount promotions by status, with discount_id, name, start and end time. Page with page_no (from 1) while more is true.", + "parameters": { + "type": "object", + "properties": { + "discount_status": { + "type": "string", + "description": "Which promotions to list.", + "enum": [ + "upcoming", + "ongoing", + "expired", + "all" + ], + "default": "ongoing" + }, + "page_no": { + "type": "integer", + "description": "Page number, from 1.", + "default": 1, + "minimum": 1 + }, + "page_size": { + "type": "integer", + "description": "Promotions per page, up to 100.", + "default": 50, + "minimum": 1, + "maximum": 100 + } + }, + "required": [ + "discount_status", + "page_no", + "page_size" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/discount/get_discount_list", + "queryParams": { + "discount_status": "$discount_status", + "page_no": "$page_no", + "page_size": "$page_size" + } + } + }, + { + "name": "shopee_get_ads_balance", + "description": "Read the shop's current Shopee Ads credit balance. Needs the Ads permission on the app.", + "parameters": { + "type": "object", + "properties": {} + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/ads/get_total_balance" + } + }, + { + "name": "shopee_get_ads_daily_performance", + "description": "Read the daily performance of all the shop's CPC ads: impressions, clicks, CTR, expense, orders, GMV and ROAS (direct and broad). At most one month per call, within the last 6 months, and start must differ from end. Needs the Ads permission on the app.", + "parameters": { + "type": "object", + "properties": { + "start_date": { + "type": "string", + "description": "First day, DD-MM-YYYY (e.g. 01-10-2026).", + "pattern": "^\\d{2}-\\d{2}-\\d{4}$" + }, + "end_date": { + "type": "string", + "description": "Last day, DD-MM-YYYY, after start_date and at most one month later.", + "pattern": "^\\d{2}-\\d{2}-\\d{4}$" + } + }, + "required": [ + "start_date", + "end_date" + ] + }, + "endpointMapping": { + "method": "GET", + "path": "/api/v2/ads/get_all_cpc_ads_daily_performance", + "queryParams": { + "start_date": "$start_date", + "end_date": "$end_date" + } + } + } + ] +} diff --git a/packages/backend/src/adapters/intl/shopee.live.spec.ts b/packages/backend/src/adapters/intl/shopee.live.spec.ts new file mode 100644 index 00000000..a0334a3e --- /dev/null +++ b/packages/backend/src/adapters/intl/shopee.live.spec.ts @@ -0,0 +1,372 @@ +import axios from 'axios'; +import * as adapter from './shopee.json'; +import { RestEngine } from '../../connectors/engines/rest.engine'; +import { OAuth2TokenService } from '../../connectors/engines/oauth2-token.service'; +import { LoginTokenService } from '../../connectors/engines/login-token.service'; +import { ShopeeTokenService, shopeeBaseString, shopeeSign } from '../../connectors/engines/shopee-token.service'; +import { describeErrorWhenProblems } from '../../connectors/engines/response-error.util'; +import { computeSetupState } from '../../connectors/connector-setup-status.util'; +import { deriveToolAnnotations } from '../../mcp-server/tool-annotations'; +import { applySchemaDefaults } from '../../common/schema-defaults.util'; +import { interpolateConnectorConfig, interpolateDeep } from '../../common/env-interpolation.util'; +import { describeAdapterEnvVars } from '../env-var-meta'; +import { getAdapter, listAdapters } from '../catalog'; + +/** + * Two layers of verification for the Shopee Seller adapter (Open Platform v2): + * + * 1. Static: always runs. Every tool's method, path and query through the + * real RestEngine, checked against Shopee's API reference + * (open.shopee.com, v2 module docs, Oct 2026): GET with parameters in + * the query, POST with a JSON body, the common parameters and the sign + * in the query of both. Array parameters (item_status, item_id_list) go + * as repeated keys, order_sn_list comma-joined, as the reference says. + * + * 2. Live: skipped unless RUN_SHOPEE_LIVE=1 and SHOPEE_HOST, + * SHOPEE_PARTNER_ID, SHOPEE_PARTNER_KEY, SHOPEE_ACCESS_TOKEN and + * SHOPEE_SHOP_ID are set (a sandbox shop works). Reads only. Not run for + * this adapter's first version: no partner account was available. + * + * RUN_SHOPEE_LIVE=1 SHOPEE_HOST=https://openplatform.sandbox.test-stable.shopee.sg ... \ + * npx jest src/adapters/intl/shopee.live.spec.ts + */ + +jest.mock('axios', () => { + const actual = jest.requireActual('axios'); + const mocked = jest.fn(); + return { + __esModule: true, + default: Object.assign(mocked, actual.default, { __actual: actual.default }), + AxiosError: actual.AxiosError, + }; +}); +const mockedAxios = axios as unknown as jest.Mock & { __actual: typeof axios }; + +type Tool = { + name: string; + description: string; + enabled?: boolean; + parameters: { properties?: Record; required?: string[] }; + endpointMapping: { method: string; path: string; queryParams?: Record; bodyMapping?: Record }; + annotations?: Record; +}; +const a = adapter as unknown as { + slug: string; + unlisted?: boolean; + category: string; + icon: string; + instructions: string; + requiredEnvVars: string[]; + envVarMeta: Record; + probe: { tool: string }; + connector: { + baseUrl: string; + authType: string; + authConfig: Record; + headers: Record; + healthcheckPath: string; + config: { errorWhen: unknown }; + }; + tools: Tool[]; +}; +const tool = (name: string) => { + const t = a.tools.find((x) => x.name === name); + if (!t) throw new Error(`missing tool ${name}`); + return t; +}; +const annotationsOf = (t: Tool) => + deriveToolAnnotations({ name: t.name, connectorType: 'REST', endpointMapping: t.endpointMapping, annotations: t.annotations }); + +const HOST = 'https://partner.shopeemobile.com'; +// Built at run time so no literal here looks like a real key. +const PARTNER_KEY = ['shpk', '4a6f', 'test', '0000', 'demo'].join(''); +const ENV = { SHOPEE_HOST: HOST, SHOPEE_PARTNER_ID: '2001887', SHOPEE_PARTNER_KEY: PARTNER_KEY }; +const SHOP = { access_token: 'tok-abc', shop_id: '14701711' }; + +// Mirrors DynamicMcpTools: {{VAR}} interpolated in the address, the mapping and authConfig. +const execute = (name: string, params: Record, env: Record = ENV, shop = SHOP) => { + const { config, endpointMapping } = interpolateConnectorConfig( + { baseUrl: a.connector.baseUrl, headers: a.connector.headers }, + tool(name).endpointMapping, + env, + ); + const tokens = { + getShopParams: jest.fn().mockResolvedValue(shop), + renewAfterRejection: jest.fn().mockResolvedValue(null), + } as unknown as ShopeeTokenService; + return new RestEngine({} as OAuth2TokenService, {} as LoginTokenService, undefined, tokens).execute( + { + baseUrl: config.baseUrl, + authType: a.connector.authType, + authConfig: interpolateDeep(a.connector.authConfig, env), + headers: config.headers, + connectorId: 'conn-shopee', + errorWhen: a.connector.config.errorWhen, + }, + endpointMapping, + applySchemaDefaults(tool(name).parameters, params), + ) as Promise; +}; +const sent = () => mockedAxios.mock.calls[mockedAxios.mock.calls.length - 1][0]; + +const WRITES = ['shopee_update_price', 'shopee_update_stock']; + +describe('shopee adapter: static spec conformance', () => { + beforeEach(() => { + mockedAxios.mockReset(); + mockedAxios.mockResolvedValue({ data: { error: '', message: '', request_id: 'r', response: {} }, headers: {} }); + }); + + it('is listed in e-commerce with its own icon', () => { + expect(a.unlisted).toBeFalsy(); + expect(a.slug).toBe('shopee'); + expect(a.icon).toBe('shopee'); + expect(a.category).toBe('e-commerce'); + expect(a.probe.tool).toBe('shopee_get_shop_info'); + expect(a.connector.healthcheckPath).toBe('/api/v2/shop/get_shop_info'); + }); + + it('signs in the query with the partner key, over partner_id + path + timestamp + token + shop', () => { + expect(a.connector.baseUrl).toBe('{{SHOPEE_HOST}}'); + expect(a.connector.authType).toBe('HMAC'); + expect(a.connector.authConfig).toEqual({ + tokenProvider: 'shopee', + partnerId: '{{SHOPEE_PARTNER_ID}}', + signature: { + algorithm: 'sha256', + encoding: 'hex', + secret: '{{SHOPEE_PARTNER_KEY}}', + placement: 'query', + paramName: 'sign', + timestampParam: 'timestamp', + queryParams: { partner_id: '{{SHOPEE_PARTNER_ID}}' }, + template: '${query.partner_id}${pathname}${timestamp}${query.access_token}${query.shop_id}', + }, + }); + }); + + it('sends the documented common parameters and a sign Shopee recomputes', async () => { + const now = jest.spyOn(Date, 'now').mockReturnValue(1655714431000); + try { + await execute('shopee_get_order_details', { order_sn_list: '201214JASXYXY6,201214JAJXU6G7' }); + } finally { + now.mockRestore(); + } + const cfg = sent(); + expect(cfg.method).toBe('GET'); + expect(cfg.url).toBe(`${HOST}/api/v2/order/get_order_detail`); + expect(cfg.params).toMatchObject({ partner_id: '2001887', timestamp: '1655714431', access_token: 'tok-abc', shop_id: '14701711' }); + expect(cfg.params.sign).toBe( + shopeeSign( + PARTNER_KEY, + shopeeBaseString({ partnerId: 2001887, path: '/api/v2/order/get_order_detail', timestamp: 1655714431, accessToken: 'tok-abc', shopId: 14701711 }), + ), + ); + expect(cfg.params.order_sn_list).toBe('201214JASXYXY6,201214JAJXU6G7'); + expect(cfg.params.response_optional_fields).toContain('item_list'); + expect(JSON.stringify(cfg)).not.toContain(PARTNER_KEY); + expect(cfg.headers.Signature).toBeUndefined(); + }); + + it('asks for the host (Shopee hosts only), the partner id and the key, the key as a secret', () => { + expect(a.requiredEnvVars).toEqual(['SHOPEE_HOST', 'SHOPEE_PARTNER_ID', 'SHOPEE_PARTNER_KEY']); + const host = new RegExp(a.envVarMeta.SHOPEE_HOST.pattern!); + for (const ok of [ + 'https://partner.shopeemobile.com', + 'https://partner.test-stable.shopeemobile.com', + 'https://openplatform.shopee.cn', + 'https://openplatform.shopee.com.br', + 'https://openplatform.sandbox.test-stable.shopee.sg', + 'https://openplatform.sandbox.test-stable.shopee.cn', + 'https://openplatform.test-stable.shopee.cn', + ]) { + expect(host.test(ok)).toBe(true); + } + for (const bad of [ + 'http://partner.shopeemobile.com', + 'https://partner.shopeemobile.com/', + 'https://partner.shopeemobile.com/api/v2', + 'https://partner.shopeemobile.com.evil.example', + 'https://evil.example', + ]) { + expect(host.test(bad)).toBe(false); + } + expect(a.envVarMeta.SHOPEE_PARTNER_KEY.secret).toBe(true); + const described = describeAdapterEnvVars(a as never); + expect(described.find((d) => d.name === 'SHOPEE_PARTNER_KEY')!.secret).toBe(true); + expect(described.find((d) => d.name === 'SHOPEE_PARTNER_ID')!.secret).toBe(false); + expect(new RegExp(a.envVarMeta.SHOPEE_PARTNER_ID.pattern!).test('2001887')).toBe(true); + }); + + it('needs the shop authorized after install, and is ready once it is', () => { + const base = { authType: 'HMAC', baseUrl: a.connector.baseUrl, headers: a.connector.headers, config: { adapterSlug: 'shopee' } }; + expect(computeSetupState({ ...base, authConfig: a.connector.authConfig, envVars: {} }).status).toBe('needs_input'); + expect(computeSetupState({ ...base, authConfig: a.connector.authConfig, envVars: ENV }).status).toBe('needs_authorization'); + expect( + computeSetupState({ + ...base, + authConfig: { ...a.connector.authConfig, accessToken: 'at', refreshToken: 'rt', shopId: '1', expiresAt: 1 }, + envVars: ENV, + }).status, + ).toBe('ready'); + }); + + it('turns an error inside a 200 answer into an error with the right status', async () => { + expect(describeErrorWhenProblems(a.connector.config.errorWhen)).toEqual([]); + const cases: Array<[string, number]> = [ + ['error_sign', 401], + ['invalid_acceess_token', 401], + ['error_permission', 403], + ['error_rate_limit', 429], + ['error_param', 400], + ]; + for (const [code, status] of cases) { + mockedAxios.mockResolvedValueOnce({ data: { error: code, message: `m-${code}`, request_id: 'r' }, headers: {} }); + await expect(execute('shopee_get_shop_profile', {})).rejects.toMatchObject({ status, message: expect.stringContaining(`m-${code}`) }); + } + // Empty error: success. + mockedAxios.mockResolvedValueOnce({ data: { error: '', message: '', response: { shop_name: 'S' } }, headers: {} }); + await expect(execute('shopee_get_shop_profile', {})).resolves.toMatchObject({ response: { shop_name: 'S' } }); + }); + + it('prefixes every tool with shopee_ and shares no tool name with another adapter', () => { + const mine = new Set(a.tools.map((t) => t.name)); + expect(mine.size).toBe(a.tools.length); + for (const name of mine) expect(name).toMatch(/^shopee_[a-z_]+$/); + for (const meta of listAdapters()) { + if (meta.slug === a.slug) continue; + for (const t of getAdapter(meta.slug)!.tools) expect(mine.has(t.name)).toBe(false); + } + }); + + it('reads are read-only; the two writes ask for confirmation and install switched off', () => { + for (const t of a.tools) { + const read = t.endpointMapping.method === 'GET'; + expect(`${t.name}:${annotationsOf(t).readOnlyHint === true}`).toBe(`${t.name}:${read}`); + } + expect(a.tools.filter((t) => t.endpointMapping.method !== 'GET').map((t) => t.name).sort()).toEqual(WRITES); + expect(a.tools.filter((t) => t.enabled === false).map((t) => t.name).sort()).toEqual(WRITES); + for (const name of WRITES) { + expect(tool(name).description).toMatch(/confirm with the user first/i); + expect(annotationsOf(tool(name)).destructiveHint).toBe(true); + } + // Nothing that ships, cancels, refunds, replies or pays. + const forbidden = /ship_order|cancel_order|handle_buyer_cancellation|accept_offer|confirm|refund|dispute|send_message|withdraw|delete|unlist_item|add_item/; + for (const t of a.tools) expect(t.endpointMapping.path).not.toMatch(forbidden); + }); + + it('only points the model at tools that exist, writes no em dashes and names no third party', () => { + const names = new Set(a.tools.map((t) => t.name)); + const mentioned = [ + ...a.instructions.matchAll(/\bshopee_[a-z_]+/g), + ...a.tools.flatMap((t) => [...t.description.matchAll(/\bshopee_[a-z_]+/g)]), + ].map((m) => m[0]); + expect(mentioned.length).toBeGreaterThan(15); + for (const name of mentioned) expect(names).toContain(name); + expect(a.instructions.startsWith('## Shopee Seller\n')).toBe(true); + for (const text of ['Partner ID', 'Partner Key', 'Redirect URL Domain', 'Authorize with Provider', 'sandbox', 'openplatform.shopee.cn', '/api/shopee/callback/']) { + expect(a.instructions).toContain(text); + } + const all = JSON.stringify(adapter); + expect(all).not.toMatch(/[–—]/); + expect(all).not.toMatch(/pipedream|github\.com|sdk/i); + }); + + // [tool, args, method, path] from the Shopee Open Platform v2 API reference. + const table: Array<[string, Record, string, string]> = [ + ['shopee_get_shop_info', {}, 'GET', '/api/v2/shop/get_shop_info'], + ['shopee_get_shop_profile', {}, 'GET', '/api/v2/shop/get_profile'], + ['shopee_list_items', {}, 'GET', '/api/v2/product/get_item_list'], + ['shopee_search_items', { item_name: 'mug' }, 'GET', '/api/v2/product/search_item'], + ['shopee_get_items', { item_id_list: [1, 2] }, 'GET', '/api/v2/product/get_item_base_info'], + ['shopee_get_items_extra_info', { item_id_list: [1] }, 'GET', '/api/v2/product/get_item_extra_info'], + ['shopee_get_item_models', { item_id: 1 }, 'GET', '/api/v2/product/get_model_list'], + ['shopee_update_stock', { item_id: 1, stock_list: [{ model_id: 0, seller_stock: [{ stock: 3 }] }] }, 'POST', '/api/v2/product/update_stock'], + ['shopee_update_price', { item_id: 1, price_list: [{ model_id: 0, original_price: 9.9 }] }, 'POST', '/api/v2/product/update_price'], + ['shopee_list_orders', { time_from: 1, time_to: 2 }, 'GET', '/api/v2/order/get_order_list'], + ['shopee_get_order_details', { order_sn_list: 'A1' }, 'GET', '/api/v2/order/get_order_detail'], + ['shopee_list_orders_to_ship', {}, 'GET', '/api/v2/order/get_shipment_list'], + ['shopee_get_shipping_parameter', { order_sn: 'A1' }, 'GET', '/api/v2/logistics/get_shipping_parameter'], + ['shopee_get_tracking_number', { order_sn: 'A1' }, 'GET', '/api/v2/logistics/get_tracking_number'], + ['shopee_get_tracking_info', { order_sn: 'A1' }, 'GET', '/api/v2/logistics/get_tracking_info'], + ['shopee_list_logistics_channels', {}, 'GET', '/api/v2/logistics/get_channel_list'], + ['shopee_list_returns', {}, 'GET', '/api/v2/returns/get_return_list'], + ['shopee_get_return', { return_sn: 'R1' }, 'GET', '/api/v2/returns/get_return_detail'], + ['shopee_get_order_income', { order_sn: 'A1' }, 'GET', '/api/v2/payment/get_escrow_detail'], + ['shopee_list_discounts', {}, 'GET', '/api/v2/discount/get_discount_list'], + ['shopee_get_ads_balance', {}, 'GET', '/api/v2/ads/get_total_balance'], + ['shopee_get_ads_daily_performance', { start_date: '01-10-2026', end_date: '15-10-2026' }, 'GET', '/api/v2/ads/get_all_cpc_ads_daily_performance'], + ]; + + it('lists every tool in the URL table', () => { + expect(table.map(([name]) => name).sort()).toEqual(a.tools.map((t) => t.name).sort()); + }); + + it.each(table)('%s sends %s to %s, signed over that path', async (name, params, method, path) => { + await execute(name, params); + const cfg = sent(); + expect(cfg.method).toBe(method); + expect(cfg.url).toBe(`${HOST}${path}`); + expect(cfg.params.sign).toBe( + shopeeSign(PARTNER_KEY, `2001887${path}${cfg.params.timestamp}tok-abc14701711`), + ); + if (method === 'GET') expect(cfg.data).toBeUndefined(); + }); + + it('pages listings with repeated item_status keys and the documented defaults', async () => { + await execute('shopee_list_items', {}); + const cfg = sent(); + expect(cfg.params).toMatchObject({ offset: 0, page_size: 50, item_status: ['NORMAL'] }); + expect(cfg.paramsSerializer({ item_status: ['NORMAL', 'UNLIST'] })).toBe('item_status=NORMAL&item_status=UNLIST'); + await execute('shopee_get_items', { item_id_list: [34001, 34002] }); + expect(sent().paramsSerializer(sent().params)).toMatch(/^item_id_list=34001&item_id_list=34002&/); + }); + + it('asks for orders in a create_time window with the status field, and returns pages from 1', async () => { + await execute('shopee_list_orders', { time_from: 1760000000, time_to: 1761000000 }); + expect(sent().params).toMatchObject({ + time_range_field: 'create_time', + time_from: 1760000000, + time_to: 1761000000, + page_size: 50, + response_optional_fields: 'order_status', + }); + await execute('shopee_list_returns', {}); + expect(sent().params).toMatchObject({ page_no: 1, page_size: 40 }); + await execute('shopee_list_discounts', {}); + expect(sent().params).toMatchObject({ discount_status: 'ongoing', page_no: 1, page_size: 50 }); + }); + + it('sends stock and price updates as the documented JSON bodies', async () => { + const stock_list = [{ model_id: 2001, seller_stock: [{ location_id: 'SGZ', stock: 7 }] }]; + await execute('shopee_update_stock', { item_id: 800000001, stock_list }); + expect(sent().data).toEqual({ item_id: 800000001, stock_list }); + const price_list = [{ model_id: 0, original_price: 12.5 }]; + await execute('shopee_update_price', { item_id: 800000001, price_list }); + expect(sent().data).toEqual({ item_id: 800000001, price_list }); + }); +}); + +const LIVE = process.env.RUN_SHOPEE_LIVE === '1' && process.env.SHOPEE_ACCESS_TOKEN && process.env.SHOPEE_SHOP_ID; +const live = LIVE ? describe : describe.skip; + +live('shopee adapter: live API (reads only)', () => { + beforeAll(() => { + mockedAxios.mockImplementation((cfg: unknown) => mockedAxios.__actual(cfg as any)); + }); + const env = () => ({ + SHOPEE_HOST: String(process.env.SHOPEE_HOST), + SHOPEE_PARTNER_ID: String(process.env.SHOPEE_PARTNER_ID), + SHOPEE_PARTNER_KEY: String(process.env.SHOPEE_PARTNER_KEY), + }); + const shop = () => ({ access_token: String(process.env.SHOPEE_ACCESS_TOKEN), shop_id: String(process.env.SHOPEE_SHOP_ID) }); + + it('reads the shop, its listings and its channels', async () => { + const info = await execute('shopee_get_shop_info', {}, env(), shop()); + expect(info.error).toBe(''); + const items = await execute('shopee_list_items', { page_size: 5 }, env(), shop()); + expect(items.response).toBeDefined(); + const channels = await execute('shopee_list_logistics_channels', {}, env(), shop()); + expect(channels.response).toBeDefined(); + }, 60_000); +}); diff --git a/packages/frontend/public/logos/connectors/shopee.svg b/packages/frontend/public/logos/connectors/shopee.svg new file mode 100644 index 00000000..43f46b5f --- /dev/null +++ b/packages/frontend/public/logos/connectors/shopee.svg @@ -0,0 +1 @@ +Shopee diff --git a/server.json b/server.json index 720ffac1..e66073ce 100644 --- a/server.json +++ b/server.json @@ -2,7 +2,7 @@ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", "name": "io.github.HelpCode-ai/anythingmcp", "title": "AnythingMCP", - "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 486 connectors: SAP, ERP.", + "description": "Any REST/SOAP/GraphQL/OData/SQL API as MCP tools for Claude & ChatGPT. 487 connectors: SAP, ERP.", "repository": { "url": "https://github.com/HelpCode-ai/anythingmcp", "source": "github" From eb3affee223d1f8bcc48bd76085a4073c667bc9f Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:39:44 +0200 Subject: [PATCH 4/4] Check the Shopee completion's user before consuming the flow Another signed-in user holding the state could burn the owner's pending authorization. Peek first, refuse a different user, then take it once. --- .../connectors/shopee-auth.controller.spec.ts | 9 +++++++-- .../src/connectors/shopee-auth.controller.ts | 18 +++++++++++++----- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/backend/src/connectors/shopee-auth.controller.spec.ts b/packages/backend/src/connectors/shopee-auth.controller.spec.ts index 6f92d15e..cbb1c0d2 100644 --- a/packages/backend/src/connectors/shopee-auth.controller.spec.ts +++ b/packages/backend/src/connectors/shopee-auth.controller.spec.ts @@ -121,9 +121,14 @@ describe('ShopeeAuthController', () => { await expect( controller.complete({ user: { sub: 'user-2' } }, { state: s1, code: 'c', shopId: '14701711' }), ).rejects.toBeInstanceOf(ForbiddenException); - // The link someone else started is dead now. - await expect(controller.complete(admin, { state: s1, code: 'c', shopId: '14701711' })).rejects.toBeInstanceOf(GoneException); expect(tokens.exchangeCode).not.toHaveBeenCalled(); + // Another user's refusal does not burn the owner's authorization. + expect(await flows.getPendingFlow(s1)).toBeDefined(); + await controller.complete(admin, { state: s1, code: 'c-1', shopId: '14701711' }); + // Single use: the same state cannot complete twice. + await expect(controller.complete(admin, { state: s1, code: 'c-1', shopId: '14701711' })).rejects.toBeInstanceOf(GoneException); + (tokens.exchangeCode as jest.Mock).mockClear(); + (mcpServer.reloadConnectorTools as jest.Mock).mockClear(); const s2 = await start(); expect(await controller.complete(admin, { state: s2, code: 'c-2', shopId: '14701711' })).toEqual({ diff --git a/packages/backend/src/connectors/shopee-auth.controller.ts b/packages/backend/src/connectors/shopee-auth.controller.ts index 84961e29..4f5fe2e6 100644 --- a/packages/backend/src/connectors/shopee-auth.controller.ts +++ b/packages/backend/src/connectors/shopee-auth.controller.ts @@ -191,17 +191,25 @@ export class ShopeeAuthController { @Req() req: any, @Body() body: { state?: string; code?: string; shopId?: string }, ): Promise<{ connectorId: string; toolsImported: number; returnTo?: string }> { - const record = await this.oauthFlows.takePendingFlow(String(body?.state || '')); - if (!record || record.flow.provider !== 'shopee') { + const state = String(body?.state || ''); + // Check who started the flow before consuming it: another signed-in user + // holding the state must not be able to burn the owner's authorization. + const pending = await this.oauthFlows.getPendingFlow(state); + if (!pending || pending.flow.provider !== 'shopee') { throw new GoneException('This authorization expired or was already used. Start it again from the connector.'); } - const { flow, returnTo } = record; - if (flow.userId !== req.user?.sub) { - this.logger.warn(`Refused Shopee completion: connector ${flow.connectorId} was authorized by another user`); + if (pending.flow.userId !== req.user?.sub) { + this.logger.warn(`Refused Shopee completion: connector ${pending.flow.connectorId} was authorized by another user`); throw new ForbiddenException( 'This authorization was started by another account. Sign in as that account, or start it again yourself.', ); } + // Single use: a second request racing this one finds nothing to take. + const record = await this.oauthFlows.takePendingFlow(state); + if (!record || record.flow.provider !== 'shopee' || record.flow.userId !== req.user?.sub) { + throw new GoneException('This authorization expired or was already used. Start it again from the connector.'); + } + const { flow, returnTo } = record; const code = typeof body?.code === 'string' ? body.code : ''; const shopId = typeof body?.shopId === 'string' ? body.shopId : ''; if (!code || !/^\d{1,20}$/.test(shopId)) {