create-cartbase 0.1.14 → 0.1.16

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.
@@ -1,307 +1,316 @@
1
- # Products
2
-
3
- The catalog read surface. The product object documented here is THE
4
- canonical shape every discovery endpoint reuses (search results, collection
5
- membership pages, related products) — build one product-card renderer
6
- against it. Money is EUR decimal major units.
7
-
8
- SDK module: `@cartbase/storefront/api/products`.
9
-
10
- **Pricing context (applies to every endpoint here):** pass `currency_code`
11
- (or `region_id` — its region's currency is used; unknown region → 400
12
- `invalid_region`) to receive `variant.calculated_price`. Without either,
13
- `calculated_price` is `null` and only raw base prices are listed. Customer
14
- groups come ONLY from the optional `authorization: Bearer <jwt>` — never
15
- from params. **Cache rule:** any response carrying `calculated_price` varies
16
- by customer group — never cache it shared when a JWT was present.
17
-
18
- **Leak rule:** raw price rows in store responses contain ONLY base
19
- (non-price-list) rows. Price-list amounts surface exclusively through
20
- `calculated_price` for the caller's own context.
21
-
22
- **Publishable-key channel scope:** sending `x-publishable-api-key` restricts
23
- the catalog to products linked to the key's sales channels — a product
24
- outside them **404s on retrieve and disappears from lists** (invisible, not
25
- forbidden). A key with no channel links scopes nothing. Unknown/revoked/
26
- foreign key → 400 `invalid_publishable_key`.
27
-
28
- ---
29
-
30
- ## GET /api/store/products
31
-
32
- - **Purpose** — the product listing: catalog pages, filtered grids.
33
- Published products only (drafts are RLS-invisible).
34
- - **Auth** — anon: `x-client-id` required; `x-publishable-api-key` optional
35
- (channel scope); `authorization: Bearer <jwt>` optional (group pricing).
36
- - **Request**
37
-
38
- ```jsonc
39
- // query (all optional)
40
- {
41
- "q": "linen", // case-insensitive substring on TITLE only
42
- "id": "prod_a,prod_b", // CSV of ids
43
- "handle": "linen-shirt", // exact
44
- "collection_id": "pcol_a", // CSV — products.collection_id (primary collection)
45
- "category_id": "pcat_a", // CSV — category membership
46
- "tag_id": "ptag_a", // CSV — tag membership
47
- "type_id": "ptyp_a", // CSV
48
- "order": "-created_at", // sort column, "-" prefix = desc (default -created_at)
49
- "currency_code": "eur", // pricing context
50
- "region_id": "reg_…", // or region → currency
51
- "limit": 50, // 1–200, default 50
52
- "offset": 0
53
- }
54
- ```
55
-
56
- - **Response** — `{ products, count, offset, limit }`; each product:
57
-
58
- ```jsonc
59
- {
60
- "id": "prod_01tst00000000000000000001",
61
- "title": "Linen Shirt",
62
- "subtitle": null,
63
- "description": "Lightweight linen shirt, relaxed fit.",
64
- "handle": "linen-shirt",
65
- "status": "published", // always — drafts never appear
66
- "thumbnail": "https://…/600/800",
67
- "is_giftcard": false,
68
- "discountable": true,
69
- "collection_id": "pcol_01tst00000000000000000001",
70
- "type_id": null,
71
- "external_id": null,
72
- "vendor": null, // the brand, plain text
73
- "weight": null, "length": null, "height": null, "width": null,
74
- "hs_code": null, "origin_country": null, "mid_code": null, "material": null,
75
- "seo_title": null, // null = fall back to title
76
- "seo_description": null, // null = fall back to plain-text description
77
- "metadata": null,
78
- "created_at": "2026-07-01T00:00:00.000Z",
79
- "updated_at": "2026-07-01T00:00:00.000Z",
80
- "variants": [
81
- {
82
- "id": "variant_01tst000000000000000001",
83
- "title": "S",
84
- "product_id": "prod_01tst00000000000000000001",
85
- "sku": "LIN-SHIRT-S",
86
- "barcode": null, "ean": null, "upc": null,
87
- "thumbnail": null,
88
- "allow_backorder": false,
89
- "manage_inventory": true,
90
- // Computed server-side by the platform's availability predicate:
91
- // untracked or backorderable variants are always true; otherwise true
92
- // iff available stock (kit-aware, reservations subtracted) is above
93
- // zero. Read this — never re-derive stock client-side (the exact
94
- // quantity is deliberately not exposed).
95
- "in_stock": true,
96
- "variant_rank": 0,
97
- "metadata": null,
98
- "options": [
99
- { "value": { "id": "optv_…", "value": "S", "option_id": "opt_…",
100
- "option": { "id": "opt_…", "title": "Size", "product_id": "prod_…" } } }
101
- ],
102
- // Raw BASE prices via the price-set link embed (leak rule: price-list
103
- // rows are stripped price_list_id is always null here):
104
- "prices": [
105
- { "price_set": { "prices": [
106
- { "id": "price_…", "amount": 45, "currency_code": "eur",
107
- "min_quantity": null, "max_quantity": null,
108
- "price_set_id": "pset_…", "price_list_id": null }
109
- ] } }
110
- ],
111
- // Present when a pricing context was given; null otherwise or when no
112
- // price matches. b2b-v1 shape (store-api.md §Pricing context):
113
- "calculated_price": {
114
- "calculated_amount": 45,
115
- "original_amount": 45,
116
- "currency_code": "eur",
117
- "is_calculated_price_price_list": false,
118
- "price_list_id": null,
119
- "price_list_type": null
120
- }
121
- }
122
- ],
123
- "images": [ { "id": "pimg_…", "url": "https://…", "rank": 0 } ],
124
- "options": [ { "id": "opt_…", "title": "Size",
125
- "values": [ { "id": "optv_…", "value": "S" } ] } ],
126
- "collection": { "id": "pcol_…", "title": "Essentials", "handle": "essentials" },
127
- "categories": [ { "category": { "id": "pcat_…", "name": "Apparel", "handle": "apparel" } } ],
128
- "tags": [ { "tag": { "id": "ptag_…", "value": "summer" } } ],
129
- "type": null
130
- }
131
- ```
132
-
133
- - **Working curl**
134
-
135
- ```bash
136
- PRODUCTS=$(curl -sf "$BASE/api/store/products?limit=5" -H "x-client-id: $CLIENT_ID")
137
- echo "$PRODUCTS" | grep -q '"products"'
138
- echo "$PRODUCTS" | grep -q '"count"'
139
- # The handle filter answers deterministically regardless of catalog size.
140
- curl -sf "$BASE/api/store/products?handle=linen-shirt" -H "x-client-id: $CLIENT_ID" \
141
- | grep -q '"handle":"linen-shirt"'
142
- ```
143
-
144
- - **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
145
- 400 `invalid_publishable_key`, 400 `invalid_region`.
146
- - **SDK** `listProducts(client, query?)`.
147
- - **Components** product card grid (components.md).
148
- - **Settings** sales-channel product links + publishable-key bindings
149
- (channel scope), price lists (calculated_price).
150
-
151
- ---
152
-
153
- ## GET /api/store/products/:idOrHandle
154
-
155
- - **Purpose** — the PDP read. Accepts a product id (`prod_…`) or a handle —
156
- storefronts deep-link by handle.
157
- - **Auth** — as the list.
158
- - **Request** query: pricing context only (`currency_code` / `region_id`).
159
- - **Response** — `{ "product": { ...shape above... } }`
160
- - **Working curl** — with pricing context; asserts the seeded base price
161
- (Linen Shirt, EUR 45) and the leak rule structurally:
162
-
163
- ```bash
164
- PRODUCT=$(curl -sf "$BASE/api/store/products/linen-shirt?currency_code=eur" \
165
- -H "x-client-id: $CLIENT_ID")
166
- echo "$PRODUCT" | grep -q '"product"'
167
- echo "$PRODUCT" | grep -q '"calculated_price"'
168
- echo "$PRODUCT" | grep -q '"calculated_amount":45'
169
- echo "$PRODUCT" | grep -q '"seo_title"'
170
- ```
171
-
172
- ```bash
173
- # Leak rule, asserted structurally: every raw price row is a BASE row
174
- # (price_list_id null) — price-list amounts only ever ride calculated_price.
175
- echo "$PRODUCT" | node -e "
176
- const c=[];process.stdin.on('data',d=>c.push(d)).on('end',()=>{
177
- const j=JSON.parse(Buffer.concat(c));
178
- const rows=j.product.variants.flatMap(v=>v.prices??[]).flatMap(l=>l.price_set?.prices??[]);
179
- if(!rows.length){console.error('no base price rows');process.exit(1)}
180
- if(rows.some(r=>r.price_list_id!==null)){console.error('price-list row leaked');process.exit(1)}
181
- })"
182
- ```
183
-
184
- - **Errors** 404 `not_found` (unknown handle/id, draft, soft-deleted, or
185
- outside the publishable key's channels), 400 `invalid_publishable_key`,
186
- 400 `invalid_region`.
187
-
188
- ```bash
189
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
190
- "$BASE/api/store/products/no-such-handle-$RUN" -H "x-client-id: $CLIENT_ID")
191
- test "$STATUS" = 404
192
- ```
193
-
194
- ```bash
195
- # Channel scope: the dev PUBLISHABLE_KEY is bound to the B2B channel, whose
196
- # catalog is Linen Shirt + Wool Beanie. The Leather Belt exists (anon read
197
- # works) but 404s under the key — invisible, not forbidden.
198
- curl -sf "$BASE/api/store/products/leather-belt" \
199
- -H "x-client-id: $CLIENT_ID" | grep -q '"handle":"leather-belt"'
200
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
201
- "$BASE/api/store/products/leather-belt" \
202
- -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY")
203
- test "$STATUS" = 404
204
- curl -sf "$BASE/api/store/products/linen-shirt" \
205
- -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
206
- | grep -q '"handle":"linen-shirt"'
207
- ```
208
-
209
- ```bash
210
- # Unknown key → 400 invalid_publishable_key (never a silent unscoped read).
211
- BODY=$(curl -s "$BASE/api/store/products/linen-shirt" \
212
- -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: pk_bogus_$RUN")
213
- echo "$BODY" | grep -q '"code":"invalid_publishable_key"'
214
- ```
215
-
216
- - **SDK** — `retrieveProduct(client, idOrHandle, query?)`.
217
- - **Components** — PDP family: gallery, option picker, price block.
218
- - **Settings** — as the list; SEO overrides (Admin → Product → SEO).
219
-
220
- ---
221
-
222
- ## GET /api/store/products/:idOrHandle/selling-plans
223
-
224
- - **Purpose** — the subscription plans this product can be purchased with
225
- (subscriptions add-on). Render as PDP purchase options (one-time vs each
226
- plan); the chosen plan id goes on the cart line
227
- (`POST /carts/:id/line-items` `selling_plan_id`) and the SERVER applies
228
- the plan price. Empty list = one-time only.
229
- - **Auth** — anon `x-client-id`; publishable-key channel scope applies
230
- (same visibility rule as the product read).
231
- - **Response** — `{ selling_plans: [{id, name, interval, interval_count,
232
- pricing_type, pricing_value, min_cycles, max_cycles}], count }`.
233
- `pricing_type` `percent` (percent off) | `fixed` (fixed unit price, EUR
234
- major units) | `null` (catalog price the plan only sets the cadence);
235
- enabled plans only, merchant-defined order.
236
- - **Errors** — 404 `not_found` (same rules as the product read).
237
- - **SDK** `products.listSellingPlans(client, idOrHandle)`.
238
- - **Components** — `PurchaseOptions` (products family): controlled radio
239
- group, one-time + plans with display-only price preview
240
- (`previewPlanPrice` mirrors the server's math; the server is truth).
241
- NOTE: a cart with a plan line requires a logged-in customer at payment
242
- (checkout.md `save_payment_method`) surface login before checkout.
243
-
244
- ```bash
245
- # The dev tenant seeds no plans the endpoint contract still executes:
246
- # a valid product answers with the envelope, an unknown product 404s.
247
- SPLANS=$(curl -sf "$BASE/api/store/products/linen-shirt/selling-plans" \
248
- -H "x-client-id: $CLIENT_ID")
249
- echo "$SPLANS" | grep -q '"selling_plans"'
250
- echo "$SPLANS" | grep -q '"count"'
251
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
252
- "$BASE/api/store/products/no-such-handle-$RUN/selling-plans" \
253
- -H "x-client-id: $CLIENT_ID")
254
- test "$STATUS" = 404
255
- ```
256
-
257
- ---
258
-
259
- ## GET /api/store/product-variants
260
-
261
- - **Purpose** — variant multi-lookup (cart-line hydration). `id` accepts a
262
- CSV of variant ids in ONE param. Ordered by `variant_rank`.
263
- - **Auth** anon: `x-client-id`; optional Bearer JWT (group pricing).
264
- NOTE (code truth): this listing is NOT publishable-key channel-scoped —
265
- scope applies to product reads.
266
- - **Request** — query `{ id?, product_id?, sku?, currency_code?, region_id?,
267
- limit?, offset? }` (`limit` 1–200, default 50).
268
- - **Response** — `{ variants, count, offset, limit }`; variant shape exactly
269
- as embedded in products above (options + prices + calculated_price).
270
- - **Working curl**
271
-
272
- ```bash
273
- VARIANTS=$(curl -sf "$BASE/api/store/product-variants?sku=LIN-SHIRT-S&currency_code=eur" \
274
- -H "x-client-id: $CLIENT_ID")
275
- echo "$VARIANTS" | grep -q '"variants"'
276
- echo "$VARIANTS" | grep -q '"sku":"LIN-SHIRT-S"'
277
- VARIANT_ID=$(echo "$VARIANTS" | grep -o '"id":"variant_[^"]*"' | head -1 | cut -d'"' -f4)
278
- test -n "$VARIANT_ID"
279
- ```
280
-
281
- - **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
282
- 400 `invalid_region`.
283
- - **SDK** — `listProductVariants(client, query?)`.
284
- - **Components** cart line renderer.
285
- - **Settings** price lists (calculated_price).
286
-
287
- ---
288
-
289
- ## GET /api/store/product-variants/:id
290
-
291
- - **Purpose** — retrieve one variant with options + base prices hydrated.
292
- - **Auth** — anon: `x-client-id`; optional Bearer JWT.
293
- - **Request** — query: pricing context only.
294
- - **Response** — `{ "variant": { ... } }`
295
- - **Working curl**
296
-
297
- ```bash
298
- VARIANT=$(curl -sf "$BASE/api/store/product-variants/$VARIANT_ID?currency_code=eur" \
299
- -H "x-client-id: $CLIENT_ID")
300
- echo "$VARIANT" | grep -q '"variant"'
301
- echo "$VARIANT" | grep -q '"calculated_price"'
302
- ```
303
-
304
- - **Errors** — 404 `not_found`, 400 `invalid_region`.
305
- - **SDK** — `retrieveProductVariant(client, variantId, query?)`.
306
- - **Components** — cart line renderer, option picker.
307
- - **Settings** — price lists.
1
+ # Products
2
+
3
+ The catalog read surface. The product object documented here is THE
4
+ canonical shape every discovery endpoint reuses (search results, collection
5
+ membership pages, related products) — build one product-card renderer
6
+ against it. Money is EUR decimal major units.
7
+
8
+ SDK module: `@cartbase/storefront/api/products`.
9
+
10
+ **Pricing context (applies to every endpoint here):** pass `currency_code`
11
+ (or `region_id` — its region's currency is used; unknown region → 400
12
+ `invalid_region`) to receive `variant.calculated_price`. Without either,
13
+ `calculated_price` is `null` and only raw base prices are listed. Customer
14
+ groups come ONLY from the optional `authorization: Bearer <jwt>` — never
15
+ from params. **Cache rule:** any response carrying `calculated_price` varies
16
+ by customer group — never cache it shared when a JWT was present.
17
+
18
+ **Leak rule:** raw price rows in store responses contain ONLY base
19
+ (non-price-list) rows. Price-list amounts surface exclusively through
20
+ `calculated_price` for the caller's own context.
21
+
22
+ **Publishable-key channel scope:** sending `x-publishable-api-key` restricts
23
+ the catalog to products linked to the key's sales channels — a product
24
+ outside them **404s on retrieve and disappears from lists** (invisible, not
25
+ forbidden). A key with no channel links scopes nothing. Unknown/revoked/
26
+ foreign key → 400 `invalid_publishable_key`.
27
+
28
+ ---
29
+
30
+ ## GET /api/store/products
31
+
32
+ - **Purpose** — the product listing: catalog pages, filtered grids.
33
+ Published products only (drafts are RLS-invisible).
34
+ - **Auth** — anon: `x-client-id` required; `x-publishable-api-key` optional
35
+ (channel scope); `authorization: Bearer <jwt>` optional (group pricing).
36
+ - **Request**
37
+
38
+ ```jsonc
39
+ // query (all optional)
40
+ {
41
+ "q": "linen", // case-insensitive substring on TITLE only
42
+ "id": "prod_a,prod_b", // CSV of ids
43
+ "handle": "linen-shirt", // exact
44
+ "collection_id": "pcol_a", // CSV — collection membership (any of them)
45
+ "category_id": "pcat_a", // CSV — category membership
46
+ "tag_id": "ptag_a", // CSV — tag membership
47
+ "type_id": "ptyp_a", // CSV
48
+ "order": "-created_at", // sort column, "-" prefix = desc (default -created_at)
49
+ "currency_code": "eur", // pricing context
50
+ "region_id": "reg_…", // or region → currency
51
+ "limit": 50, // 1–200, default 50
52
+ "offset": 0
53
+ }
54
+ ```
55
+
56
+ - **Response** — `{ products, count, offset, limit }`; each product:
57
+
58
+ ```jsonc
59
+ {
60
+ "id": "prod_01tst00000000000000000001",
61
+ "title": "Linen Shirt",
62
+ "subtitle": null,
63
+ "description": "Lightweight linen shirt, relaxed fit.",
64
+ "handle": "linen-shirt",
65
+ "status": "published", // always — drafts never appear
66
+ "thumbnail": "https://…/600/800",
67
+ "is_giftcard": false,
68
+ "discountable": true,
69
+ "type_id": null,
70
+ "external_id": null,
71
+ "vendor": null, // the brand, plain text
72
+ "weight": null, "length": null, "height": null, "width": null,
73
+ "hs_code": null, "origin_country": null, "mid_code": null, "material": null,
74
+ "seo_title": null, // null = fall back to title
75
+ "seo_description": null, // null = fall back to plain-text description
76
+ "metadata": null,
77
+ // The store's custom fields under their definition keys, values as stored
78
+ // (string, number, boolean, array for a list, id for a metaobject
79
+ // reference). Public definitions only: Settings, Custom fields switches a
80
+ // field off the storefront. Always present, {} when the product has none.
81
+ "metafields": { "custom.product_label_1": "500 мл", "custom.product_label_2": "Испания" },
82
+ "created_at": "2026-07-01T00:00:00.000Z",
83
+ "updated_at": "2026-07-01T00:00:00.000Z",
84
+ "variants": [
85
+ {
86
+ "id": "variant_01tst000000000000000001",
87
+ "title": "S",
88
+ "product_id": "prod_01tst00000000000000000001",
89
+ "sku": "LIN-SHIRT-S",
90
+ "barcode": null, "ean": null, "upc": null,
91
+ "thumbnail": null,
92
+ "allow_backorder": false,
93
+ "manage_inventory": true,
94
+ // Computed server-side by the platform's availability predicate:
95
+ // untracked or backorderable variants are always true; otherwise true
96
+ // iff available stock (kit-aware, reservations subtracted) is above
97
+ // zero. Read this — never re-derive stock client-side (the exact
98
+ // quantity is deliberately not exposed).
99
+ "in_stock": true,
100
+ "variant_rank": 0,
101
+ "metadata": null,
102
+ "options": [
103
+ { "value": { "id": "optv_…", "value": "S", "option_id": "opt_…",
104
+ "option": { "id": "opt_…", "title": "Size", "product_id": "prod_…" } } }
105
+ ],
106
+ // Raw BASE prices via the price-set link embed (leak rule: price-list
107
+ // rows are stripped — price_list_id is always null here):
108
+ "prices": [
109
+ { "price_set": { "prices": [
110
+ { "id": "price_…", "amount": 45, "currency_code": "eur",
111
+ "min_quantity": null, "max_quantity": null,
112
+ "price_set_id": "pset_…", "price_list_id": null }
113
+ ] } }
114
+ ],
115
+ // Present when a pricing context was given; null otherwise or when no
116
+ // price matches. b2b-v1 shape (store-api.md §Pricing context):
117
+ "calculated_price": {
118
+ "calculated_amount": 45,
119
+ "original_amount": 45,
120
+ "currency_code": "eur",
121
+ "is_calculated_price_price_list": false,
122
+ "price_list_id": null,
123
+ "price_list_type": null
124
+ }
125
+ }
126
+ ],
127
+ "images": [ { "id": "pimg_…", "url": "https://…", "rank": 0 } ],
128
+ "options": [ { "id": "opt_…", "title": "Size",
129
+ "values": [ { "id": "optv_…", "value": "S" } ] } ],
130
+ // EVERY collection the product is in, the shape categories and tags have.
131
+ // It was one `collection` object read off products.collection_id until
132
+ // 2026-09-15; that column held one of a product's collections at most.
133
+ "collections": [
134
+ { "collection": { "id": "pcol_…", "title": "Essentials", "handle": "essentials" } }
135
+ ],
136
+ "categories": [ { "category": { "id": "pcat_…", "name": "Apparel", "handle": "apparel" } } ],
137
+ "tags": [ { "tag": { "id": "ptag_…", "value": "summer" } } ],
138
+ "type": null
139
+ }
140
+ ```
141
+
142
+ - **Working curl**
143
+
144
+ ```bash
145
+ PRODUCTS=$(curl -sf "$BASE/api/store/products?limit=5" -H "x-client-id: $CLIENT_ID")
146
+ echo "$PRODUCTS" | grep -q '"products"'
147
+ echo "$PRODUCTS" | grep -q '"count"'
148
+ # The handle filter answers deterministically regardless of catalog size.
149
+ curl -sf "$BASE/api/store/products?handle=linen-shirt" -H "x-client-id: $CLIENT_ID" \
150
+ | grep -q '"handle":"linen-shirt"'
151
+ ```
152
+
153
+ - **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
154
+ 400 `invalid_publishable_key`, 400 `invalid_region`.
155
+ - **SDK** — `listProducts(client, query?)`.
156
+ - **Components** — product card grid (components.md).
157
+ - **Settings** — sales-channel product links + publishable-key bindings
158
+ (channel scope), price lists (calculated_price).
159
+
160
+ ---
161
+
162
+ ## GET /api/store/products/:idOrHandle
163
+
164
+ - **Purpose** — the PDP read. Accepts a product id (`prod_…`) or a handle —
165
+ storefronts deep-link by handle.
166
+ - **Auth** as the list.
167
+ - **Request** query: pricing context only (`currency_code` / `region_id`).
168
+ - **Response** — `{ "product": { ...shape above... } }`
169
+ - **Working curl** with pricing context; asserts the seeded base price
170
+ (Linen Shirt, EUR 45) and the leak rule structurally:
171
+
172
+ ```bash
173
+ PRODUCT=$(curl -sf "$BASE/api/store/products/linen-shirt?currency_code=eur" \
174
+ -H "x-client-id: $CLIENT_ID")
175
+ echo "$PRODUCT" | grep -q '"product"'
176
+ echo "$PRODUCT" | grep -q '"calculated_price"'
177
+ echo "$PRODUCT" | grep -q '"calculated_amount":45'
178
+ echo "$PRODUCT" | grep -q '"seo_title"'
179
+ ```
180
+
181
+ ```bash
182
+ # Leak rule, asserted structurally: every raw price row is a BASE row
183
+ # (price_list_id null) — price-list amounts only ever ride calculated_price.
184
+ echo "$PRODUCT" | node -e "
185
+ const c=[];process.stdin.on('data',d=>c.push(d)).on('end',()=>{
186
+ const j=JSON.parse(Buffer.concat(c));
187
+ const rows=j.product.variants.flatMap(v=>v.prices??[]).flatMap(l=>l.price_set?.prices??[]);
188
+ if(!rows.length){console.error('no base price rows');process.exit(1)}
189
+ if(rows.some(r=>r.price_list_id!==null)){console.error('price-list row leaked');process.exit(1)}
190
+ })"
191
+ ```
192
+
193
+ - **Errors** — 404 `not_found` (unknown handle/id, draft, soft-deleted, or
194
+ outside the publishable key's channels), 400 `invalid_publishable_key`,
195
+ 400 `invalid_region`.
196
+
197
+ ```bash
198
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
199
+ "$BASE/api/store/products/no-such-handle-$RUN" -H "x-client-id: $CLIENT_ID")
200
+ test "$STATUS" = 404
201
+ ```
202
+
203
+ ```bash
204
+ # Channel scope: the dev PUBLISHABLE_KEY is bound to the B2B channel, whose
205
+ # catalog is Linen Shirt + Wool Beanie. The Leather Belt exists (anon read
206
+ # works) but 404s under the key — invisible, not forbidden.
207
+ curl -sf "$BASE/api/store/products/leather-belt" \
208
+ -H "x-client-id: $CLIENT_ID" | grep -q '"handle":"leather-belt"'
209
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
210
+ "$BASE/api/store/products/leather-belt" \
211
+ -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY")
212
+ test "$STATUS" = 404
213
+ curl -sf "$BASE/api/store/products/linen-shirt" \
214
+ -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
215
+ | grep -q '"handle":"linen-shirt"'
216
+ ```
217
+
218
+ ```bash
219
+ # Unknown key → 400 invalid_publishable_key (never a silent unscoped read).
220
+ BODY=$(curl -s "$BASE/api/store/products/linen-shirt" \
221
+ -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: pk_bogus_$RUN")
222
+ echo "$BODY" | grep -q '"code":"invalid_publishable_key"'
223
+ ```
224
+
225
+ - **SDK** `retrieveProduct(client, idOrHandle, query?)`.
226
+ - **Components** PDP family: gallery, option picker, price block.
227
+ - **Settings** as the list; SEO overrides (Admin → Product → SEO).
228
+
229
+ ---
230
+
231
+ ## GET /api/store/products/:idOrHandle/selling-plans
232
+
233
+ - **Purpose** the subscription plans this product can be purchased with
234
+ (subscriptions add-on). Render as PDP purchase options (one-time vs each
235
+ plan); the chosen plan id goes on the cart line
236
+ (`POST /carts/:id/line-items` `selling_plan_id`) and the SERVER applies
237
+ the plan price. Empty list = one-time only.
238
+ - **Auth** — anon `x-client-id`; publishable-key channel scope applies
239
+ (same visibility rule as the product read).
240
+ - **Response** `{ selling_plans: [{id, name, interval, interval_count,
241
+ pricing_type, pricing_value, min_cycles, max_cycles}], count }`.
242
+ `pricing_type` `percent` (percent off) | `fixed` (fixed unit price, EUR
243
+ major units) | `null` (catalog price — the plan only sets the cadence);
244
+ enabled plans only, merchant-defined order.
245
+ - **Errors** 404 `not_found` (same rules as the product read).
246
+ - **SDK** `products.listSellingPlans(client, idOrHandle)`.
247
+ - **Components** — `PurchaseOptions` (products family): controlled radio
248
+ group, one-time + plans with display-only price preview
249
+ (`previewPlanPrice` mirrors the server's math; the server is truth).
250
+ NOTE: a cart with a plan line requires a logged-in customer at payment
251
+ (checkout.md `save_payment_method`) surface login before checkout.
252
+
253
+ ```bash
254
+ # The dev tenant seeds no plans — the endpoint contract still executes:
255
+ # a valid product answers with the envelope, an unknown product 404s.
256
+ SPLANS=$(curl -sf "$BASE/api/store/products/linen-shirt/selling-plans" \
257
+ -H "x-client-id: $CLIENT_ID")
258
+ echo "$SPLANS" | grep -q '"selling_plans"'
259
+ echo "$SPLANS" | grep -q '"count"'
260
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
261
+ "$BASE/api/store/products/no-such-handle-$RUN/selling-plans" \
262
+ -H "x-client-id: $CLIENT_ID")
263
+ test "$STATUS" = 404
264
+ ```
265
+
266
+ ---
267
+
268
+ ## GET /api/store/product-variants
269
+
270
+ - **Purpose** — variant multi-lookup (cart-line hydration). `id` accepts a
271
+ CSV of variant ids in ONE param. Ordered by `variant_rank`.
272
+ - **Auth** — anon: `x-client-id`; optional Bearer JWT (group pricing).
273
+ NOTE (code truth): this listing is NOT publishable-key channel-scoped
274
+ scope applies to product reads.
275
+ - **Request** query `{ id?, product_id?, sku?, currency_code?, region_id?,
276
+ limit?, offset? }` (`limit` 1–200, default 50).
277
+ - **Response** `{ variants, count, offset, limit }`; variant shape exactly
278
+ as embedded in products above (options + prices + calculated_price).
279
+ - **Working curl**
280
+
281
+ ```bash
282
+ VARIANTS=$(curl -sf "$BASE/api/store/product-variants?sku=LIN-SHIRT-S&currency_code=eur" \
283
+ -H "x-client-id: $CLIENT_ID")
284
+ echo "$VARIANTS" | grep -q '"variants"'
285
+ echo "$VARIANTS" | grep -q '"sku":"LIN-SHIRT-S"'
286
+ VARIANT_ID=$(echo "$VARIANTS" | grep -o '"id":"variant_[^"]*"' | head -1 | cut -d'"' -f4)
287
+ test -n "$VARIANT_ID"
288
+ ```
289
+
290
+ - **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
291
+ 400 `invalid_region`.
292
+ - **SDK** — `listProductVariants(client, query?)`.
293
+ - **Components** — cart line renderer.
294
+ - **Settings** — price lists (calculated_price).
295
+
296
+ ---
297
+
298
+ ## GET /api/store/product-variants/:id
299
+
300
+ - **Purpose** retrieve one variant with options + base prices hydrated.
301
+ - **Auth** anon: `x-client-id`; optional Bearer JWT.
302
+ - **Request** — query: pricing context only.
303
+ - **Response** — `{ "variant": { ... } }`
304
+ - **Working curl**
305
+
306
+ ```bash
307
+ VARIANT=$(curl -sf "$BASE/api/store/product-variants/$VARIANT_ID?currency_code=eur" \
308
+ -H "x-client-id: $CLIENT_ID")
309
+ echo "$VARIANT" | grep -q '"variant"'
310
+ echo "$VARIANT" | grep -q '"calculated_price"'
311
+ ```
312
+
313
+ - **Errors** — 404 `not_found`, 400 `invalid_region`.
314
+ - **SDK** — `retrieveProductVariant(client, variantId, query?)`.
315
+ - **Components** — cart line renderer, option picker.
316
+ - **Settings** — price lists.