Skip to content

Commit a3cfcd1

Browse files
committed
Implement Amazon Associates PA-API adapter
1 parent 3cf0cc5 commit a3cfcd1

2 files changed

Lines changed: 316 additions & 29 deletions

File tree

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,125 @@
11
import { smokeTest } from '@profullstack/sh1pt-core/testing';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
23
import adapter from './index.js';
34

45
smokeTest(adapter, { idPrefix: 'affiliate' });
6+
7+
const ctx = (secrets: Record<string, string> = { AMAZON_PAAPI_SECRET: 'amazon-secret' }) => ({
8+
secret: (key: string) => secrets[key],
9+
log: () => {},
10+
});
11+
12+
describe('Amazon Associates adapter', () => {
13+
afterEach(() => {
14+
vi.unstubAllGlobals();
15+
vi.useRealTimers();
16+
});
17+
18+
it('requires PA-API credentials before probing the API', async () => {
19+
await expect(adapter.connect(ctx({}), {
20+
accessKey: 'AKIA_TEST',
21+
partnerTag: 'example-20',
22+
})).rejects.toThrow('AMAZON_PAAPI_SECRET not in vault');
23+
await expect(adapter.connect(ctx(), {
24+
partnerTag: 'example-20',
25+
})).rejects.toThrow('Amazon PA-API accessKey is required');
26+
await expect(adapter.connect(ctx(), {
27+
accessKey: 'AKIA_TEST',
28+
})).rejects.toThrow('Amazon Associates partnerTag is required');
29+
});
30+
31+
it('signs a PA-API SearchItems probe during connect', async () => {
32+
vi.useFakeTimers();
33+
vi.setSystemTime(new Date('2026-05-21T03:00:00.000Z'));
34+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ SearchResult: { Items: [] } }));
35+
vi.stubGlobal('fetch', fetchMock);
36+
37+
await expect(adapter.connect(ctx(), {
38+
accessKey: 'AKIA_TEST',
39+
partnerTag: 'example-20',
40+
})).resolves.toEqual({ accountId: 'example-20' });
41+
42+
const [url, request] = fetchMock.mock.calls[0]!;
43+
expect(String(url)).toBe('https://webservices.amazon.com/paapi5/searchitems');
44+
expect(request.method).toBe('POST');
45+
expect(request.headers['content-encoding']).toBe('amz-1.0');
46+
expect(request.headers['content-type']).toBe('application/json; charset=utf-8');
47+
expect(request.headers['x-amz-date']).toBe('20260521T030000Z');
48+
expect(request.headers['x-amz-target']).toBe(
49+
'com.amazon.paapi5.v1.ProductAdvertisingAPIv1.SearchItems',
50+
);
51+
expect(request.headers.authorization).toContain(
52+
'Credential=AKIA_TEST/20260521/us-east-1/ProductAdvertisingAPI/aws4_request',
53+
);
54+
expect(request.headers.authorization).toContain(
55+
'SignedHeaders=content-encoding;content-type;host;x-amz-date;x-amz-target',
56+
);
57+
expect(request.headers.authorization).not.toContain('amazon-secret');
58+
expect(JSON.parse(request.body)).toMatchObject({
59+
Keywords: 'sh1pt',
60+
PartnerTag: 'example-20',
61+
PartnerType: 'Associates',
62+
Marketplace: 'www.amazon.com',
63+
});
64+
});
65+
66+
it('builds Amazon tagged links from an ASIN or supplied destination URL', async () => {
67+
await expect(adapter.getTrackingLink?.(ctx(), 'B08TEST123', '', {
68+
partnerTag: 'example-20',
69+
subtag: 'spring campaign!',
70+
})).resolves.toEqual({
71+
url: 'https://www.amazon.com/dp/B08TEST123?tag=example-20&ascsubtag=spring_campaign_',
72+
});
73+
74+
await expect(adapter.getTrackingLink?.(
75+
ctx(),
76+
'B08TEST123',
77+
'https://www.amazon.co.uk/dp/B08TEST123?tag=existing-21',
78+
{ partnerTag: 'example-20' },
79+
)).resolves.toEqual({
80+
url: 'https://www.amazon.co.uk/dp/B08TEST123?tag=existing-21',
81+
});
82+
});
83+
84+
it('supports custom PA-API hosts, regions, and marketplaces', async () => {
85+
vi.useFakeTimers();
86+
vi.setSystemTime(new Date('2026-05-21T03:00:00.000Z'));
87+
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ SearchResult: { Items: [] } }));
88+
vi.stubGlobal('fetch', fetchMock);
89+
90+
await adapter.connect(ctx(), {
91+
accessKey: 'AKIA_TEST',
92+
apiHost: 'webservices.amazon.co.uk',
93+
marketplaceHost: 'www.amazon.co.uk',
94+
partnerTag: 'example-21',
95+
region: 'eu-west-1',
96+
});
97+
98+
const [url, request] = fetchMock.mock.calls[0]!;
99+
expect(String(url)).toBe('https://webservices.amazon.co.uk/paapi5/searchitems');
100+
expect(request.headers.authorization).toContain(
101+
'Credential=AKIA_TEST/20260521/eu-west-1/ProductAdvertisingAPI/aws4_request',
102+
);
103+
expect(JSON.parse(request.body).Marketplace).toBe('www.amazon.co.uk');
104+
});
105+
106+
it('redacts PA-API credentials from provider error excerpts', async () => {
107+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
108+
ok: false,
109+
status: 403,
110+
text: async () => 'invalid AKIA_TEST / amazon-secret credentials',
111+
}));
112+
113+
await expect(adapter.connect(ctx(), {
114+
accessKey: 'AKIA_TEST',
115+
partnerTag: 'example-20',
116+
})).rejects.toThrow('invalid [redacted] / [redacted] credentials');
117+
});
118+
});
119+
120+
function jsonResponse(body: unknown) {
121+
return {
122+
ok: true,
123+
json: async () => body,
124+
};
125+
}
Lines changed: 195 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,212 @@
1-
import { defineAffiliate, tokenSetup } from '@profullstack/sh1pt-core';
1+
import { createHash, createHmac } from 'node:crypto';
2+
import { defineAffiliate, tokenSetup, type AffiliateConnectContext } from '@profullstack/sh1pt-core';
23

34
interface Config {
5+
accessKey?: string;
46
accountId?: string;
7+
apiHost?: string;
8+
marketplace?: string;
9+
marketplaceHost?: string;
10+
partnerTag?: string;
11+
region?: string;
12+
subtag?: string;
513
}
614

15+
const DEFAULT_API_HOST = 'webservices.amazon.com';
16+
const DEFAULT_MARKETPLACE_HOST = 'www.amazon.com';
17+
const DEFAULT_REGION = 'us-east-1';
18+
const SERVICE = 'ProductAdvertisingAPI';
19+
720
export default defineAffiliate<Config>({
8-
id: "affiliate-amazon-associates",
9-
label: "Amazon Associates / PAAPI",
10-
side: "publisher",
21+
id: 'affiliate-amazon-associates',
22+
label: 'Amazon Associates / PA-API',
23+
side: 'publisher',
1124

1225
async connect(ctx, config) {
13-
const token = ctx.secret("AMAZON_PAAPI_SECRET");
14-
if (!token) throw new Error("AMAZON_PAAPI_SECRET not in vault — run `sh1pt promote affiliates setup`");
15-
return { accountId: config.accountId ?? "affiliate-amazon-associates" };
16-
},
17-
18-
async createProgram(ctx, program) {
19-
ctx.log(`[stub] ${"affiliate-amazon-associates"} createProgram name=${program.name} commission=${program.commissionRate}${program.commissionType === 'percentage' ? '%' : ''}`);
20-
return {
21-
programId: `stub-${Date.now()}`,
22-
marketplaceUrl: "https://affiliate-program.amazon.com",
23-
};
24-
},
25-
26-
async getTrackingLink(ctx, programId, destinationUrl) {
27-
ctx.log(`[stub] ${"affiliate-amazon-associates"} getTrackingLink program=${programId}`);
28-
return { url: destinationUrl };
26+
const partnerTag = requirePartnerTag(config);
27+
await paapiRequest(ctx, config, 'SearchItems', {
28+
Keywords: 'sh1pt',
29+
SearchIndex: 'All',
30+
ItemCount: 1,
31+
PartnerTag: partnerTag,
32+
PartnerType: 'Associates',
33+
Marketplace: marketplace(config),
34+
Resources: ['ItemInfo.Title'],
35+
});
36+
return { accountId: config.accountId ?? partnerTag };
2937
},
3038

31-
async stats(ctx, programId) {
32-
ctx.log(`[stub] ${"affiliate-amazon-associates"} stats program=${programId}`);
33-
return { publishers: 0, clicks: 0, conversions: 0, revenue: 0, commissionsPaid: 0, currency: 'USD' };
39+
async getTrackingLink(ctx, programId, destinationUrl, config) {
40+
const partnerTag = requirePartnerTag(config);
41+
ctx.log(`amazon associates link · asin=${programId}`);
42+
const url = new URL(destinationUrl || `https://${marketplaceHost(config)}/dp/${encodeURIComponent(programId)}`);
43+
if (!url.searchParams.has('tag')) url.searchParams.set('tag', partnerTag);
44+
const subtag = cleanSubtag(config.subtag);
45+
if (subtag && !url.searchParams.has('ascsubtag')) url.searchParams.set('ascsubtag', subtag);
46+
return { url: url.toString() };
3447
},
3548

3649
setup: tokenSetup<Config>({
37-
secretKey: "AMAZON_PAAPI_SECRET",
38-
label: "Amazon Associates / PAAPI",
39-
vendorDocUrl: "https://affiliate-program.amazon.com",
50+
secretKey: 'AMAZON_PAAPI_SECRET',
51+
label: 'Amazon Associates / PA-API',
52+
vendorDocUrl: 'https://webservices.amazon.com/paapi5/documentation/',
4053
steps: [
41-
"Join affiliate-program.amazon.com (per region)",
42-
"Apply for the Product Advertising API (requires 3 sales)",
43-
"Generate access key + secret in IAM; paste the secret below",
54+
'Join Amazon Associates for the target marketplace',
55+
'Apply for Product Advertising API access and create access credentials',
56+
'Paste the PA-API secret key below; enter the access key and partner tag as fields',
57+
],
58+
fields: [
59+
{
60+
key: 'accessKey',
61+
message: 'Amazon PA-API access key:',
62+
},
63+
{
64+
key: 'partnerTag',
65+
message: 'Amazon Associates partner tag, for example example-20:',
66+
},
67+
{
68+
key: 'marketplaceHost',
69+
message: 'Marketplace host for tagged links (default: www.amazon.com):',
70+
},
4471
],
4572
}),
4673
});
74+
75+
async function paapiRequest(
76+
ctx: AffiliateConnectContext,
77+
config: Config,
78+
operation: 'SearchItems' | 'GetItems',
79+
payload: Record<string, unknown>,
80+
): Promise<unknown> {
81+
const accessKey = requireValue(config.accessKey, 'Amazon PA-API accessKey is required');
82+
const secretKey = requireValue(ctx.secret('AMAZON_PAAPI_SECRET'), 'AMAZON_PAAPI_SECRET not in vault');
83+
const apiHost = config.apiHost ?? DEFAULT_API_HOST;
84+
const region = config.region ?? DEFAULT_REGION;
85+
const path = `/paapi5/${operation.toLowerCase()}`;
86+
const body = JSON.stringify(payload);
87+
const now = new Date();
88+
const amzDate = amzTimestamp(now);
89+
const dateStamp = amzDate.slice(0, 8);
90+
const target = `com.amazon.paapi5.v1.ProductAdvertisingAPIv1.${operation}`;
91+
const headers = signedHeaders({
92+
accessKey,
93+
apiHost,
94+
amzDate,
95+
body,
96+
dateStamp,
97+
path,
98+
region,
99+
secretKey,
100+
target,
101+
});
102+
const res = await fetch(`https://${apiHost}${path}`, {
103+
method: 'POST',
104+
headers,
105+
body,
106+
});
107+
if (!res.ok) {
108+
const text = await res.text();
109+
throw new Error(`Amazon PA-API ${res.status}: ${redact(text, [accessKey, secretKey]).slice(0, 200)}`);
110+
}
111+
return res.json();
112+
}
113+
114+
function signedHeaders(input: {
115+
accessKey: string;
116+
apiHost: string;
117+
amzDate: string;
118+
body: string;
119+
dateStamp: string;
120+
path: string;
121+
region: string;
122+
secretKey: string;
123+
target: string;
124+
}): Record<string, string> {
125+
const canonicalHeaders = [
126+
'content-encoding:amz-1.0',
127+
'content-type:application/json; charset=utf-8',
128+
`host:${input.apiHost}`,
129+
`x-amz-date:${input.amzDate}`,
130+
`x-amz-target:${input.target}`,
131+
'',
132+
].join('\n');
133+
const signedHeaderNames = 'content-encoding;content-type;host;x-amz-date;x-amz-target';
134+
const canonicalRequest = [
135+
'POST',
136+
input.path,
137+
'',
138+
canonicalHeaders,
139+
signedHeaderNames,
140+
sha256(input.body),
141+
].join('\n');
142+
const credentialScope = `${input.dateStamp}/${input.region}/${SERVICE}/aws4_request`;
143+
const stringToSign = [
144+
'AWS4-HMAC-SHA256',
145+
input.amzDate,
146+
credentialScope,
147+
sha256(canonicalRequest),
148+
].join('\n');
149+
const signature = hmacHex(signingKey(input.secretKey, input.dateStamp, input.region), stringToSign);
150+
return {
151+
accept: 'application/json',
152+
'content-encoding': 'amz-1.0',
153+
'content-type': 'application/json; charset=utf-8',
154+
host: input.apiHost,
155+
'x-amz-date': input.amzDate,
156+
'x-amz-target': input.target,
157+
authorization: [
158+
`AWS4-HMAC-SHA256 Credential=${input.accessKey}/${credentialScope}`,
159+
`SignedHeaders=${signedHeaderNames}`,
160+
`Signature=${signature}`,
161+
].join(', '),
162+
};
163+
}
164+
165+
function signingKey(secretKey: string, dateStamp: string, region: string): Buffer {
166+
const kDate = hmac(Buffer.from(`AWS4${secretKey}`, 'utf8'), dateStamp);
167+
const kRegion = hmac(kDate, region);
168+
const kService = hmac(kRegion, SERVICE);
169+
return hmac(kService, 'aws4_request');
170+
}
171+
172+
function hmac(key: Buffer | string, data: string): Buffer {
173+
return createHmac('sha256', key).update(data, 'utf8').digest();
174+
}
175+
176+
function hmacHex(key: Buffer | string, data: string): string {
177+
return createHmac('sha256', key).update(data, 'utf8').digest('hex');
178+
}
179+
180+
function sha256(value: string): string {
181+
return createHash('sha256').update(value, 'utf8').digest('hex');
182+
}
183+
184+
function amzTimestamp(date: Date): string {
185+
return date.toISOString().replace(/[:-]|\.\d{3}/g, '');
186+
}
187+
188+
function marketplace(config: Config): string {
189+
return `www.${marketplaceHost(config).replace(/^www\./, '')}`;
190+
}
191+
192+
function marketplaceHost(config: Config): string {
193+
return config.marketplaceHost ?? config.marketplace ?? DEFAULT_MARKETPLACE_HOST;
194+
}
195+
196+
function requirePartnerTag(config: Config): string {
197+
return requireValue(config.partnerTag ?? config.accountId, 'Amazon Associates partnerTag is required');
198+
}
199+
200+
function requireValue(value: string | undefined, message: string): string {
201+
if (!value) throw new Error(message);
202+
return value;
203+
}
204+
205+
function cleanSubtag(value: string | undefined): string | undefined {
206+
if (!value) return undefined;
207+
return value.replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 100);
208+
}
209+
210+
function redact(value: string, secrets: string[]): string {
211+
return secrets.reduce((text, secret) => text.split(secret).join('[redacted]'), value);
212+
}

0 commit comments

Comments
 (0)