@jytextiles/medusa-plugin-faire-store-sync 0.2.2 → 0.2.3

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.
@@ -187,6 +187,13 @@ const FaireSettingsPage = () => {
187
187
  {connected ? (
188
188
  <div className="flex items-center gap-3">
189
189
  <StatusBadge color="green">Connected</StatusBadge>
190
+ <Button
191
+ size="small"
192
+ variant="secondary"
193
+ onClick={() => navigate("/settings/faire/bulk")}
194
+ >
195
+ Bulk sync
196
+ </Button>
190
197
  <Button
191
198
  size="small"
192
199
  variant="secondary"
@@ -230,9 +237,6 @@ const FaireSettingsPage = () => {
230
237
  )}
231
238
  </Container>
232
239
 
233
- {/* Bulk operations */}
234
- <BulkOperations connected={connected} />
235
-
236
240
  {/* Recent syncs */}
237
241
  <Container className="divide-y p-0">
238
242
  <DataTable instance={table}>
@@ -272,175 +276,6 @@ const InfoField = ({ label, value }: { label: string; value: string }) => (
272
276
  </div>
273
277
  )
274
278
 
275
- /**
276
- * Bulk operations card: push many products to Faire, and pull Faire orders into
277
- * Medusa. Both run as long-running background workflows; progress is polled.
278
- */
279
- const BulkOperations = ({ connected }: { connected: boolean }) => {
280
- const [productIds, setProductIds] = useState("")
281
- const [pushBatch, setPushBatch] = useState<string | null>(null)
282
- const [pullBatch, setPullBatch] = useState<string | null>(null)
283
-
284
- const pushMutation = useMutation({
285
- mutationFn: (ids: string[]) => faireApi.syncBulk(ids),
286
- onSuccess: (res: any) => {
287
- setPushBatch(res.batch_id)
288
- toast.success("Bulk product sync started", {
289
- description: "Running in the background. Polling progress…",
290
- })
291
- },
292
- onError: (err: any) =>
293
- toast.error("Failed to start bulk sync", { description: err.message }),
294
- })
295
-
296
- const pullMutation = useMutation({
297
- mutationFn: () => faireApi.ingestOrders(),
298
- onSuccess: (res: any) => {
299
- setPullBatch(res.batch_id)
300
- toast.success("Faire order pull started", {
301
- description: "Running in the background. Polling progress…",
302
- })
303
- },
304
- onError: (err: any) =>
305
- toast.error("Failed to start order pull", { description: err.message }),
306
- })
307
-
308
- // Poll both batches until finished.
309
- const pushStatus = useQuery({
310
- queryKey: ["faire", "bulk", pushBatch],
311
- enabled: !!pushBatch,
312
- refetchInterval: (q) =>
313
- (q.state.data as any)?.progress?.finished ? false : 3000,
314
- queryFn: () => faireApi.bulkStatus(pushBatch!),
315
- })
316
- const pullStatus = useQuery({
317
- queryKey: ["faire", "ingest", pullBatch],
318
- enabled: !!pullBatch,
319
- refetchInterval: (q) =>
320
- (q.state.data as any)?.progress?.finished ? false : 3000,
321
- queryFn: () => faireApi.ingestStatus(pullBatch!),
322
- })
323
-
324
- const pushProgress = (pushStatus.data as any)?.progress
325
- const pullProgress = (pullStatus.data as any)?.progress
326
-
327
- const handlePush = () => {
328
- const ids = productIds
329
- .split(/[\s,]+/)
330
- .map((s) => s.trim())
331
- .filter(Boolean)
332
- if (!ids.length) {
333
- toast.error("Enter at least one product id")
334
- return
335
- }
336
- pushMutation.mutate(ids)
337
- }
338
-
339
- return (
340
- <Container className="divide-y p-0">
341
- <div className="px-6 py-4">
342
- <Heading>Bulk operations</Heading>
343
- <Text className="text-ui-fg-subtle" size="small">
344
- Push many products to Faire, or pull wholesale orders from Faire into
345
- Medusa. Both run as long-running background workflows.
346
- </Text>
347
- </div>
348
- <div className="px-6 py-4 flex flex-col gap-6">
349
- {/* Push products */}
350
- <div className="flex flex-col gap-3">
351
- <div className="flex flex-col gap-1">
352
- <Label>Push products to Faire</Label>
353
- <Text className="text-ui-fg-subtle" size="small">
354
- Comma- or line-separated Medusa product IDs.
355
- </Text>
356
- </div>
357
- <textarea
358
- className="border-ui-border-base rounded-lg border px-3 py-2 text-sm min-h-[72px]"
359
- placeholder="prod_01..., prod_02..."
360
- value={productIds}
361
- onChange={(e) => setProductIds(e.target.value)}
362
- disabled={!connected || pushMutation.isPending}
363
- />
364
- <div className="flex flex-wrap items-center gap-3">
365
- <Button
366
- size="small"
367
- variant="secondary"
368
- disabled={!connected}
369
- onClick={() => navigate("/settings/faire/bulk")}
370
- >
371
- Select products
372
- </Button>
373
- <Button
374
- size="small"
375
- onClick={handlePush}
376
- disabled={!connected || pushMutation.isPending}
377
- isLoading={pushMutation.isPending}
378
- >
379
- Start bulk push
380
- </Button>
381
- {pushBatch && pushProgress && (
382
- <BulkProgress
383
- label="Push"
384
- progress={pushProgress}
385
- status={(pushStatus.data as any)?.batch?.status}
386
- />
387
- )}
388
- </div>
389
- </div>
390
-
391
- {/* Pull orders */}
392
- <div className="flex flex-col gap-3">
393
- <div className="flex flex-col gap-1">
394
- <Label>Pull Faire orders into Medusa</Label>
395
- <Text className="text-ui-fg-subtle" size="small">
396
- Ingests all available Faire orders as Medusa orders (idempotent).
397
- </Text>
398
- </div>
399
- <div className="flex items-center gap-3">
400
- <Button
401
- size="small"
402
- variant="secondary"
403
- onClick={() => pullMutation.mutate()}
404
- disabled={!connected || pullMutation.isPending}
405
- isLoading={pullMutation.isPending}
406
- >
407
- Start order pull
408
- </Button>
409
- {pullBatch && pullProgress && (
410
- <BulkProgress
411
- label="Pull"
412
- progress={pullProgress}
413
- status={(pullStatus.data as any)?.batch?.status}
414
- />
415
- )}
416
- </div>
417
- </div>
418
- </div>
419
- </Container>
420
- )
421
- }
422
-
423
- const BulkProgress = ({
424
- label,
425
- progress,
426
- status,
427
- }: {
428
- label: string
429
- progress: { total: number; done: number; pct: number; finished: boolean }
430
- status?: string
431
- }) => (
432
- <div className="flex items-center gap-3">
433
- <StatusBadge color={progress.finished ? (status === "failed" ? "red" : "green") : "orange"}>
434
- {label}: {progress.done}/{progress.total || "?"} ({progress.pct}%)
435
- </StatusBadge>
436
- {progress.finished && (
437
- <Text className="text-ui-fg-subtle" size="small">
438
- {status === "failed" ? "Completed with errors" : "Done"}
439
- </Text>
440
- )}
441
- </div>
442
- )
443
-
444
279
  export const config = defineRouteConfig({
445
280
  label: "Faire",
446
281
  icon: BuildingStorefront,
@@ -0,0 +1,84 @@
1
+ import crypto from "crypto"
2
+
3
+ /**
4
+ * Symmetric encryption for secrets at rest (Faire OAuth access/refresh tokens).
5
+ *
6
+ * AES-256-GCM, mirroring the host app's `encryption` module so the same
7
+ * `ENCRYPTION_KEY` protects Faire tokens the same way it protects social
8
+ * platform tokens. A dedicated `FAIRE_TOKEN_ENCRYPTION_KEY` takes precedence
9
+ * if set.
10
+ *
11
+ * Stored format (single self-describing string, fits the existing text column):
12
+ * fenc1:<iv_b64>.<authTag_b64>.<ciphertext_b64>
13
+ *
14
+ * Backward/forward compatible:
15
+ * - `encryptSecret` of an empty/nullish value returns it unchanged.
16
+ * - `decryptSecret` of a value WITHOUT the `fenc1:` prefix is returned as-is,
17
+ * so legacy plaintext rows keep working and a missing key degrades to
18
+ * plaintext (with a one-time warning) rather than crashing.
19
+ */
20
+
21
+ const PREFIX = "fenc1:"
22
+ const ALGORITHM = "aes-256-gcm"
23
+
24
+ let warnedNoKey = false
25
+
26
+ /** Resolve a 32-byte key from whatever secret is configured (any length). */
27
+ function resolveKey(): Buffer | null {
28
+ const keyString =
29
+ process.env.FAIRE_TOKEN_ENCRYPTION_KEY ||
30
+ process.env.ENCRYPTION_KEY ||
31
+ process.env.ENCRYPTION_KEY_V1
32
+ if (!keyString) return null
33
+
34
+ // Prefer a base64 32-byte key (matches the host encryption module); otherwise
35
+ // derive a stable 32-byte key from the secret so any value works.
36
+ const asBase64 = Buffer.from(keyString, "base64")
37
+ if (asBase64.length === 32) return asBase64
38
+ return crypto.createHash("sha256").update(keyString, "utf8").digest()
39
+ }
40
+
41
+ export function encryptSecret(plaintext: string | null | undefined): string | null {
42
+ if (plaintext == null || plaintext === "") {
43
+ return plaintext ?? null
44
+ }
45
+ const key = resolveKey()
46
+ if (!key) {
47
+ if (!warnedNoKey) {
48
+ // eslint-disable-next-line no-console
49
+ console.warn(
50
+ "[faire-sync] No ENCRYPTION_KEY/FAIRE_TOKEN_ENCRYPTION_KEY set — Faire tokens are stored in plaintext. Set one to encrypt at rest."
51
+ )
52
+ warnedNoKey = true
53
+ }
54
+ return plaintext
55
+ }
56
+ const iv = crypto.randomBytes(12)
57
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
58
+ const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()])
59
+ const tag = cipher.getAuthTag()
60
+ return `${PREFIX}${iv.toString("base64")}.${tag.toString("base64")}.${ct.toString("base64")}`
61
+ }
62
+
63
+ export function decryptSecret(stored: string | null | undefined): string {
64
+ if (stored == null || stored === "") return ""
65
+ if (!stored.startsWith(PREFIX)) return stored // legacy plaintext / unencrypted
66
+ const key = resolveKey()
67
+ if (!key) {
68
+ throw new Error(
69
+ "[faire-sync] Encrypted Faire token found but no ENCRYPTION_KEY/FAIRE_TOKEN_ENCRYPTION_KEY is set to decrypt it."
70
+ )
71
+ }
72
+ const [ivB64, tagB64, ctB64] = stored.slice(PREFIX.length).split(".")
73
+ const iv = Buffer.from(ivB64, "base64")
74
+ const tag = Buffer.from(tagB64, "base64")
75
+ const ct = Buffer.from(ctB64, "base64")
76
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
77
+ decipher.setAuthTag(tag)
78
+ return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8")
79
+ }
80
+
81
+ /** True if the stored value is in the encrypted envelope format. */
82
+ export function isEncrypted(stored: string | null | undefined): boolean {
83
+ return typeof stored === "string" && stored.startsWith(PREFIX)
84
+ }
@@ -130,6 +130,30 @@ export class FaireClient {
130
130
  }
131
131
  }
132
132
 
133
+ /**
134
+ * Revoke an OAuth access token on Faire's side.
135
+ *
136
+ * Faire allows only ONE active OAuth token per (app, brand); without revoking,
137
+ * a reconnect fails with 400 "Application is already installed via an active
138
+ * OAuth access token". Non-RFC-6749 body (matches the token endpoint), posted
139
+ * to `/api/external-api-oauth2/revoke`, no auth header.
140
+ */
141
+ async revokeToken(accessToken: string): Promise<void> {
142
+ if (!accessToken) return
143
+ const revokeUrl = this.tokenUrl.replace(/\/token$/, "/revoke")
144
+ const body = {
145
+ application_token: this.clientId,
146
+ application_secret: this.clientSecret,
147
+ access_token_o_auth: accessToken,
148
+ }
149
+ await this.requestJson<any>(revokeUrl, {
150
+ method: "POST",
151
+ headers: { "Content-Type": "application/json" },
152
+ body: JSON.stringify(body),
153
+ auth: false,
154
+ })
155
+ }
156
+
133
157
  async refreshAccessToken(refresh_token: string): Promise<TokenData> {
134
158
  const body = {
135
159
  application_token: this.clientId,
@@ -6,6 +6,7 @@ import FaireSyncBatch from "./models/faire-sync-batch"
6
6
  import FaireWebhookEvent from "./models/faire-webhook-event"
7
7
  import FaireOrder from "./models/faire-order"
8
8
  import { FaireClient } from "../../lib/faire-client"
9
+ import { encryptSecret, decryptSecret } from "../../lib/crypto"
9
10
  import { FairePluginOptions, BrandInfo, TokenData } from "../../lib/types"
10
11
 
11
12
  type ModuleOptions = FairePluginOptions & Record<string, any>
@@ -33,6 +34,12 @@ function toMedusaError(err: any): MedusaError {
33
34
  "Faire authorization expired or was already used. Please reconnect."
34
35
  )
35
36
  }
37
+ if (/already installed/i.test(message)) {
38
+ return new MedusaError(
39
+ MedusaError.Types.NOT_ALLOWED,
40
+ "This Faire app already has an active connection. Disconnect it here first, or uninstall the app from your Faire account (Settings → Apps), then connect again."
41
+ )
42
+ }
36
43
  if (/brand/i.test(message) && /(no|not|own)/i.test(message)) {
37
44
  return new MedusaError(MedusaError.Types.NOT_FOUND, message)
38
45
  }
@@ -105,6 +112,24 @@ class FaireSyncService extends MedusaService({
105
112
  return account ?? null
106
113
  }
107
114
 
115
+ /**
116
+ * Return a shallow copy of an account with its at-rest secrets decrypted, for
117
+ * use when calling the Faire API. Tokens are stored encrypted (AES-256-GCM);
118
+ * callers that make API requests must go through this / `ensureFreshToken`.
119
+ */
120
+ private withDecryptedTokens<T extends { access_token?: any; refresh_token?: any }>(
121
+ account: T | null
122
+ ): T | null {
123
+ if (!account) return account
124
+ return {
125
+ ...account,
126
+ access_token: decryptSecret(account.access_token),
127
+ refresh_token: account.refresh_token
128
+ ? decryptSecret(account.refresh_token)
129
+ : account.refresh_token,
130
+ }
131
+ }
132
+
108
133
  async saveAccount(token: TokenData, brand: BrandInfo) {
109
134
  const existing = await this.getActiveAccount()
110
135
  const expiresAt =
@@ -116,8 +141,8 @@ class FaireSyncService extends MedusaService({
116
141
  brand_name: brand.brand_name,
117
142
  currency: brand.currency ?? null,
118
143
  country: brand.country ?? null,
119
- access_token: token.access_token,
120
- refresh_token: token.refresh_token ?? null,
144
+ access_token: encryptSecret(token.access_token),
145
+ refresh_token: encryptSecret(token.refresh_token ?? null),
121
146
  token_expires_at: expiresAt,
122
147
  brand_info: brand.raw,
123
148
  is_active: true,
@@ -134,6 +159,22 @@ class FaireSyncService extends MedusaService({
134
159
  async disconnect() {
135
160
  const account = await this.getActiveAccount()
136
161
  if (!account) return
162
+ // Revoke on Faire's side FIRST. Faire allows only one active OAuth token per
163
+ // (app, brand); if we only clear locally, a later reconnect fails with 400
164
+ // "Application is already installed via an active OAuth access token".
165
+ // Best-effort: a failed revoke must not block local disconnect.
166
+ try {
167
+ const accessToken = decryptSecret((account as any).access_token)
168
+ if (accessToken && this.options_.authMode !== "apiKey") {
169
+ await this.getClient().revokeToken(accessToken)
170
+ }
171
+ } catch (err: any) {
172
+ // eslint-disable-next-line no-console
173
+ console.warn(
174
+ "[faire-sync] Faire token revoke failed on disconnect (clearing locally anyway):",
175
+ err?.message
176
+ )
177
+ }
137
178
  await this.updateFaireSyncAccounts({
138
179
  id: account.id,
139
180
  is_active: false,
@@ -142,6 +183,11 @@ class FaireSyncService extends MedusaService({
142
183
  } as any)
143
184
  }
144
185
 
186
+ /**
187
+ * Resolve an account whose access/refresh tokens are DECRYPTED and non-expired,
188
+ * refreshing against Faire if needed. All Faire API callers must use this — the
189
+ * tokens are stored encrypted at rest, so a raw account row is unusable.
190
+ */
145
191
  async ensureFreshToken(account?: any) {
146
192
  const acct = account ?? (await this.getActiveAccount())
147
193
  if (!acct) {
@@ -151,15 +197,16 @@ class FaireSyncService extends MedusaService({
151
197
  )
152
198
  }
153
199
  if (!acct.token_expires_at) {
154
- return acct
200
+ return this.withDecryptedTokens(acct)
155
201
  }
156
202
  const now = Date.now()
157
203
  const expiresAt = new Date(acct.token_expires_at).getTime()
158
204
  const fiveMinutes = 5 * 60 * 1000
159
205
  if (expiresAt - now > fiveMinutes) {
160
- return acct
206
+ return this.withDecryptedTokens(acct)
161
207
  }
162
- if (!acct.refresh_token) {
208
+ const refreshToken = acct.refresh_token ? decryptSecret(acct.refresh_token) : ""
209
+ if (!refreshToken) {
163
210
  throw new MedusaError(
164
211
  MedusaError.Types.NOT_ALLOWED,
165
212
  "Faire access token has expired. Please reconnect your Faire account."
@@ -167,16 +214,17 @@ class FaireSyncService extends MedusaService({
167
214
  }
168
215
  try {
169
216
  const client = this.getClient()
170
- const token = await client.refreshAccessToken(acct.refresh_token)
171
- return this.updateFaireSyncAccounts({
217
+ const token = await client.refreshAccessToken(refreshToken)
218
+ const updated = await this.updateFaireSyncAccounts({
172
219
  id: acct.id,
173
- access_token: token.access_token,
174
- refresh_token: token.refresh_token ?? acct.refresh_token,
220
+ access_token: encryptSecret(token.access_token),
221
+ refresh_token: encryptSecret(token.refresh_token ?? refreshToken),
175
222
  token_expires_at:
176
223
  token.expires_in != null
177
224
  ? new Date(token.retrieved_at + token.expires_in * 1000)
178
225
  : null,
179
226
  } as any)
227
+ return this.withDecryptedTokens(updated)
180
228
  } catch (err) {
181
229
  throw toMedusaError(err)
182
230
  }