create-cartbase 0.1.16 → 0.1.17

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 (34) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +25 -25
  3. package/dist/index.js +20 -20
  4. package/package.json +24 -24
  5. package/template/app/docs/auth.md +105 -105
  6. package/template/app/docs/carts.md +376 -376
  7. package/template/app/docs/categories.md +194 -194
  8. package/template/app/docs/checkout.md +714 -714
  9. package/template/app/docs/components.md +44 -11
  10. package/template/app/docs/consent.md +91 -91
  11. package/template/app/docs/deploy.md +197 -197
  12. package/template/app/docs/gift-cards.md +153 -153
  13. package/template/app/docs/metaobjects.md +126 -126
  14. package/template/app/docs/orders.md +221 -221
  15. package/template/app/docs/products.md +51 -2
  16. package/template/app/docs/regions.md +269 -269
  17. package/template/app/docs/reviews.md +223 -223
  18. package/template/app/docs/search.md +227 -227
  19. package/template/app/docs/store.md +47 -47
  20. package/template/app/docs/subscriptions.md +148 -148
  21. package/template/app/docs/variables.md +315 -315
  22. package/template/app/package.json +1 -1
  23. package/template/app/postcss.config.cjs +11 -11
  24. package/template/app/src/app/checkout/checkout-page-client.tsx +73 -73
  25. package/template/app/src/app/checkout/page.tsx +48 -48
  26. package/template/app/src/app/globals.css +26 -26
  27. package/template/app/src/app/page.tsx +28 -28
  28. package/template/app/src/app/products/[handle]/page.tsx +87 -87
  29. package/template/app/src/app/providers.tsx +64 -64
  30. package/template/app/src/app/search/page.tsx +23 -23
  31. package/template/app/src/lib/browser-client.ts +35 -35
  32. package/template/app/src/lib/config.ts +41 -41
  33. package/template/app/src/lib/server-client.ts +25 -25
  34. package/template/app/src/lib/cart-actions.ts +0 -47
@@ -1,221 +1,221 @@
1
- # Orders
2
-
3
- The authenticated customer's order surface: list, detail (items +
4
- fulfillments with tracking + addresses) and order transfers. There is **no
5
- anonymous order read** — every endpoint on this page requires a customer
6
- session (`authorization: Bearer <jwt>`, obtained via the passwordless code
7
- flow — see [auth.md](auth.md)); a guest's only order handle is the
8
- `completeCart()` response ([checkout.md](checkout.md)). Missing/invalid
9
- JWT → `401 {code: "unauthenticated"}` — demonstrated executably below;
10
- happy-path shapes are shown as jsonc (a customer session is admin-owned
11
- state on the shared tenant; the authenticated paths are proven by
12
- `tests/store/customer-accounts-ownership.test.ts` and the transfer suite).
13
-
14
- **Auth for every endpoint on this page:** `x-client-id` + Bearer JWT.
15
-
16
- SDK module: `@cartbase/storefront/api/orders`.
17
-
18
- ---
19
-
20
- ## GET /api/store/orders/display/:displayId — my order by its human number
21
-
22
- - **Purpose** — resolve "order #42" (the number customers see in emails and
23
- confirmations) to the full order detail; account order pages and support
24
- links use this instead of the opaque id.
25
- - **Auth** — Bearer JWT required, same ownership scope as the id read:
26
- another customer's number is `404 not_found`. `display_id` is a guessable
27
- autoincrement — that is exactly why there is no anonymous lookup.
28
- - **Request** — `GET`, no query. A numeric segment matches the
29
- autoincrement `display_id` OR a purely-numeric `custom_display_id`
30
- (custom wins ties — it is the number the store actually showed); any
31
- other segment matches `custom_display_id` only.
32
- - **Response** — identical `{order}` detail shape to
33
- `GET /api/store/orders/:id` above.
34
- - **Errors** — 401 `unauthenticated`; 404 `not_found`.
35
- - **SDK** — `orders.retrieveOrderByDisplayId(client, displayId)`.
36
- - **Components** — order-confirmation deep links, account order detail.
37
- - **Settings** — `custom_display_id` is set by merchants/imports (admin
38
- surface); absent it, only the autoincrement resolves.
39
-
40
- ```bash
41
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
42
- "$BASE/api/store/orders/display/42" -H "x-client-id: $CLIENT_ID")
43
- test "$STATUS" = 401
44
- ```
45
-
46
- ---
47
-
48
- ## GET /api/store/orders — list my orders
49
-
50
- - **Purpose** — the account "My orders" list, newest first.
51
- - **Auth** — Bearer JWT required. Scoped to the session customer AND the
52
- tenant — another customer's orders are invisible (not 403).
53
- - **Request** — `GET ?limit=&offset=&status=` — `limit` 1–200 (default
54
- 20), `offset` ≥ 0 (default 0), `status` exact-match filter (`pending`,
55
- `completed`, `canceled`, …).
56
- - **Response** — plain order rows (NO embeds — fetch the detail for
57
- items/tracking):
58
-
59
- ```jsonc
60
- {
61
- "orders": [{
62
- "id": "order_…",
63
- "display_id": 42, // human-facing autoincrement
64
- "status": "pending",
65
- "email": "jane@example.com",
66
- "currency_code": "eur",
67
- "customer_id": "cus_…",
68
- "sales_channel_id": null,
69
- "region_id": "reg_…",
70
- "metadata": {}, // internal staff notes NEVER appear here
71
- "created_at": "2026-07-20T…", "updated_at": "2026-07-20T…"
72
- }],
73
- "count": 1, "offset": 0, "limit": 20
74
- }
75
- ```
76
-
77
- - **Errors** — 401 `unauthenticated`; 400 `validation_failed` (bad
78
- limit/offset).
79
- - **SDK** — `orders.listOrders(client, {limit, offset, status})`.
80
- - **Components** — account order-history page.
81
- - **Settings** — none (ownership is structural).
82
-
83
- ```bash
84
- # Executable auth contract: no Bearer → 401 unauthenticated.
85
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
86
- "$BASE/api/store/orders" -H "x-client-id: $CLIENT_ID")
87
- test "$STATUS" = 401
88
- ```
89
-
90
- ---
91
-
92
- ## GET /api/store/orders/:id — my order, in full
93
-
94
- - **Purpose** — the order-detail page: items, fulfillment lifecycle +
95
- tracking, both addresses.
96
- - **Auth** — Bearer JWT required. Ownership is part of the lookup: an
97
- unknown id and another customer's order are BOTH `404 not_found`
98
- (indistinguishable — no existence oracle).
99
- - **Request** — `GET`, no query.
100
- - **Response** — a store-safe subset of the admin order select (no
101
- customer row, no payment internals, no internal timeline comments):
102
-
103
- ```jsonc
104
- {
105
- "order": {
106
- "id": "order_…", "display_id": 42, "status": "pending",
107
- "email": "jane@example.com", "currency_code": "eur",
108
- "items": [{ // version pivot + embedded line item
109
- "id": "oi_…", "order_id": "order_…", "quantity": 1,
110
- "line_item": {
111
- "id": "oli_…", "title": "M", "product_title": "Linen Shirt",
112
- "product_handle": "linen-shirt", "thumbnail": "https://…",
113
- "variant_id": "variant_…", "variant_sku": "LIN-SHIRT-M",
114
- "unit_price": 45, "metadata": null
115
- }
116
- }],
117
- "fulfillments": [{ // couriers surface
118
- "fulfillment": {
119
- "id": "ful_…",
120
- "packed_at": "2026-07-20T…", "shipped_at": null,
121
- "delivered_at": null, "canceled_at": null,
122
- "labels": [{ "tracking_number": "…", "tracking_url": "https://…" }]
123
- }
124
- }],
125
- "shipping_address": { "first_name": "Jane", "city": "Sofia", "…": "order_addresses row" },
126
- "billing_address": { "…": "order_addresses row" }
127
- }
128
- }
129
- ```
130
-
131
- - **Errors** — 401 `unauthenticated`; 404 `not_found`.
132
- - **SDK** — `orders.retrieveOrder(client, orderId)`.
133
- - **Components** — order-detail page, tracking widget.
134
- - **Settings** — fulfillment/tracking data appears as the merchant packs
135
- and ships (admin fulfillment flow + carrier labels).
136
-
137
- ```bash
138
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
139
- "$BASE/api/store/orders/order_any" -H "x-client-id: $CLIENT_ID")
140
- test "$STATUS" = 401
141
- ```
142
-
143
- ---
144
-
145
- ## Order transfers — claim a (guest) order into my account
146
-
147
- Flow: the **claiming** customer calls `request` on an order that is not
148
- theirs → a one-time token is stored for the order's email holder (email
149
- dispatch pending server-side) → the claimer, whose account email must
150
- EQUAL the order's email, calls `accept` with the token. `decline` (the
151
- recipient rejects, token required) or `cancel` (the requester withdraws)
152
- end a pending transfer. One pending transfer per order — a duplicate
153
- `request` acknowledges with the same `{transfer: {requested: true}}` shape
154
- without creating another. **The token never crosses this surface** — it is
155
- email-delivered only (pinned by
156
- `tests/store/order-transfer-token-leak.test.ts`).
157
-
158
- ### POST /api/store/orders/:id/transfer/request
159
-
160
- - **Request** — `{description?}`.
161
- - **Response** — `200 {"order":{"id":"order_…","transfer":{"requested":true}}}`
162
- (or the existing pending action object on a duplicate request).
163
- - **Errors** — 401 `unauthenticated`; 404 `not_found`; 400 `already_owned`
164
- (the order already belongs to the caller).
165
- - **SDK** — `orders.requestOrderTransfer(client, orderId, {description})`.
166
-
167
- ### POST /api/store/orders/:id/transfer/accept
168
-
169
- - **Request** — `{token}` (≥16 chars, from the transfer email).
170
- - **Response** — `200 {order}` — the full updated order row, now owned by
171
- the caller.
172
- - **Errors** — 401 `unauthenticated`; 404 `not_found` (order / no pending
173
- transfer); 403 `invalid_token` | `email_mismatch` (the caller's email
174
- must match the order's original email — a leaked token alone is not
175
- enough); 400 `validation_failed`.
176
- - **SDK** — `orders.acceptOrderTransfer(client, orderId, {token})`.
177
-
178
- ### POST /api/store/orders/:id/transfer/decline
179
-
180
- - **Request** — `{token}`.
181
- - **Response** — `200 {"order":{"id":"order_…","transfer":{"declined":true}}}`.
182
- - **Errors** — 401 `unauthenticated`; 404 `not_found`; 403 `invalid_token`;
183
- 400 `validation_failed`.
184
- - **SDK** — `orders.declineOrderTransfer(client, orderId, {token})`.
185
-
186
- ### POST /api/store/orders/:id/transfer/cancel
187
-
188
- - **Request** — no body.
189
- - **Response** — `200 {"order":{"id":"order_…","transfer":{"canceled":true}}}`.
190
- - **Errors** — 401 `unauthenticated`; 404 `not_found`; 403 `forbidden`
191
- (only the requester may cancel).
192
- - **SDK** — `orders.cancelOrderTransfer(client, orderId)`.
193
- - **Components** — account "claim this order" flow + the transfer email's
194
- accept/decline landing page.
195
-
196
- ```bash
197
- # Executable auth contract for the transfer family (all four Bearer-gated).
198
- # Note the ordering nuance: accept/decline validate the BODY before auth,
199
- # so a missing token 400s even for guests; request/cancel hit auth first.
200
- for EP in transfer/request transfer/cancel; do
201
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
202
- "$BASE/api/store/orders/order_any/$EP" \
203
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
204
- test "$STATUS" = 401
205
- done
206
- for EP in transfer/accept transfer/decline; do
207
- RES=$(curl -s -X POST "$BASE/api/store/orders/order_any/$EP" \
208
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
209
- echo "$RES" | grep -q '"code":"validation_failed"'
210
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
211
- "$BASE/api/store/orders/order_any/$EP" \
212
- -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
213
- -d '{"token":"0123456789abcdef0123456789abcdef"}')
214
- test "$STATUS" = 401
215
- done
216
- ```
217
-
218
- ## Cleanup / accretion note
219
-
220
- This page creates nothing — every executable block is a read/auth-contract
221
- probe against nonexistent ids.
1
+ # Orders
2
+
3
+ The authenticated customer's order surface: list, detail (items +
4
+ fulfillments with tracking + addresses) and order transfers. There is **no
5
+ anonymous order read** — every endpoint on this page requires a customer
6
+ session (`authorization: Bearer <jwt>`, obtained via the passwordless code
7
+ flow — see [auth.md](auth.md)); a guest's only order handle is the
8
+ `completeCart()` response ([checkout.md](checkout.md)). Missing/invalid
9
+ JWT → `401 {code: "unauthenticated"}` — demonstrated executably below;
10
+ happy-path shapes are shown as jsonc (a customer session is admin-owned
11
+ state on the shared tenant; the authenticated paths are proven by
12
+ `tests/store/customer-accounts-ownership.test.ts` and the transfer suite).
13
+
14
+ **Auth for every endpoint on this page:** `x-client-id` + Bearer JWT.
15
+
16
+ SDK module: `@cartbase/storefront/api/orders`.
17
+
18
+ ---
19
+
20
+ ## GET /api/store/orders/display/:displayId — my order by its human number
21
+
22
+ - **Purpose** — resolve "order #42" (the number customers see in emails and
23
+ confirmations) to the full order detail; account order pages and support
24
+ links use this instead of the opaque id.
25
+ - **Auth** — Bearer JWT required, same ownership scope as the id read:
26
+ another customer's number is `404 not_found`. `display_id` is a guessable
27
+ autoincrement — that is exactly why there is no anonymous lookup.
28
+ - **Request** — `GET`, no query. A numeric segment matches the
29
+ autoincrement `display_id` OR a purely-numeric `custom_display_id`
30
+ (custom wins ties — it is the number the store actually showed); any
31
+ other segment matches `custom_display_id` only.
32
+ - **Response** — identical `{order}` detail shape to
33
+ `GET /api/store/orders/:id` above.
34
+ - **Errors** — 401 `unauthenticated`; 404 `not_found`.
35
+ - **SDK** — `orders.retrieveOrderByDisplayId(client, displayId)`.
36
+ - **Components** — order-confirmation deep links, account order detail.
37
+ - **Settings** — `custom_display_id` is set by merchants/imports (admin
38
+ surface); absent it, only the autoincrement resolves.
39
+
40
+ ```bash
41
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
42
+ "$BASE/api/store/orders/display/42" -H "x-client-id: $CLIENT_ID")
43
+ test "$STATUS" = 401
44
+ ```
45
+
46
+ ---
47
+
48
+ ## GET /api/store/orders — list my orders
49
+
50
+ - **Purpose** — the account "My orders" list, newest first.
51
+ - **Auth** — Bearer JWT required. Scoped to the session customer AND the
52
+ tenant — another customer's orders are invisible (not 403).
53
+ - **Request** — `GET ?limit=&offset=&status=` — `limit` 1–200 (default
54
+ 20), `offset` ≥ 0 (default 0), `status` exact-match filter (`pending`,
55
+ `completed`, `canceled`, …).
56
+ - **Response** — plain order rows (NO embeds — fetch the detail for
57
+ items/tracking):
58
+
59
+ ```jsonc
60
+ {
61
+ "orders": [{
62
+ "id": "order_…",
63
+ "display_id": 42, // human-facing autoincrement
64
+ "status": "pending",
65
+ "email": "jane@example.com",
66
+ "currency_code": "eur",
67
+ "customer_id": "cus_…",
68
+ "sales_channel_id": null,
69
+ "region_id": "reg_…",
70
+ "metadata": {}, // internal staff notes NEVER appear here
71
+ "created_at": "2026-07-20T…", "updated_at": "2026-07-20T…"
72
+ }],
73
+ "count": 1, "offset": 0, "limit": 20
74
+ }
75
+ ```
76
+
77
+ - **Errors** — 401 `unauthenticated`; 400 `validation_failed` (bad
78
+ limit/offset).
79
+ - **SDK** — `orders.listOrders(client, {limit, offset, status})`.
80
+ - **Components** — account order-history page.
81
+ - **Settings** — none (ownership is structural).
82
+
83
+ ```bash
84
+ # Executable auth contract: no Bearer → 401 unauthenticated.
85
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
86
+ "$BASE/api/store/orders" -H "x-client-id: $CLIENT_ID")
87
+ test "$STATUS" = 401
88
+ ```
89
+
90
+ ---
91
+
92
+ ## GET /api/store/orders/:id — my order, in full
93
+
94
+ - **Purpose** — the order-detail page: items, fulfillment lifecycle +
95
+ tracking, both addresses.
96
+ - **Auth** — Bearer JWT required. Ownership is part of the lookup: an
97
+ unknown id and another customer's order are BOTH `404 not_found`
98
+ (indistinguishable — no existence oracle).
99
+ - **Request** — `GET`, no query.
100
+ - **Response** — a store-safe subset of the admin order select (no
101
+ customer row, no payment internals, no internal timeline comments):
102
+
103
+ ```jsonc
104
+ {
105
+ "order": {
106
+ "id": "order_…", "display_id": 42, "status": "pending",
107
+ "email": "jane@example.com", "currency_code": "eur",
108
+ "items": [{ // version pivot + embedded line item
109
+ "id": "oi_…", "order_id": "order_…", "quantity": 1,
110
+ "line_item": {
111
+ "id": "oli_…", "title": "M", "product_title": "Linen Shirt",
112
+ "product_handle": "linen-shirt", "thumbnail": "https://…",
113
+ "variant_id": "variant_…", "variant_sku": "LIN-SHIRT-M",
114
+ "unit_price": 45, "metadata": null
115
+ }
116
+ }],
117
+ "fulfillments": [{ // couriers surface
118
+ "fulfillment": {
119
+ "id": "ful_…",
120
+ "packed_at": "2026-07-20T…", "shipped_at": null,
121
+ "delivered_at": null, "canceled_at": null,
122
+ "labels": [{ "tracking_number": "…", "tracking_url": "https://…" }]
123
+ }
124
+ }],
125
+ "shipping_address": { "first_name": "Jane", "city": "Sofia", "…": "order_addresses row" },
126
+ "billing_address": { "…": "order_addresses row" }
127
+ }
128
+ }
129
+ ```
130
+
131
+ - **Errors** — 401 `unauthenticated`; 404 `not_found`.
132
+ - **SDK** — `orders.retrieveOrder(client, orderId)`.
133
+ - **Components** — order-detail page, tracking widget.
134
+ - **Settings** — fulfillment/tracking data appears as the merchant packs
135
+ and ships (admin fulfillment flow + carrier labels).
136
+
137
+ ```bash
138
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
139
+ "$BASE/api/store/orders/order_any" -H "x-client-id: $CLIENT_ID")
140
+ test "$STATUS" = 401
141
+ ```
142
+
143
+ ---
144
+
145
+ ## Order transfers — claim a (guest) order into my account
146
+
147
+ Flow: the **claiming** customer calls `request` on an order that is not
148
+ theirs → a one-time token is stored for the order's email holder (email
149
+ dispatch pending server-side) → the claimer, whose account email must
150
+ EQUAL the order's email, calls `accept` with the token. `decline` (the
151
+ recipient rejects, token required) or `cancel` (the requester withdraws)
152
+ end a pending transfer. One pending transfer per order — a duplicate
153
+ `request` acknowledges with the same `{transfer: {requested: true}}` shape
154
+ without creating another. **The token never crosses this surface** — it is
155
+ email-delivered only (pinned by
156
+ `tests/store/order-transfer-token-leak.test.ts`).
157
+
158
+ ### POST /api/store/orders/:id/transfer/request
159
+
160
+ - **Request** — `{description?}`.
161
+ - **Response** — `200 {"order":{"id":"order_…","transfer":{"requested":true}}}`
162
+ (or the existing pending action object on a duplicate request).
163
+ - **Errors** — 401 `unauthenticated`; 404 `not_found`; 400 `already_owned`
164
+ (the order already belongs to the caller).
165
+ - **SDK** — `orders.requestOrderTransfer(client, orderId, {description})`.
166
+
167
+ ### POST /api/store/orders/:id/transfer/accept
168
+
169
+ - **Request** — `{token}` (≥16 chars, from the transfer email).
170
+ - **Response** — `200 {order}` — the full updated order row, now owned by
171
+ the caller.
172
+ - **Errors** — 401 `unauthenticated`; 404 `not_found` (order / no pending
173
+ transfer); 403 `invalid_token` | `email_mismatch` (the caller's email
174
+ must match the order's original email — a leaked token alone is not
175
+ enough); 400 `validation_failed`.
176
+ - **SDK** — `orders.acceptOrderTransfer(client, orderId, {token})`.
177
+
178
+ ### POST /api/store/orders/:id/transfer/decline
179
+
180
+ - **Request** — `{token}`.
181
+ - **Response** — `200 {"order":{"id":"order_…","transfer":{"declined":true}}}`.
182
+ - **Errors** — 401 `unauthenticated`; 404 `not_found`; 403 `invalid_token`;
183
+ 400 `validation_failed`.
184
+ - **SDK** — `orders.declineOrderTransfer(client, orderId, {token})`.
185
+
186
+ ### POST /api/store/orders/:id/transfer/cancel
187
+
188
+ - **Request** — no body.
189
+ - **Response** — `200 {"order":{"id":"order_…","transfer":{"canceled":true}}}`.
190
+ - **Errors** — 401 `unauthenticated`; 404 `not_found`; 403 `forbidden`
191
+ (only the requester may cancel).
192
+ - **SDK** — `orders.cancelOrderTransfer(client, orderId)`.
193
+ - **Components** — account "claim this order" flow + the transfer email's
194
+ accept/decline landing page.
195
+
196
+ ```bash
197
+ # Executable auth contract for the transfer family (all four Bearer-gated).
198
+ # Note the ordering nuance: accept/decline validate the BODY before auth,
199
+ # so a missing token 400s even for guests; request/cancel hit auth first.
200
+ for EP in transfer/request transfer/cancel; do
201
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
202
+ "$BASE/api/store/orders/order_any/$EP" \
203
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
204
+ test "$STATUS" = 401
205
+ done
206
+ for EP in transfer/accept transfer/decline; do
207
+ RES=$(curl -s -X POST "$BASE/api/store/orders/order_any/$EP" \
208
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" -d '{}')
209
+ echo "$RES" | grep -q '"code":"validation_failed"'
210
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
211
+ "$BASE/api/store/orders/order_any/$EP" \
212
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
213
+ -d '{"token":"0123456789abcdef0123456789abcdef"}')
214
+ test "$STATUS" = 401
215
+ done
216
+ ```
217
+
218
+ ## Cleanup / accretion note
219
+
220
+ This page creates nothing — every executable block is a read/auth-contract
221
+ probe against nonexistent ids.
@@ -49,7 +49,8 @@ foreign key → 400 `invalid_publishable_key`.
49
49
  "currency_code": "eur", // pricing context
50
50
  "region_id": "reg_…", // or region → currency
51
51
  "limit": 50, // 1–200, default 50
52
- "offset": 0
52
+ "offset": 0,
53
+ "fields": "*variants.inventory_items,…" // Medusa's field selection: only the kit links change the answer (below)
53
54
  }
54
55
  ```
55
56
 
@@ -150,6 +151,53 @@ curl -sf "$BASE/api/store/products?handle=linen-shirt" -H "x-client-id: $CLIENT_
150
151
  | grep -q '"handle":"linen-shirt"'
151
152
  ```
152
153
 
154
+ ### Sets: a variant's inventory kit (`fields`)
155
+
156
+ A set is an inventory kit, Medusa's model: the set's variant draws on the
157
+ stock items of the products inside it, each with a `required_quantity`.
158
+ Ask for the links the way Medusa's store API is asked, on the list or the
159
+ single product:
160
+
161
+ ```
162
+ fields=*variants.inventory_items,*variants.inventory_items.inventory,*variants.inventory_items.inventory.variants,*variants.inventory_items.inventory.variants.product
163
+ ```
164
+
165
+ Any `fields` entry naming `variants.inventory_items` adds `inventory_items`
166
+ to every variant (an empty list when it has none); nothing else in `fields`
167
+ changes the answer, which is always the full shape above. It costs no extra
168
+ round trip.
169
+
170
+ ```jsonc
171
+ "inventory_items": [
172
+ {
173
+ "variant_id": "variant_…", // the variant being read
174
+ "inventory_item_id": "iitem_…",
175
+ "required_quantity": 2, // units of this item one variant takes
176
+ "inventory": {
177
+ "id": "iitem_…", "sku": "BELT-01", "title": "Leather Belt", "thumbnail": null,
178
+ // every PUBLISHED variant drawing on this item: the product that owns it
179
+ // and every set containing it (a draft set is never listed)
180
+ "variants": [
181
+ { "id": "variant_…", "product_id": "prod_…", "title": "Default", "sku": "BELT-01",
182
+ "product": { "id": "prod_…", "title": "Leather Belt", "handle": "leather-belt", "thumbnail": null } }
183
+ ]
184
+ }
185
+ }
186
+ ]
187
+ ```
188
+
189
+ A set is a variant with links to items other products own: its contents are
190
+ those owners. The sets a product belongs to are the other variants on its
191
+ own item.
192
+
193
+ ```bash
194
+ # The key rides only when fields asks for it.
195
+ curl -sf "$BASE/api/store/products/linen-shirt?fields=*variants.inventory_items.inventory.variants.product" \
196
+ -H "x-client-id: $CLIENT_ID" | grep -q '"inventory_items"'
197
+ if curl -sf "$BASE/api/store/products/linen-shirt" -H "x-client-id: $CLIENT_ID" \
198
+ | grep -q '"inventory_items"'; then exit 1; fi
199
+ ```
200
+
153
201
  - **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
154
202
  400 `invalid_publishable_key`, 400 `invalid_region`.
155
203
  - **SDK** — `listProducts(client, query?)`.
@@ -164,7 +212,8 @@ curl -sf "$BASE/api/store/products?handle=linen-shirt" -H "x-client-id: $CLIENT_
164
212
  - **Purpose** — the PDP read. Accepts a product id (`prod_…`) or a handle —
165
213
  storefronts deep-link by handle.
166
214
  - **Auth** — as the list.
167
- - **Request** — query: pricing context only (`currency_code` / `region_id`).
215
+ - **Request** — query: pricing context (`currency_code` / `region_id`) and
216
+ `fields` for a set's inventory kit (see Sets above).
168
217
  - **Response** — `{ "product": { ...shape above... } }`
169
218
  - **Working curl** — with pricing context; asserts the seeded base price
170
219
  (Linen Shirt, EUR 45) and the leak rule structurally: