create-cartbase 0.1.13 → 0.1.15

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,306 +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
- "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
- "created_at": "2026-07-01T00:00:00.000Z",
78
- "updated_at": "2026-07-01T00:00:00.000Z",
79
- "variants": [
80
- {
81
- "id": "variant_01tst000000000000000001",
82
- "title": "S",
83
- "product_id": "prod_01tst00000000000000000001",
84
- "sku": "LIN-SHIRT-S",
85
- "barcode": null, "ean": null, "upc": null,
86
- "thumbnail": null,
87
- "allow_backorder": false,
88
- "manage_inventory": true,
89
- // Computed server-side by the platform's availability predicate:
90
- // untracked or backorderable variants are always true; otherwise true
91
- // iff available stock (kit-aware, reservations subtracted) is above
92
- // zero. Read this — never re-derive stock client-side (the exact
93
- // quantity is deliberately not exposed).
94
- "in_stock": true,
95
- "variant_rank": 0,
96
- "metadata": null,
97
- "options": [
98
- { "value": { "id": "optv_…", "value": "S", "option_id": "opt_…",
99
- "option": { "id": "opt_…", "title": "Size", "product_id": "prod_…" } } }
100
- ],
101
- // Raw BASE prices via the price-set link embed (leak rule: price-list
102
- // rows are stripped — price_list_id is always null here):
103
- "prices": [
104
- { "price_set": { "prices": [
105
- { "id": "price_…", "amount": 45, "currency_code": "eur",
106
- "min_quantity": null, "max_quantity": null,
107
- "price_set_id": "pset_…", "price_list_id": null }
108
- ] } }
109
- ],
110
- // Present when a pricing context was given; null otherwise or when no
111
- // price matches. b2b-v1 shape (store-api.md §Pricing context):
112
- "calculated_price": {
113
- "calculated_amount": 45,
114
- "original_amount": 45,
115
- "currency_code": "eur",
116
- "is_calculated_price_price_list": false,
117
- "price_list_id": null,
118
- "price_list_type": null
119
- }
120
- }
121
- ],
122
- "images": [ { "id": "pimg_…", "url": "https://…", "rank": 0 } ],
123
- "options": [ { "id": "opt_…", "title": "Size",
124
- "values": [ { "id": "optv_…", "value": "S" } ] } ],
125
- "collection": { "id": "pcol_…", "title": "Essentials", "handle": "essentials" },
126
- "categories": [ { "category": { "id": "pcat_…", "name": "Apparel", "handle": "apparel" } } ],
127
- "tags": [ { "tag": { "id": "ptag_…", "value": "summer" } } ],
128
- "type": null
129
- }
130
- ```
131
-
132
- - **Working curl**
133
-
134
- ```bash
135
- PRODUCTS=$(curl -sf "$BASE/api/store/products?limit=5" -H "x-client-id: $CLIENT_ID")
136
- echo "$PRODUCTS" | grep -q '"products"'
137
- echo "$PRODUCTS" | grep -q '"count"'
138
- # The handle filter answers deterministically regardless of catalog size.
139
- curl -sf "$BASE/api/store/products?handle=linen-shirt" -H "x-client-id: $CLIENT_ID" \
140
- | grep -q '"handle":"linen-shirt"'
141
- ```
142
-
143
- - **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
144
- 400 `invalid_publishable_key`, 400 `invalid_region`.
145
- - **SDK** `listProducts(client, query?)`.
146
- - **Components** product card grid (components.md).
147
- - **Settings** sales-channel product links + publishable-key bindings
148
- (channel scope), price lists (calculated_price).
149
-
150
- ---
151
-
152
- ## GET /api/store/products/:idOrHandle
153
-
154
- - **Purpose** the PDP read. Accepts a product id (`prod_…`) or a handle —
155
- storefronts deep-link by handle.
156
- - **Auth** — as the list.
157
- - **Request** — query: pricing context only (`currency_code` / `region_id`).
158
- - **Response** `{ "product": { ...shape above... } }`
159
- - **Working curl** — with pricing context; asserts the seeded base price
160
- (Linen Shirt, EUR 45) and the leak rule structurally:
161
-
162
- ```bash
163
- PRODUCT=$(curl -sf "$BASE/api/store/products/linen-shirt?currency_code=eur" \
164
- -H "x-client-id: $CLIENT_ID")
165
- echo "$PRODUCT" | grep -q '"product"'
166
- echo "$PRODUCT" | grep -q '"calculated_price"'
167
- echo "$PRODUCT" | grep -q '"calculated_amount":45'
168
- echo "$PRODUCT" | grep -q '"seo_title"'
169
- ```
170
-
171
- ```bash
172
- # Leak rule, asserted structurally: every raw price row is a BASE row
173
- # (price_list_id null) — price-list amounts only ever ride calculated_price.
174
- echo "$PRODUCT" | node -e "
175
- const c=[];process.stdin.on('data',d=>c.push(d)).on('end',()=>{
176
- const j=JSON.parse(Buffer.concat(c));
177
- const rows=j.product.variants.flatMap(v=>v.prices??[]).flatMap(l=>l.price_set?.prices??[]);
178
- if(!rows.length){console.error('no base price rows');process.exit(1)}
179
- if(rows.some(r=>r.price_list_id!==null)){console.error('price-list row leaked');process.exit(1)}
180
- })"
181
- ```
182
-
183
- - **Errors**404 `not_found` (unknown handle/id, draft, soft-deleted, or
184
- outside the publishable key's channels), 400 `invalid_publishable_key`,
185
- 400 `invalid_region`.
186
-
187
- ```bash
188
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
189
- "$BASE/api/store/products/no-such-handle-$RUN" -H "x-client-id: $CLIENT_ID")
190
- test "$STATUS" = 404
191
- ```
192
-
193
- ```bash
194
- # Channel scope: the dev PUBLISHABLE_KEY is bound to the B2B channel, whose
195
- # catalog is Linen Shirt + Wool Beanie. The Leather Belt exists (anon read
196
- # works) but 404s under the key — invisible, not forbidden.
197
- curl -sf "$BASE/api/store/products/leather-belt" \
198
- -H "x-client-id: $CLIENT_ID" | grep -q '"handle":"leather-belt"'
199
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
200
- "$BASE/api/store/products/leather-belt" \
201
- -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY")
202
- test "$STATUS" = 404
203
- curl -sf "$BASE/api/store/products/linen-shirt" \
204
- -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: $PUBLISHABLE_KEY" \
205
- | grep -q '"handle":"linen-shirt"'
206
- ```
207
-
208
- ```bash
209
- # Unknown key 400 invalid_publishable_key (never a silent unscoped read).
210
- BODY=$(curl -s "$BASE/api/store/products/linen-shirt" \
211
- -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: pk_bogus_$RUN")
212
- echo "$BODY" | grep -q '"code":"invalid_publishable_key"'
213
- ```
214
-
215
- - **SDK** `retrieveProduct(client, idOrHandle, query?)`.
216
- - **Components** — PDP family: gallery, option picker, price block.
217
- - **Settings** — as the list; SEO overrides (Admin → Product → SEO).
218
-
219
- ---
220
-
221
- ## GET /api/store/products/:idOrHandle/selling-plans
222
-
223
- - **Purpose** — the subscription plans this product can be purchased with
224
- (subscriptions add-on). Render as PDP purchase options (one-time vs each
225
- plan); the chosen plan id goes on the cart line
226
- (`POST /carts/:id/line-items` `selling_plan_id`) and the SERVER applies
227
- the plan price. Empty list = one-time only.
228
- - **Auth** — anon `x-client-id`; publishable-key channel scope applies
229
- (same visibility rule as the product read).
230
- - **Response** — `{ selling_plans: [{id, name, interval, interval_count,
231
- pricing_type, pricing_value, min_cycles, max_cycles}], count }`.
232
- `pricing_type` `percent` (percent off) | `fixed` (fixed unit price, EUR
233
- major units) | `null` (catalog price the plan only sets the cadence);
234
- enabled plans only, merchant-defined order.
235
- - **Errors** 404 `not_found` (same rules as the product read).
236
- - **SDK** `products.listSellingPlans(client, idOrHandle)`.
237
- - **Components** `PurchaseOptions` (products family): controlled radio
238
- group, one-time + plans with display-only price preview
239
- (`previewPlanPrice` mirrors the server's math; the server is truth).
240
- NOTE: a cart with a plan line requires a logged-in customer at payment
241
- (checkout.md `save_payment_method`) surface login before checkout.
242
-
243
- ```bash
244
- # The dev tenant seeds no plans the endpoint contract still executes:
245
- # a valid product answers with the envelope, an unknown product 404s.
246
- SPLANS=$(curl -sf "$BASE/api/store/products/linen-shirt/selling-plans" \
247
- -H "x-client-id: $CLIENT_ID")
248
- echo "$SPLANS" | grep -q '"selling_plans"'
249
- echo "$SPLANS" | grep -q '"count"'
250
- STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
251
- "$BASE/api/store/products/no-such-handle-$RUN/selling-plans" \
252
- -H "x-client-id: $CLIENT_ID")
253
- test "$STATUS" = 404
254
- ```
255
-
256
- ---
257
-
258
- ## GET /api/store/product-variants
259
-
260
- - **Purpose** — variant multi-lookup (cart-line hydration). `id` accepts a
261
- CSV of variant ids in ONE param. Ordered by `variant_rank`.
262
- - **Auth** — anon: `x-client-id`; optional Bearer JWT (group pricing).
263
- NOTE (code truth): this listing is NOT publishable-key channel-scoped —
264
- scope applies to product reads.
265
- - **Request** — query `{ id?, product_id?, sku?, currency_code?, region_id?,
266
- limit?, offset? }` (`limit` 1–200, default 50).
267
- - **Response** — `{ variants, count, offset, limit }`; variant shape exactly
268
- as embedded in products above (options + prices + calculated_price).
269
- - **Working curl**
270
-
271
- ```bash
272
- VARIANTS=$(curl -sf "$BASE/api/store/product-variants?sku=LIN-SHIRT-S&currency_code=eur" \
273
- -H "x-client-id: $CLIENT_ID")
274
- echo "$VARIANTS" | grep -q '"variants"'
275
- echo "$VARIANTS" | grep -q '"sku":"LIN-SHIRT-S"'
276
- VARIANT_ID=$(echo "$VARIANTS" | grep -o '"id":"variant_[^"]*"' | head -1 | cut -d'"' -f4)
277
- test -n "$VARIANT_ID"
278
- ```
279
-
280
- - **Errors** — 400 `missing_client_id`, 400 `validation_failed`,
281
- 400 `invalid_region`.
282
- - **SDK** — `listProductVariants(client, query?)`.
283
- - **Components** — cart line renderer.
284
- - **Settings** price lists (calculated_price).
285
-
286
- ---
287
-
288
- ## GET /api/store/product-variants/:id
289
-
290
- - **Purpose** — retrieve one variant with options + base prices hydrated.
291
- - **Auth** — anon: `x-client-id`; optional Bearer JWT.
292
- - **Request** — query: pricing context only.
293
- - **Response** — `{ "variant": { ... } }`
294
- - **Working curl**
295
-
296
- ```bash
297
- VARIANT=$(curl -sf "$BASE/api/store/product-variants/$VARIANT_ID?currency_code=eur" \
298
- -H "x-client-id: $CLIENT_ID")
299
- echo "$VARIANT" | grep -q '"variant"'
300
- echo "$VARIANT" | grep -q '"calculated_price"'
301
- ```
302
-
303
- - **Errors** — 404 `not_found`, 400 `invalid_region`.
304
- - **SDK** — `retrieveProductVariant(client, variantId, query?)`.
305
- - **Components** — cart line renderer, option picker.
306
- - **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.