@legenki/print2medusa 0.1.0 → 0.2.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/README.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # @legenki/print2medusa
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@legenki/print2medusa.svg)](https://www.npmjs.com/package/@legenki/print2medusa)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@legenki/print2medusa.svg)](https://www.npmjs.com/package/@legenki/print2medusa)
5
+ [![license](https://img.shields.io/npm/l/@legenki/print2medusa.svg)](./LICENSE)
6
+
3
7
  Printful → Medusa v2 plugin: **sync Store Products**, **auto-create Printful orders** on payment capture, and a **Fulfillment Provider** for admin shipping options.
4
8
 
5
- MIT licensed.
9
+ Published on npm as [`@legenki/print2medusa`](https://www.npmjs.com/package/@legenki/print2medusa). MIT licensed.
6
10
 
7
11
  ## Requirements
8
12
 
@@ -16,6 +20,12 @@ MIT licensed.
16
20
  npm install @legenki/print2medusa
17
21
  ```
18
22
 
23
+ Or add it to a Medusa app the plugin-native way:
24
+
25
+ ```bash
26
+ npx medusa plugin:add @legenki/print2medusa
27
+ ```
28
+
19
29
  Register the plugin and fulfillment provider in `medusa-config.ts`:
20
30
 
21
31
  ```ts
@@ -66,19 +76,106 @@ See `examples/basic-store/` for a fuller snippet.
66
76
 
67
77
  ## What it does (MVP)
68
78
 
69
- | Feature | How |
70
- |--------|-----|
71
- | Product sync | Admin **Sync Now** or `POST /admin/printful/sync` → workflow pulls Printful Sync Products into Medusa |
72
- | Links | `printful_product_link` / `printful_variant_link` (+ metadata IDs) |
73
- | Orders | On `payment.captured` → creates Printful order with **`sync_variant_id`** |
74
- | Fulfillment provider | Select Printful shipping option in Admin locations |
75
- | Status | `GET /admin/printful/status` + product list widget |
79
+ | Feature | How |
80
+ | -------------------- | ----------------------------------------------------------------------------------------------------- |
81
+ | Product sync | Admin **Sync Now** or `POST /admin/printful/sync` → workflow pulls Printful Sync Products into Medusa |
82
+ | Links | `printful_product_link` / `printful_variant_link` (+ metadata IDs) |
83
+ | Orders | On `payment.captured` → creates Printful order with **`sync_variant_id`** |
84
+ | Fulfillment provider | Select Printful shipping option in Admin locations |
85
+ | Status | `GET /admin/printful/status` + product list widget |
86
+ | Shipment tracking | Printful webhooks → Medusa fulfillment + shipment per parcel, with tracking |
87
+ | Order visibility | Printful status and per-parcel tracking on the Admin order page |
76
88
 
77
89
  ### Idempotency
78
90
 
79
91
  - Re-sync updates existing products via link tables (no duplicates) and **upserts variants** — price and assortment changes in Printful reach Medusa; manually-added Medusa variants are left untouched.
80
92
  - Concurrent / re-fired payment events will not create a second Printful order: the order is **claimed insert-first** via a unique index on `printful_order_link.medusa_order_id` before the Printful API is called.
81
93
  - Shipping `province` is normalized to the 2-letter `state_code` Printful expects for US/CA.
94
+ - Printful redelivers webhooks by design. Each event is stored under a **derived `event_id`** carrying a unique index, so a redelivery is absorbed rather than producing a second fulfillment. Delivery metadata (`retries`, `store`) is excluded from that id — otherwise the same event would hash differently on each attempt.
95
+ - Events for one order are **serialized with a transaction-scoped advisory lock**, so two events cannot both pass the "shipment not yet recorded" check and each create a fulfillment for one parcel.
96
+
97
+ ## Webhooks
98
+
99
+ Printful notifies the store of fulfillment progress (`package_shipped`,
100
+ `order_failed`, `order_canceled`, `package_returned`) at:
101
+
102
+ ```
103
+ POST /hooks/printful/<webhookSecret>
104
+ ```
105
+
106
+ Set the secret as a plugin option, then register the endpoint with Printful:
107
+
108
+ ```ts
109
+ options: {
110
+ apiToken: process.env.PRINTFUL_API_TOKEN,
111
+ webhookSecret: process.env.PRINTFUL_WEBHOOK_SECRET, // long, random
112
+ }
113
+ ```
114
+
115
+ ```bash
116
+ curl -X POST https://your-store.com/admin/printful/webhook \
117
+ -H 'content-type: application/json' \
118
+ -d '{"base_url":"https://your-store.com"}'
119
+ ```
120
+
121
+ The payload is treated as a **trigger, not a source of truth**: the endpoint
122
+ stores the event, answers `200`, and the workflow re-reads
123
+ `GET /orders/{id}` from Printful for the authoritative state.
124
+
125
+ ### The secret is in the URL path
126
+
127
+ Printful API v1's webhook configuration accepts only `url`, `types` and
128
+ `params` — there is no custom-header support — so the shared secret has to
129
+ travel as a path segment. That has consequences worth planning around.
130
+
131
+ **Treat the secret as rotatable, and expect it in access logs.** Any reverse
132
+ proxy, load balancer, or CDN in front of Medusa logs request paths by default,
133
+ and that is entirely outside this plugin's control. Anyone who can read those
134
+ logs can forge webhook deliveries.
135
+
136
+ Mitigations, in rough order of value:
137
+
138
+ - **Scope it.** The secret only authenticates Printful's callback. It grants no
139
+ API access, and because payloads are re-verified against Printful's API, a
140
+ forged delivery cannot invent a shipment — at worst it triggers a redundant
141
+ re-read.
142
+ - **Strip it at the proxy.** If your proxy supports rewriting logged paths, mask
143
+ the segment after `/hooks/printful/`.
144
+ - **Rotate it** on any suspected log exposure, and on staff offboarding.
145
+
146
+ ### Rotating the secret
147
+
148
+ 1. Change `webhookSecret` to a new random value and restart Medusa.
149
+ 2. Re-register with Printful so it stops calling the old URL:
150
+ ```bash
151
+ curl -X POST https://your-store.com/admin/printful/webhook \
152
+ -H 'content-type: application/json' \
153
+ -d '{"base_url":"https://your-store.com"}'
154
+ ```
155
+
156
+ Printful keeps **one webhook configuration per store**, so step 2 replaces the
157
+ previous URL outright — the old secret stops being accepted as soon as Medusa
158
+ restarts. Deliveries in flight during the swap are retried by Printful, and
159
+ duplicate events are absorbed by the stored `event_id`, so rotation is safe to
160
+ perform in production.
161
+
162
+ `GET /admin/printful/webhook` shows the registered URL with the secret masked,
163
+ so the admin UI can confirm the configuration without re-exposing the token.
164
+
165
+ ### Request logging
166
+
167
+ Errors raised by this route (`404` bad token, `400` malformed payload, `500`
168
+ storage failure) are logged with the secret replaced by `[redacted]`, since
169
+ Medusa's error handler logs the request path verbatim.
170
+
171
+ One gap remains and cannot be closed from plugin code: errors thrown by
172
+ Medusa's **global body parser** — an oversized body or malformed JSON — reach
173
+ the error handler without running any route-scoped middleware, so those log
174
+ lines contain the real path. The endpoint's body limit is therefore raised to
175
+ **1 MB**, well above the largest realistic delivery (a 50-line-item
176
+ `package_shipped` measures ~262 KB; the framework default of 100 KB is in fact
177
+ exceeded by roughly a 25-item order), so genuine Printful traffic does not
178
+ reach that path. This is another reason to treat the secret as rotatable.
82
179
 
83
180
  ## Admin usage
84
181
 
@@ -103,24 +200,33 @@ In a host Medusa app:
103
200
  npx medusa plugin:add @legenki/print2medusa
104
201
  ```
105
202
 
203
+ ## Roadmap
204
+
205
+ See [ROADMAP.md](./ROADMAP.md) for the planned path from `0.2.0` (webhooks and
206
+ order status) through `1.0.0` (stable API and Printful v2 migration), including
207
+ the testing strategy for each release.
208
+
106
209
  ## Architecture notes
107
210
 
108
211
  - **Printful is source of truth** for products; Medusa holds a copy + links.
109
212
  - Printful API **v1** (`https://api.printful.com`).
110
213
  - Long-running sync runs as a Medusa **workflow** (not a blocking HTTP body only—route awaits the workflow today; can be queued later).
111
- - Webhooks / multi-store / live rates: planned Phase 2.
214
+ - Webhooks carry their secret in the URL path because Printful v1 supports no
215
+ custom headers — see [Webhooks](#webhooks).
216
+ - Multi-store / live rates: planned Phase 2.
112
217
 
113
218
  ## Options
114
219
 
115
- | Option | Description |
116
- |--------|-------------|
117
- | `apiToken` | Printful private token (required) |
118
- | `storeId` | `X-PF-Store-Id` for account-level tokens |
119
- | `autoSubmitOrders` | Confirm orders for fulfillment (default true) |
120
- | `createOnOrderPlaced` | Also create Printful order on `order.placed` |
121
- | `allowPartialOrders` | Allow orders that mix Printful + non-Printful items |
122
- | `markupPercent` | Markup on retail prices during sync |
123
- | `defaultCurrency` | Fallback currency code |
220
+ | Option | Description |
221
+ | --------------------- | ----------------------------------------------------------------------- |
222
+ | `apiToken` | Printful private token (required) |
223
+ | `storeId` | `X-PF-Store-Id` for account-level tokens |
224
+ | `autoSubmitOrders` | Confirm orders for fulfillment (default true) |
225
+ | `createOnOrderPlaced` | Also create Printful order on `order.placed` |
226
+ | `allowPartialOrders` | Allow orders that mix Printful + non-Printful items |
227
+ | `markupPercent` | Markup on retail prices during sync |
228
+ | `defaultCurrency` | Fallback currency code |
229
+ | `webhookSecret` | Shared secret for the Printful webhook path (see [Webhooks](#webhooks)) |
124
230
 
125
231
  ## License
126
232
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@legenki/print2medusa",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Printful → Medusa v2 plugin: product sync, auto fulfillment, and admin tools",
5
5
  "author": "Andy Legenki",
6
6
  "license": "MIT",
@@ -42,8 +42,12 @@
42
42
  "build": "medusa plugin:build",
43
43
  "dev": "medusa plugin:develop",
44
44
  "lint": "medusa lint",
45
+ "format": "prettier --write .",
46
+ "format:check": "prettier --check .",
45
47
  "typecheck": "tsc --noEmit",
48
+ "typecheck:tests": "tsc --noEmit -p tsconfig.tests.json",
46
49
  "test": "vitest run",
50
+ "test:integration": "vitest run --config vitest.integration.config.ts",
47
51
  "test:watch": "vitest",
48
52
  "prepublishOnly": "medusa plugin:build"
49
53
  },
@@ -62,6 +66,7 @@
62
66
  "@types/react-dom": "^18.2.25",
63
67
  "eslint": "^9.0.0",
64
68
  "jiti": "^2.0.0",
69
+ "prettier": "^3.3.0",
65
70
  "prop-types": "^15.8.1",
66
71
  "react": "^18.2.0",
67
72
  "react-dom": "^18.2.0",