@jytextiles/medusa-plugin-faire-store-sync 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.medusa/server/src/__tests__/ingest-faire-order-support.spec.js +74 -0
- package/.medusa/server/src/__tests__/webhook.spec.js +77 -0
- package/.medusa/server/src/admin/index.js +960 -0
- package/.medusa/server/src/admin/index.mjs +959 -0
- package/.medusa/server/src/api/admin/faire/auth/authorize/route.js +12 -0
- package/.medusa/server/src/api/admin/faire/auth/callback/route.js +25 -0
- package/.medusa/server/src/api/admin/faire/auth/disconnect/route.js +12 -0
- package/.medusa/server/src/api/admin/faire/brand/route.js +14 -0
- package/.medusa/server/src/api/admin/faire/ingest/orders/[batchId]/route.js +27 -0
- package/.medusa/server/src/api/admin/faire/ingest/orders/route.js +20 -0
- package/.medusa/server/src/api/admin/faire/products/route.js +16 -0
- package/.medusa/server/src/api/admin/faire/settings/route.js +19 -0
- package/.medusa/server/src/api/admin/faire/status/product/[id]/route.js +30 -0
- package/.medusa/server/src/api/admin/faire/status/route.js +46 -0
- package/.medusa/server/src/api/admin/faire/sync/bulk/[batchId]/route.js +29 -0
- package/.medusa/server/src/api/admin/faire/sync/bulk/route.js +20 -0
- package/.medusa/server/src/api/admin/faire/sync/product/[id]/route.js +14 -0
- package/.medusa/server/src/api/admin/faire/syncs/[id]/route.js +32 -0
- package/.medusa/server/src/api/admin/faire/syncs/route.js +18 -0
- package/.medusa/server/src/api/webhooks/faire/route.js +125 -0
- package/.medusa/server/src/jobs/pull-faire-orders.js +32 -0
- package/.medusa/server/src/jobs/refresh-faire-token.js +25 -0
- package/.medusa/server/src/lib/faire-client.js +321 -0
- package/.medusa/server/src/lib/types.js +10 -0
- package/.medusa/server/src/lib/webhook.js +58 -0
- package/.medusa/server/src/links/product-faire-sync-link.js +57 -0
- package/.medusa/server/src/modules/faire-sync/index.js +15 -0
- package/.medusa/server/src/modules/faire-sync/migrations/Migration20260707211346.js +34 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-order.js +21 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-account.js +17 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-batch.js +18 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-record.js +22 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-sync-settings.js +26 -0
- package/.medusa/server/src/modules/faire-sync/models/faire-webhook-event.js +21 -0
- package/.medusa/server/src/modules/faire-sync/service.js +222 -0
- package/.medusa/server/src/subscribers/faire-inventory-sync.js +87 -0
- package/.medusa/server/src/subscribers/faire-listing-events.js +46 -0
- package/.medusa/server/src/subscribers/faire-order-ingest.js +54 -0
- package/.medusa/server/src/subscribers/faire-order-lifecycle.js +71 -0
- package/.medusa/server/src/subscribers/faire-product-status-sync.js +71 -0
- package/.medusa/server/src/workflows/index.js +21 -0
- package/.medusa/server/src/workflows/ingest-faire-order-support.js +76 -0
- package/.medusa/server/src/workflows/ingest-faire-order.js +148 -0
- package/.medusa/server/src/workflows/ingest-faire-orders-bulk.js +124 -0
- package/.medusa/server/src/workflows/refresh-faire-token.js +20 -0
- package/.medusa/server/src/workflows/sync-product-to-faire.js +262 -0
- package/.medusa/server/src/workflows/sync-products-to-faire.js +90 -0
- package/LICENSE +21 -0
- package/README.md +180 -0
- package/package.json +84 -0
- package/scripts/postbuild.js +36 -0
- package/src/__tests__/ingest-faire-order-support.spec.ts +77 -0
- package/src/__tests__/webhook.spec.ts +81 -0
- package/src/admin/lib/api.ts +88 -0
- package/src/admin/routes/settings/faire/[id]/page.tsx +184 -0
- package/src/admin/routes/settings/faire/hooks/use-faire-sync-columns.tsx +80 -0
- package/src/admin/routes/settings/faire/page.tsx +445 -0
- package/src/admin/routes/settings/faire/settings/page.tsx +286 -0
- package/src/admin/routes/settings/oauth/faire/callback/page.tsx +67 -0
- package/src/admin/widgets/faire-product-widget.tsx +180 -0
- package/src/api/admin/faire/auth/authorize/route.ts +10 -0
- package/src/api/admin/faire/auth/callback/route.ts +29 -0
- package/src/api/admin/faire/auth/disconnect/route.ts +10 -0
- package/src/api/admin/faire/brand/route.ts +12 -0
- package/src/api/admin/faire/ingest/orders/[batchId]/route.ts +31 -0
- package/src/api/admin/faire/ingest/orders/route.ts +29 -0
- package/src/api/admin/faire/products/route.ts +14 -0
- package/src/api/admin/faire/settings/route.ts +17 -0
- package/src/api/admin/faire/status/product/[id]/route.ts +30 -0
- package/src/api/admin/faire/status/route.ts +47 -0
- package/src/api/admin/faire/sync/bulk/[batchId]/route.ts +33 -0
- package/src/api/admin/faire/sync/bulk/route.ts +27 -0
- package/src/api/admin/faire/sync/product/[id]/route.ts +13 -0
- package/src/api/admin/faire/syncs/[id]/route.ts +32 -0
- package/src/api/admin/faire/syncs/route.ts +17 -0
- package/src/api/webhooks/faire/route.ts +133 -0
- package/src/jobs/pull-faire-orders.ts +30 -0
- package/src/jobs/refresh-faire-token.ts +23 -0
- package/src/lib/faire-client.ts +436 -0
- package/src/lib/types.ts +117 -0
- package/src/lib/webhook.ts +85 -0
- package/src/links/product-faire-sync-link.ts +56 -0
- package/src/modules/faire-sync/index.ts +11 -0
- package/src/modules/faire-sync/migrations/.snapshot-medusa-faire-sync.json +1486 -0
- package/src/modules/faire-sync/migrations/Migration20260707211346.ts +43 -0
- package/src/modules/faire-sync/models/faire-order.ts +20 -0
- package/src/modules/faire-sync/models/faire-sync-account.ts +16 -0
- package/src/modules/faire-sync/models/faire-sync-batch.ts +17 -0
- package/src/modules/faire-sync/models/faire-sync-record.ts +21 -0
- package/src/modules/faire-sync/models/faire-sync-settings.ts +25 -0
- package/src/modules/faire-sync/models/faire-webhook-event.ts +20 -0
- package/src/modules/faire-sync/service.ts +252 -0
- package/src/subscribers/faire-inventory-sync.ts +97 -0
- package/src/subscribers/faire-listing-events.ts +48 -0
- package/src/subscribers/faire-order-ingest.ts +61 -0
- package/src/subscribers/faire-order-lifecycle.ts +81 -0
- package/src/subscribers/faire-product-status-sync.ts +76 -0
- package/src/workflows/index.ts +4 -0
- package/src/workflows/ingest-faire-order-support.ts +96 -0
- package/src/workflows/ingest-faire-order.ts +212 -0
- package/src/workflows/ingest-faire-orders-bulk.ts +168 -0
- package/src/workflows/refresh-faire-token.ts +28 -0
- package/src/workflows/sync-product-to-faire.ts +375 -0
- package/src/workflows/sync-products-to-faire.ts +133 -0
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
import crypto from "crypto"
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_API_BASE,
|
|
4
|
+
DEFAULT_AUTH_URL,
|
|
5
|
+
DEFAULT_TOKEN_URL,
|
|
6
|
+
FairePluginOptions,
|
|
7
|
+
BrandInfo,
|
|
8
|
+
CreateProductInput,
|
|
9
|
+
UpdateProductInput,
|
|
10
|
+
ProductResponse,
|
|
11
|
+
InventoryLevel,
|
|
12
|
+
FaireOrder,
|
|
13
|
+
TokenData,
|
|
14
|
+
} from "./types"
|
|
15
|
+
|
|
16
|
+
export class FaireApiError extends Error {
|
|
17
|
+
status: number
|
|
18
|
+
body: any
|
|
19
|
+
constructor(message: string, status: number, body: any) {
|
|
20
|
+
super(message)
|
|
21
|
+
this.name = "FaireApiError"
|
|
22
|
+
this.status = status
|
|
23
|
+
this.body = body
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Faire External Platform API v2 client.
|
|
29
|
+
*
|
|
30
|
+
* Auth model:
|
|
31
|
+
* - OAuth 2.0 authorization-code flow (no PKCE; Faire does not use PKCE).
|
|
32
|
+
* - Scoped requests send `Authorization: Bearer <access_token>`.
|
|
33
|
+
* - Faire's API-key mode (`X-FAIRE-ACCESS-TOKEN`) is not used here because the
|
|
34
|
+
* plugin connects a brand account via OAuth redirect.
|
|
35
|
+
*
|
|
36
|
+
* NOTE: Faire's developer portal is access-gated. Endpoint paths, payload
|
|
37
|
+
* shapes and the webhook signature scheme below reflect the publicly documented
|
|
38
|
+
* v2 surface and standard OAuth2 conventions; adjust via options / env if your
|
|
39
|
+
* Faire app uses a different base or signing scheme.
|
|
40
|
+
*/
|
|
41
|
+
export class FaireClient {
|
|
42
|
+
private clientId: string
|
|
43
|
+
private clientSecret: string
|
|
44
|
+
private redirectUri: string
|
|
45
|
+
private apiBase: string
|
|
46
|
+
private authUrl: string
|
|
47
|
+
private tokenUrl: string
|
|
48
|
+
private scope: string
|
|
49
|
+
|
|
50
|
+
constructor(opts: FairePluginOptions) {
|
|
51
|
+
this.clientId = opts.clientId
|
|
52
|
+
this.clientSecret = opts.clientSecret
|
|
53
|
+
this.redirectUri = opts.redirectUri
|
|
54
|
+
this.apiBase = opts.apiBase || DEFAULT_API_BASE
|
|
55
|
+
this.authUrl = opts.authUrl || DEFAULT_AUTH_URL
|
|
56
|
+
this.tokenUrl = opts.tokenUrl || DEFAULT_TOKEN_URL
|
|
57
|
+
this.scope = opts.scope ?? ""
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get redirectUriValue(): string {
|
|
61
|
+
return this.redirectUri
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── OAuth ───────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Build the Faire OAuth authorize URL. The caller must persist `state`
|
|
68
|
+
* (keyed by it) for the callback verification.
|
|
69
|
+
*/
|
|
70
|
+
getAuthorizationUrl(state: string): string {
|
|
71
|
+
const params = new URLSearchParams({
|
|
72
|
+
response_type: "code",
|
|
73
|
+
client_id: this.clientId,
|
|
74
|
+
redirect_uri: this.redirectUri,
|
|
75
|
+
state,
|
|
76
|
+
})
|
|
77
|
+
if (this.scope) params.set("scope", this.scope)
|
|
78
|
+
return `${this.authUrl}?${params.toString()}`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async exchangeCodeForToken(code: string): Promise<TokenData> {
|
|
82
|
+
const body = new URLSearchParams({
|
|
83
|
+
grant_type: "authorization_code",
|
|
84
|
+
client_id: this.clientId,
|
|
85
|
+
client_secret: this.clientSecret,
|
|
86
|
+
redirect_uri: this.redirectUri,
|
|
87
|
+
code,
|
|
88
|
+
})
|
|
89
|
+
const data = await this.requestJson<any>(this.tokenUrl, {
|
|
90
|
+
method: "POST",
|
|
91
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
92
|
+
body: body.toString(),
|
|
93
|
+
auth: false,
|
|
94
|
+
})
|
|
95
|
+
return {
|
|
96
|
+
access_token: data.access_token,
|
|
97
|
+
refresh_token: data.refresh_token,
|
|
98
|
+
token_type: data.token_type || "Bearer",
|
|
99
|
+
expires_in: data.expires_in,
|
|
100
|
+
retrieved_at: Date.now(),
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async refreshAccessToken(refresh_token: string): Promise<TokenData> {
|
|
105
|
+
const body = new URLSearchParams({
|
|
106
|
+
grant_type: "refresh_token",
|
|
107
|
+
client_id: this.clientId,
|
|
108
|
+
client_secret: this.clientSecret,
|
|
109
|
+
refresh_token,
|
|
110
|
+
})
|
|
111
|
+
const data = await this.requestJson<any>(this.tokenUrl, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
114
|
+
body: body.toString(),
|
|
115
|
+
auth: false,
|
|
116
|
+
})
|
|
117
|
+
return {
|
|
118
|
+
access_token: data.access_token,
|
|
119
|
+
refresh_token: data.refresh_token || refresh_token,
|
|
120
|
+
token_type: data.token_type || "Bearer",
|
|
121
|
+
expires_in: data.expires_in,
|
|
122
|
+
retrieved_at: Date.now(),
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Resolve the brand the access token belongs to. Faire exposes the current
|
|
128
|
+
* brand via GET /api/v2/brand. Falls back gracefully if the shape differs.
|
|
129
|
+
*/
|
|
130
|
+
async getBrand(accessToken: string): Promise<BrandInfo> {
|
|
131
|
+
const data = await this.requestJson<any>(`${this.apiBase}/brand`, {
|
|
132
|
+
method: "GET",
|
|
133
|
+
accessToken,
|
|
134
|
+
})
|
|
135
|
+
const brand =
|
|
136
|
+
data && typeof data === "object" && (data.brand_id || data.id)
|
|
137
|
+
? data
|
|
138
|
+
: data?.brand ?? data?.results?.[0] ?? data
|
|
139
|
+
if (!brand?.brand_id && !brand?.id) {
|
|
140
|
+
throw new Error(
|
|
141
|
+
"No Faire brand found for the authorized account. Make sure you connected the brand owner."
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
brand_id: String(brand.brand_id ?? brand.id),
|
|
146
|
+
brand_name: brand.name ?? brand.brand_name ?? "Faire Brand",
|
|
147
|
+
currency: brand.currency ?? brand.currency_code,
|
|
148
|
+
country: brand.country ?? brand.country_code,
|
|
149
|
+
raw: brand,
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async fetchResource<T = any>(accessToken: string, url: string): Promise<T> {
|
|
154
|
+
return this.requestJson<T>(url, { method: "GET", accessToken })
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Products ────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
async createProduct(
|
|
160
|
+
accessToken: string,
|
|
161
|
+
input: CreateProductInput
|
|
162
|
+
): Promise<ProductResponse> {
|
|
163
|
+
const data = await this.requestJson<any>(`${this.apiBase}/products`, {
|
|
164
|
+
method: "POST",
|
|
165
|
+
headers: { "Content-Type": "application/json" },
|
|
166
|
+
body: JSON.stringify(input),
|
|
167
|
+
accessToken,
|
|
168
|
+
})
|
|
169
|
+
return this.mapProduct(data?.product ?? data)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async updateProduct(
|
|
173
|
+
accessToken: string,
|
|
174
|
+
productToken: string,
|
|
175
|
+
input: UpdateProductInput
|
|
176
|
+
): Promise<ProductResponse> {
|
|
177
|
+
const data = await this.requestJson<any>(
|
|
178
|
+
`${this.apiBase}/products/${productToken}`,
|
|
179
|
+
{
|
|
180
|
+
method: "PUT",
|
|
181
|
+
headers: { "Content-Type": "application/json" },
|
|
182
|
+
body: JSON.stringify(input),
|
|
183
|
+
accessToken,
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
return this.mapProduct(data?.product ?? data)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async getProduct(
|
|
190
|
+
accessToken: string,
|
|
191
|
+
productToken: string
|
|
192
|
+
): Promise<ProductResponse> {
|
|
193
|
+
const data = await this.requestJson<any>(
|
|
194
|
+
`${this.apiBase}/products/${productToken}`,
|
|
195
|
+
{ method: "GET", accessToken }
|
|
196
|
+
)
|
|
197
|
+
return this.mapProduct(data?.product ?? data)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async listProducts(
|
|
201
|
+
accessToken: string,
|
|
202
|
+
opts: { limit?: number; page?: number } = {}
|
|
203
|
+
): Promise<{ count: number; results: ProductResponse[] }> {
|
|
204
|
+
const params = new URLSearchParams()
|
|
205
|
+
params.set("limit", String(opts.limit ?? 100))
|
|
206
|
+
if (opts.page != null) params.set("page", String(opts.page))
|
|
207
|
+
const data = await this.requestJson<any>(
|
|
208
|
+
`${this.apiBase}/products?${params.toString()}`,
|
|
209
|
+
{ method: "GET", accessToken }
|
|
210
|
+
)
|
|
211
|
+
const results = (data.products ?? data.results ?? []).map((p: any) =>
|
|
212
|
+
this.mapProduct(p)
|
|
213
|
+
)
|
|
214
|
+
return { count: data.count ?? results.length, results }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── Inventory ───────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
async getInventory(
|
|
220
|
+
accessToken: string,
|
|
221
|
+
productToken?: string
|
|
222
|
+
): Promise<InventoryLevel[]> {
|
|
223
|
+
const params = new URLSearchParams()
|
|
224
|
+
if (productToken) params.set("product_token", productToken)
|
|
225
|
+
const data = await this.requestJson<any>(
|
|
226
|
+
`${this.apiBase}/inventory?${params.toString()}`,
|
|
227
|
+
{ method: "GET", accessToken }
|
|
228
|
+
)
|
|
229
|
+
const rows = data.inventory ?? data.results ?? []
|
|
230
|
+
return (Array.isArray(rows) ? rows : []).map((r: any) => ({
|
|
231
|
+
sku: String(r.sku ?? r.variant_id ?? ""),
|
|
232
|
+
product_token: r.product_token ? String(r.product_token) : undefined,
|
|
233
|
+
current_count: Number(r.current_count ?? r.inventory_count ?? 0),
|
|
234
|
+
raw: r,
|
|
235
|
+
}))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async updateInventory(
|
|
239
|
+
accessToken: string,
|
|
240
|
+
levels: Array<{ sku: string; current_count: number }>
|
|
241
|
+
): Promise<any> {
|
|
242
|
+
return this.requestJson<any>(`${this.apiBase}/inventory`, {
|
|
243
|
+
method: "PUT",
|
|
244
|
+
headers: { "Content-Type": "application/json" },
|
|
245
|
+
body: JSON.stringify({ inventory: levels }),
|
|
246
|
+
accessToken,
|
|
247
|
+
})
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── Orders ──────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
async listOrders(
|
|
253
|
+
accessToken: string,
|
|
254
|
+
opts: { limit?: number; page?: number } = {}
|
|
255
|
+
): Promise<{ count: number; results: FaireOrder[] }> {
|
|
256
|
+
const params = new URLSearchParams()
|
|
257
|
+
params.set("limit", String(opts.limit ?? 100))
|
|
258
|
+
if (opts.page != null) params.set("page", String(opts.page))
|
|
259
|
+
const data = await this.requestJson<any>(
|
|
260
|
+
`${this.apiBase}/orders?${params.toString()}`,
|
|
261
|
+
{ method: "GET", accessToken }
|
|
262
|
+
)
|
|
263
|
+
const results = (data.orders ?? data.results ?? []).map((o: any) =>
|
|
264
|
+
this.mapOrder(o)
|
|
265
|
+
)
|
|
266
|
+
return { count: data.count ?? results.length, results }
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async getOrder(accessToken: string, orderToken: string): Promise<FaireOrder> {
|
|
270
|
+
const data = await this.requestJson<any>(
|
|
271
|
+
`${this.apiBase}/orders/${orderToken}`,
|
|
272
|
+
{ method: "GET", accessToken }
|
|
273
|
+
)
|
|
274
|
+
return this.mapOrder(data?.order ?? data)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async setOrderItemAvailability(
|
|
278
|
+
accessToken: string,
|
|
279
|
+
orderToken: string,
|
|
280
|
+
items: Array<{ item_id: string; availability: string }>
|
|
281
|
+
): Promise<any> {
|
|
282
|
+
return this.requestJson<any>(
|
|
283
|
+
`${this.apiBase}/orders/${orderToken}/items/availability`,
|
|
284
|
+
{
|
|
285
|
+
method: "POST",
|
|
286
|
+
headers: { "Content-Type": "application/json" },
|
|
287
|
+
body: JSON.stringify({ items }),
|
|
288
|
+
accessToken,
|
|
289
|
+
}
|
|
290
|
+
)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Webhooks ────────────────────────────────────────────────────────────
|
|
294
|
+
|
|
295
|
+
async registerWebhook(
|
|
296
|
+
accessToken: string,
|
|
297
|
+
payload: { url: string; events: string[] }
|
|
298
|
+
): Promise<any> {
|
|
299
|
+
return this.requestJson<any>(`${this.apiBase}/webhooks`, {
|
|
300
|
+
method: "POST",
|
|
301
|
+
headers: { "Content-Type": "application/json" },
|
|
302
|
+
body: JSON.stringify(payload),
|
|
303
|
+
accessToken,
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Fetch a remote image URL into a Buffer (used when Faire requires binary
|
|
311
|
+
* image payloads rather than remote URLs).
|
|
312
|
+
*/
|
|
313
|
+
static async fetchImageBuffer(url: string): Promise<{ buffer: Buffer; filename: string }> {
|
|
314
|
+
const res = await fetch(url)
|
|
315
|
+
if (!res.ok) {
|
|
316
|
+
throw new Error(`Failed to fetch image ${url}: ${res.status}`)
|
|
317
|
+
}
|
|
318
|
+
const arrayBuffer = await res.arrayBuffer()
|
|
319
|
+
const buffer = Buffer.from(arrayBuffer)
|
|
320
|
+
const filename = url.split("/").pop()?.split("?")[0] || "image.jpg"
|
|
321
|
+
return { buffer, filename }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private mapProduct(data: any): ProductResponse {
|
|
325
|
+
return {
|
|
326
|
+
product_token: String(data.product_token ?? data.id ?? data.token),
|
|
327
|
+
brand_id: data.brand_id != null ? String(data.brand_id) : undefined,
|
|
328
|
+
name: data.name,
|
|
329
|
+
description: data.description,
|
|
330
|
+
state: data.state,
|
|
331
|
+
url: data.url,
|
|
332
|
+
wholesale_price_cents: data.wholesale_price_cents,
|
|
333
|
+
retail_price_cents: data.retail_price_cents,
|
|
334
|
+
variants: data.variants,
|
|
335
|
+
images: data.images,
|
|
336
|
+
raw: data,
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private mapOrder(data: any): FaireOrder {
|
|
341
|
+
return {
|
|
342
|
+
order_token: String(data.order_token ?? data.id ?? data.token),
|
|
343
|
+
state: data.state ?? data.status,
|
|
344
|
+
currency: data.currency ?? data.currency_code,
|
|
345
|
+
total_cents: data.total_cents ?? data.grand_total_cents,
|
|
346
|
+
buyer_name: data.buyer_name ?? data.customer?.name,
|
|
347
|
+
raw: data,
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── Low-level request with rate-limit handling ──────────────────────────
|
|
352
|
+
|
|
353
|
+
private async requestJson<T>(
|
|
354
|
+
url: string,
|
|
355
|
+
init: {
|
|
356
|
+
method: string
|
|
357
|
+
body?: any
|
|
358
|
+
headers?: Record<string, string>
|
|
359
|
+
accessToken?: string
|
|
360
|
+
auth?: boolean
|
|
361
|
+
}
|
|
362
|
+
): Promise<T> {
|
|
363
|
+
const headers: Record<string, string> = {
|
|
364
|
+
Accept: "application/json",
|
|
365
|
+
...(init.headers || {}),
|
|
366
|
+
}
|
|
367
|
+
if (init.accessToken) {
|
|
368
|
+
headers["Authorization"] = `Bearer ${init.accessToken}`
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let attempt = 0
|
|
372
|
+
const maxAttempts = 4
|
|
373
|
+
while (attempt < maxAttempts) {
|
|
374
|
+
attempt++
|
|
375
|
+
let res: Response
|
|
376
|
+
try {
|
|
377
|
+
res = await fetch(url, { method: init.method, headers, body: init.body })
|
|
378
|
+
} catch (err: any) {
|
|
379
|
+
if (attempt >= maxAttempts) throw err
|
|
380
|
+
await this.backoff(attempt)
|
|
381
|
+
continue
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (res.status === 429) {
|
|
385
|
+
const retryAfter = Number(res.headers.get("retry-after") || 1)
|
|
386
|
+
await this.sleep(Math.min(retryAfter * 1000, 10000))
|
|
387
|
+
continue
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (res.status === 401) {
|
|
391
|
+
throw new FaireApiError("Faire access token expired or invalid", 401, null)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (res.status === 204) {
|
|
395
|
+
return undefined as T
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const text = await res.text()
|
|
399
|
+
let body: any = null
|
|
400
|
+
if (text) {
|
|
401
|
+
try {
|
|
402
|
+
body = JSON.parse(text)
|
|
403
|
+
} catch {
|
|
404
|
+
body = text
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (!res.ok) {
|
|
409
|
+
const detail =
|
|
410
|
+
body?.error_description ||
|
|
411
|
+
body?.error ||
|
|
412
|
+
body?.message ||
|
|
413
|
+
(Array.isArray(body?.errors)
|
|
414
|
+
? body.errors.map((e: any) => e?.message || e?.error || e).join("; ")
|
|
415
|
+
: undefined) ||
|
|
416
|
+
(typeof body === "string" && body ? body : undefined) ||
|
|
417
|
+
(body && typeof body === "object" ? JSON.stringify(body) : undefined)
|
|
418
|
+
const msg = detail
|
|
419
|
+
? `Faire API ${res.status}: ${detail}`
|
|
420
|
+
: `Faire API error ${res.status}`
|
|
421
|
+
throw new FaireApiError(msg, res.status, body)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return body as T
|
|
425
|
+
}
|
|
426
|
+
throw new FaireApiError("Max retries exceeded", 599, null)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
private async backoff(attempt: number): Promise<void> {
|
|
430
|
+
await this.sleep(Math.min(500 * Math.pow(2, attempt - 1), 8000))
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private sleep(ms: number): Promise<void> {
|
|
434
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
435
|
+
}
|
|
436
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export interface FairePluginOptions {
|
|
2
|
+
clientId: string
|
|
3
|
+
clientSecret: string
|
|
4
|
+
redirectUri: string
|
|
5
|
+
apiBase?: string
|
|
6
|
+
authUrl?: string
|
|
7
|
+
tokenUrl?: string
|
|
8
|
+
scope?: string
|
|
9
|
+
webhookSecret?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_API_BASE = "https://www.faire.com/api/v2"
|
|
13
|
+
export const DEFAULT_AUTH_URL = "https://www.faire.com/oauth/authorize"
|
|
14
|
+
export const DEFAULT_TOKEN_URL = "https://www.faire.com/oauth/token"
|
|
15
|
+
// Faire OAuth currently exposes no granular scopes; the parameter is kept for
|
|
16
|
+
// forward-compat and to match the OAuth2 authorize URL shape.
|
|
17
|
+
export const DEFAULT_SCOPE = ""
|
|
18
|
+
|
|
19
|
+
export interface TokenData {
|
|
20
|
+
access_token: string
|
|
21
|
+
refresh_token?: string
|
|
22
|
+
token_type: string
|
|
23
|
+
expires_in?: number
|
|
24
|
+
retrieved_at: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BrandInfo {
|
|
28
|
+
brand_id: string
|
|
29
|
+
brand_name: string
|
|
30
|
+
currency?: string
|
|
31
|
+
country?: string
|
|
32
|
+
raw: Record<string, any>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Faire returns money as integer cents in the brand's currency. Keep cents for
|
|
37
|
+
* transport/storage, but convert to decimal major units (cents / 100) at the
|
|
38
|
+
* boundary where amounts enter Medusa order/payment workflows — see
|
|
39
|
+
* faireMoney() in ingest-faire-order-support.ts.
|
|
40
|
+
*/
|
|
41
|
+
export interface FaireMoney {
|
|
42
|
+
amount_cents: number
|
|
43
|
+
currency_code?: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type ProductState = "active" | "inactive" | "sold_out" | "draft"
|
|
47
|
+
|
|
48
|
+
export interface ProductImage {
|
|
49
|
+
url: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ProductVariantInput {
|
|
53
|
+
sku: string
|
|
54
|
+
name?: string
|
|
55
|
+
wholesale_price_cents?: number
|
|
56
|
+
retail_price_cents?: number
|
|
57
|
+
inventory_count?: number
|
|
58
|
+
images?: ProductImage[]
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CreateProductInput {
|
|
62
|
+
brand_id: string
|
|
63
|
+
name: string
|
|
64
|
+
description?: string
|
|
65
|
+
wholesale_price_cents?: number
|
|
66
|
+
retail_price_cents?: number
|
|
67
|
+
images?: ProductImage[]
|
|
68
|
+
variants?: ProductVariantInput[]
|
|
69
|
+
tags?: string[]
|
|
70
|
+
shipping?: Record<string, any>
|
|
71
|
+
metadata?: Record<string, any>
|
|
72
|
+
short_description?: string
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type UpdateProductInput = Partial<CreateProductInput>
|
|
76
|
+
|
|
77
|
+
export interface ProductResponse {
|
|
78
|
+
product_token: string
|
|
79
|
+
brand_id?: string
|
|
80
|
+
name: string
|
|
81
|
+
description?: string
|
|
82
|
+
state?: ProductState
|
|
83
|
+
url?: string
|
|
84
|
+
wholesale_price_cents?: number
|
|
85
|
+
retail_price_cents?: number
|
|
86
|
+
variants?: any[]
|
|
87
|
+
images?: ProductImage[]
|
|
88
|
+
raw: Record<string, any>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface InventoryLevel {
|
|
92
|
+
sku: string
|
|
93
|
+
product_token?: string
|
|
94
|
+
current_count: number
|
|
95
|
+
raw?: Record<string, any>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface FaireOrder {
|
|
99
|
+
order_token: string
|
|
100
|
+
state?: string
|
|
101
|
+
currency?: string
|
|
102
|
+
total_cents?: number
|
|
103
|
+
buyer_name?: string
|
|
104
|
+
raw: Record<string, any>
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface PreparedProduct {
|
|
108
|
+
product: ProductResponse
|
|
109
|
+
published: boolean
|
|
110
|
+
warnings: string[]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface WebhookRegistration {
|
|
114
|
+
webhook_token?: string
|
|
115
|
+
url: string
|
|
116
|
+
events: string[]
|
|
117
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import crypto from "crypto"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Verify an inbound Faire webhook.
|
|
5
|
+
*
|
|
6
|
+
* Faire signs each delivery with an HMAC-SHA256 of the raw request body using
|
|
7
|
+
* the webhook signing secret shared at registration time. The signature is sent
|
|
8
|
+
* in the `X-Faire-Webhook-Signature` header, typically as a hex digest. Some
|
|
9
|
+
* deployments send a base64 digest, so we accept both. The header may also
|
|
10
|
+
* carry several space-separated `v1,<sig>` entries (Svix-style); any match
|
|
11
|
+
* passes.
|
|
12
|
+
*
|
|
13
|
+
* NOTE: Faire's developer portal is access-gated; if your app uses a different
|
|
14
|
+
* header name or digest encoding, adjust the headers passed in or the
|
|
15
|
+
* `encoding` option accordingly.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type FaireWebhookHeaders = {
|
|
19
|
+
signature?: string
|
|
20
|
+
timestamp?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const TOLERANCE_SECONDS = 5 * 60
|
|
24
|
+
|
|
25
|
+
const isHex = (s: string): boolean => /^[0-9a-fA-F]+$/.test(s) && s.length % 2 === 0
|
|
26
|
+
|
|
27
|
+
const safeEqual = (a: Buffer, b: Buffer): boolean => {
|
|
28
|
+
if (a.length !== b.length) return false
|
|
29
|
+
return crypto.timingSafeEqual(a, b)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function verifyFaireWebhook(opts: {
|
|
33
|
+
secret: string
|
|
34
|
+
headers: FaireWebhookHeaders
|
|
35
|
+
rawBody: string
|
|
36
|
+
encoding?: "hex" | "base64"
|
|
37
|
+
toleranceSeconds?: number
|
|
38
|
+
now?: number
|
|
39
|
+
}): { valid: boolean; reason?: string } {
|
|
40
|
+
const { secret, headers, rawBody } = opts
|
|
41
|
+
const { signature, timestamp } = headers
|
|
42
|
+
|
|
43
|
+
if (!secret) return { valid: false, reason: "no signing secret configured" }
|
|
44
|
+
if (!signature) {
|
|
45
|
+
return { valid: false, reason: "missing webhook signature header" }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Optional replay guard — only enforced when a timestamp is present.
|
|
49
|
+
if (timestamp) {
|
|
50
|
+
const ts = Number(timestamp)
|
|
51
|
+
const now = Math.floor((opts.now ?? Date.now()) / 1000)
|
|
52
|
+
const tolerance = opts.toleranceSeconds ?? TOLERANCE_SECONDS
|
|
53
|
+
if (Number.isFinite(ts) && Math.abs(now - ts) > tolerance) {
|
|
54
|
+
return { valid: false, reason: "timestamp outside tolerance" }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const key = Buffer.from(secret, "utf8")
|
|
59
|
+
const computed = crypto
|
|
60
|
+
.createHmac("sha256", key)
|
|
61
|
+
.update(rawBody, "utf8")
|
|
62
|
+
.digest()
|
|
63
|
+
|
|
64
|
+
const candidates = signature
|
|
65
|
+
.split(" ")
|
|
66
|
+
.map((part) => (part.includes(",") ? part.split(",")[1] : part))
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
|
|
69
|
+
let passed = false
|
|
70
|
+
for (const sig of candidates) {
|
|
71
|
+
const encoding = opts.encoding || (isHex(sig) ? "hex" : "base64")
|
|
72
|
+
let provided: Buffer
|
|
73
|
+
try {
|
|
74
|
+
provided = Buffer.from(sig, encoding)
|
|
75
|
+
} catch {
|
|
76
|
+
continue
|
|
77
|
+
}
|
|
78
|
+
if (provided.length && safeEqual(provided, computed)) {
|
|
79
|
+
passed = true
|
|
80
|
+
break
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return passed ? { valid: true } : { valid: false, reason: "signature mismatch" }
|
|
85
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import ProductModule from "@medusajs/medusa/product"
|
|
2
|
+
import FaireSyncModule from "../modules/faire-sync"
|
|
3
|
+
import { defineLink } from "@medusajs/framework/utils"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Link between Product and Faire account, storing the *current* per-product
|
|
7
|
+
* sync state. Historical sync attempts live in the faire_sync_record table.
|
|
8
|
+
*
|
|
9
|
+
* extraColumns:
|
|
10
|
+
* - faire_product_token: Faire product token once created
|
|
11
|
+
* - faire_url: public Faire product URL
|
|
12
|
+
* - sync_status: pending | synced | failed | out_of_sync
|
|
13
|
+
* - last_synced_at: last successful sync
|
|
14
|
+
* - sync_error: last error message
|
|
15
|
+
*/
|
|
16
|
+
export default defineLink(
|
|
17
|
+
{
|
|
18
|
+
linkable: ProductModule.linkable.product,
|
|
19
|
+
isList: true,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
linkable: FaireSyncModule.linkable.faireSyncAccount,
|
|
23
|
+
isList: false,
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
database: {
|
|
27
|
+
extraColumns: {
|
|
28
|
+
faire_product_token: {
|
|
29
|
+
type: "text",
|
|
30
|
+
nullable: true,
|
|
31
|
+
},
|
|
32
|
+
faire_url: {
|
|
33
|
+
type: "text",
|
|
34
|
+
nullable: true,
|
|
35
|
+
},
|
|
36
|
+
sync_status: {
|
|
37
|
+
type: "text",
|
|
38
|
+
defaultValue: "'pending'",
|
|
39
|
+
nullable: false,
|
|
40
|
+
},
|
|
41
|
+
last_synced_at: {
|
|
42
|
+
type: "datetime",
|
|
43
|
+
nullable: true,
|
|
44
|
+
},
|
|
45
|
+
sync_error: {
|
|
46
|
+
type: "text",
|
|
47
|
+
nullable: true,
|
|
48
|
+
},
|
|
49
|
+
metadata: {
|
|
50
|
+
type: "json",
|
|
51
|
+
nullable: true,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Module } from "@medusajs/framework/utils"
|
|
2
|
+
import FaireSyncService from "./service"
|
|
3
|
+
|
|
4
|
+
export const FAIRE_SYNC_MODULE = "faireSync"
|
|
5
|
+
|
|
6
|
+
const FaireSyncModule = Module(FAIRE_SYNC_MODULE, {
|
|
7
|
+
service: FaireSyncService,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export default FaireSyncModule
|
|
11
|
+
export { FaireSyncService }
|