@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.
Files changed (104) hide show
  1. package/.medusa/server/src/__tests__/ingest-faire-order-support.spec.js +74 -0
  2. package/.medusa/server/src/__tests__/webhook.spec.js +77 -0
  3. package/.medusa/server/src/admin/index.js +960 -0
  4. package/.medusa/server/src/admin/index.mjs +959 -0
  5. package/.medusa/server/src/api/admin/faire/auth/authorize/route.js +12 -0
  6. package/.medusa/server/src/api/admin/faire/auth/callback/route.js +25 -0
  7. package/.medusa/server/src/api/admin/faire/auth/disconnect/route.js +12 -0
  8. package/.medusa/server/src/api/admin/faire/brand/route.js +14 -0
  9. package/.medusa/server/src/api/admin/faire/ingest/orders/[batchId]/route.js +27 -0
  10. package/.medusa/server/src/api/admin/faire/ingest/orders/route.js +20 -0
  11. package/.medusa/server/src/api/admin/faire/products/route.js +16 -0
  12. package/.medusa/server/src/api/admin/faire/settings/route.js +19 -0
  13. package/.medusa/server/src/api/admin/faire/status/product/[id]/route.js +30 -0
  14. package/.medusa/server/src/api/admin/faire/status/route.js +46 -0
  15. package/.medusa/server/src/api/admin/faire/sync/bulk/[batchId]/route.js +29 -0
  16. package/.medusa/server/src/api/admin/faire/sync/bulk/route.js +20 -0
  17. package/.medusa/server/src/api/admin/faire/sync/product/[id]/route.js +14 -0
  18. package/.medusa/server/src/api/admin/faire/syncs/[id]/route.js +32 -0
  19. package/.medusa/server/src/api/admin/faire/syncs/route.js +18 -0
  20. package/.medusa/server/src/api/webhooks/faire/route.js +125 -0
  21. package/.medusa/server/src/jobs/pull-faire-orders.js +32 -0
  22. package/.medusa/server/src/jobs/refresh-faire-token.js +25 -0
  23. package/.medusa/server/src/lib/faire-client.js +321 -0
  24. package/.medusa/server/src/lib/types.js +10 -0
  25. package/.medusa/server/src/lib/webhook.js +58 -0
  26. package/.medusa/server/src/links/product-faire-sync-link.js +57 -0
  27. package/.medusa/server/src/modules/faire-sync/index.js +15 -0
  28. package/.medusa/server/src/modules/faire-sync/migrations/Migration20260707211346.js +34 -0
  29. package/.medusa/server/src/modules/faire-sync/models/faire-order.js +21 -0
  30. package/.medusa/server/src/modules/faire-sync/models/faire-sync-account.js +17 -0
  31. package/.medusa/server/src/modules/faire-sync/models/faire-sync-batch.js +18 -0
  32. package/.medusa/server/src/modules/faire-sync/models/faire-sync-record.js +22 -0
  33. package/.medusa/server/src/modules/faire-sync/models/faire-sync-settings.js +26 -0
  34. package/.medusa/server/src/modules/faire-sync/models/faire-webhook-event.js +21 -0
  35. package/.medusa/server/src/modules/faire-sync/service.js +222 -0
  36. package/.medusa/server/src/subscribers/faire-inventory-sync.js +87 -0
  37. package/.medusa/server/src/subscribers/faire-listing-events.js +46 -0
  38. package/.medusa/server/src/subscribers/faire-order-ingest.js +54 -0
  39. package/.medusa/server/src/subscribers/faire-order-lifecycle.js +71 -0
  40. package/.medusa/server/src/subscribers/faire-product-status-sync.js +71 -0
  41. package/.medusa/server/src/workflows/index.js +21 -0
  42. package/.medusa/server/src/workflows/ingest-faire-order-support.js +76 -0
  43. package/.medusa/server/src/workflows/ingest-faire-order.js +148 -0
  44. package/.medusa/server/src/workflows/ingest-faire-orders-bulk.js +124 -0
  45. package/.medusa/server/src/workflows/refresh-faire-token.js +20 -0
  46. package/.medusa/server/src/workflows/sync-product-to-faire.js +262 -0
  47. package/.medusa/server/src/workflows/sync-products-to-faire.js +90 -0
  48. package/LICENSE +21 -0
  49. package/README.md +180 -0
  50. package/package.json +84 -0
  51. package/scripts/postbuild.js +36 -0
  52. package/src/__tests__/ingest-faire-order-support.spec.ts +77 -0
  53. package/src/__tests__/webhook.spec.ts +81 -0
  54. package/src/admin/lib/api.ts +88 -0
  55. package/src/admin/routes/settings/faire/[id]/page.tsx +184 -0
  56. package/src/admin/routes/settings/faire/hooks/use-faire-sync-columns.tsx +80 -0
  57. package/src/admin/routes/settings/faire/page.tsx +445 -0
  58. package/src/admin/routes/settings/faire/settings/page.tsx +286 -0
  59. package/src/admin/routes/settings/oauth/faire/callback/page.tsx +67 -0
  60. package/src/admin/widgets/faire-product-widget.tsx +180 -0
  61. package/src/api/admin/faire/auth/authorize/route.ts +10 -0
  62. package/src/api/admin/faire/auth/callback/route.ts +29 -0
  63. package/src/api/admin/faire/auth/disconnect/route.ts +10 -0
  64. package/src/api/admin/faire/brand/route.ts +12 -0
  65. package/src/api/admin/faire/ingest/orders/[batchId]/route.ts +31 -0
  66. package/src/api/admin/faire/ingest/orders/route.ts +29 -0
  67. package/src/api/admin/faire/products/route.ts +14 -0
  68. package/src/api/admin/faire/settings/route.ts +17 -0
  69. package/src/api/admin/faire/status/product/[id]/route.ts +30 -0
  70. package/src/api/admin/faire/status/route.ts +47 -0
  71. package/src/api/admin/faire/sync/bulk/[batchId]/route.ts +33 -0
  72. package/src/api/admin/faire/sync/bulk/route.ts +27 -0
  73. package/src/api/admin/faire/sync/product/[id]/route.ts +13 -0
  74. package/src/api/admin/faire/syncs/[id]/route.ts +32 -0
  75. package/src/api/admin/faire/syncs/route.ts +17 -0
  76. package/src/api/webhooks/faire/route.ts +133 -0
  77. package/src/jobs/pull-faire-orders.ts +30 -0
  78. package/src/jobs/refresh-faire-token.ts +23 -0
  79. package/src/lib/faire-client.ts +436 -0
  80. package/src/lib/types.ts +117 -0
  81. package/src/lib/webhook.ts +85 -0
  82. package/src/links/product-faire-sync-link.ts +56 -0
  83. package/src/modules/faire-sync/index.ts +11 -0
  84. package/src/modules/faire-sync/migrations/.snapshot-medusa-faire-sync.json +1486 -0
  85. package/src/modules/faire-sync/migrations/Migration20260707211346.ts +43 -0
  86. package/src/modules/faire-sync/models/faire-order.ts +20 -0
  87. package/src/modules/faire-sync/models/faire-sync-account.ts +16 -0
  88. package/src/modules/faire-sync/models/faire-sync-batch.ts +17 -0
  89. package/src/modules/faire-sync/models/faire-sync-record.ts +21 -0
  90. package/src/modules/faire-sync/models/faire-sync-settings.ts +25 -0
  91. package/src/modules/faire-sync/models/faire-webhook-event.ts +20 -0
  92. package/src/modules/faire-sync/service.ts +252 -0
  93. package/src/subscribers/faire-inventory-sync.ts +97 -0
  94. package/src/subscribers/faire-listing-events.ts +48 -0
  95. package/src/subscribers/faire-order-ingest.ts +61 -0
  96. package/src/subscribers/faire-order-lifecycle.ts +81 -0
  97. package/src/subscribers/faire-product-status-sync.ts +76 -0
  98. package/src/workflows/index.ts +4 -0
  99. package/src/workflows/ingest-faire-order-support.ts +96 -0
  100. package/src/workflows/ingest-faire-order.ts +212 -0
  101. package/src/workflows/ingest-faire-orders-bulk.ts +168 -0
  102. package/src/workflows/refresh-faire-token.ts +28 -0
  103. package/src/workflows/sync-product-to-faire.ts +375 -0
  104. 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
+ }
@@ -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 }