create-cartbase 0.0.1 → 0.1.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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -3
  3. package/dist/index.js +94 -0
  4. package/package.json +18 -4
  5. package/template/app/CLAUDE.md +18 -0
  6. package/template/app/docs/BUILD-A-STOREFRONT.md +216 -0
  7. package/template/app/docs/README.md +76 -0
  8. package/template/app/docs/auth.md +105 -0
  9. package/template/app/docs/carts.md +376 -0
  10. package/template/app/docs/categories.md +194 -0
  11. package/template/app/docs/checkout.md +611 -0
  12. package/template/app/docs/collections.md +167 -0
  13. package/template/app/docs/components.md +1089 -0
  14. package/template/app/docs/consent.md +81 -0
  15. package/template/app/docs/content.md +126 -0
  16. package/template/app/docs/customers.md +269 -0
  17. package/template/app/docs/deploy.md +192 -0
  18. package/template/app/docs/gift-cards.md +153 -0
  19. package/template/app/docs/integrations.md +137 -0
  20. package/template/app/docs/menus.md +73 -0
  21. package/template/app/docs/metaobjects.md +126 -0
  22. package/template/app/docs/orders.md +221 -0
  23. package/template/app/docs/products.md +300 -0
  24. package/template/app/docs/redirects.md +50 -0
  25. package/template/app/docs/regions.md +207 -0
  26. package/template/app/docs/reviews.md +223 -0
  27. package/template/app/docs/search.md +218 -0
  28. package/template/app/docs/subscriptions.md +148 -0
  29. package/template/app/next.config.ts +34 -0
  30. package/template/app/package.json +25 -0
  31. package/template/app/postcss.config.cjs +6 -0
  32. package/template/app/smoke.mjs +158 -0
  33. package/template/app/src/app/checkout/checkout-page-client.tsx +66 -0
  34. package/template/app/src/app/checkout/page.tsx +49 -0
  35. package/template/app/src/app/globals.css +42 -0
  36. package/template/app/src/app/layout.tsx +105 -0
  37. package/template/app/src/app/order/[id]/confirmed/page.tsx +77 -0
  38. package/template/app/src/app/page.tsx +25 -0
  39. package/template/app/src/app/products/[handle]/page.tsx +58 -0
  40. package/template/app/src/app/providers.tsx +54 -0
  41. package/template/app/src/app/search/page.tsx +20 -0
  42. package/template/app/src/lib/browser-client.ts +35 -0
  43. package/template/app/src/lib/cart-actions.ts +43 -0
  44. package/template/app/src/lib/config.ts +16 -0
  45. package/template/app/src/lib/server-client.ts +25 -0
  46. package/template/app/tailwind.config.cjs +9 -0
  47. package/template/app/tsconfig.json +41 -0
  48. package/template/app/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,153 @@
1
+ # Gift cards — tender on carts
2
+
3
+ Gift-card redemption is a **payment tender** (`pp_giftcard`), never a
4
+ discount: cart totals and VAT compute first and **never move**; applied
5
+ cards cover part (or all) of `cart.total` and the remainder provider
6
+ (Stripe/COD/manual) charges only what is left. Balances derive from an
7
+ append-only transactions ledger — resolved LIVE on every cart read, so a
8
+ balance spent elsewhere shrinks this cart's tender instead of
9
+ over-redeeming. Actual redemption happens ONLY at cart complete, atomically.
10
+
11
+ **Auth for every endpoint on this page:** anon `x-client-id`.
12
+
13
+ SDK module: `@cartbase/storefront/api/gift-cards`. Zero-remainder checkout
14
+ (cards cover everything) is part of the Buy-click sequence — see
15
+ [checkout.md](checkout.md).
16
+
17
+ ---
18
+
19
+ ## Cart decoration (every cart read)
20
+
21
+ Every cart response carries three tender fields (declared on the `Cart` DTO
22
+ in `@cartbase/storefront/api/carts`; see [carts.md](carts.md) for the full
23
+ cart shape):
24
+
25
+ ```jsonc
26
+ {
27
+ "cart": {
28
+ // …totals (NEVER moved by gift cards)…
29
+ "total": 54,
30
+ "gift_cards": [ // apply order; each covers min(live balance, remaining total)
31
+ { "id": "gift_…", "last4": "PQRS", "amount": 25 }
32
+ // a disabled/expired/depleted card stays listed at amount: 0
33
+ ],
34
+ "gift_card_total": 25, // Σ coverage = the pp_giftcard session amount
35
+ "gift_card_remainder": 29 // max(total − gift_card_total, 0) — what the remainder provider charges
36
+ }
37
+ }
38
+ ```
39
+
40
+ ```bash
41
+ # The decoration fields exist on every cart read (empty state shown here).
42
+ CART_JSON=$(curl -sf -X POST "$BASE/api/store/carts" \
43
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
44
+ -d '{"email":"gc-doc-'"$RUN"'@example.test",
45
+ "items":[{"variant_id":"variant_01tst000000000000000002","quantity":1}]}')
46
+ CART_ID=$(echo "$CART_JSON" | grep -o '"id":"cart_[^"]*"' | head -1 | cut -d'"' -f4)
47
+ echo "$CART_JSON" | grep -q '"gift_cards":\[\]'
48
+ echo "$CART_JSON" | grep -q '"gift_card_total":0'
49
+ echo "$CART_JSON" | grep -q '"gift_card_remainder"'
50
+ ```
51
+
52
+ ---
53
+
54
+ ## POST /api/store/carts/:id/gift-cards — apply a code
55
+
56
+ - **Purpose** — apply a gift-card code to an open cart (idempotent
57
+ re-apply).
58
+ - **Auth** — anon `x-client-id`.
59
+ - **Request** — `{code}` (`.strict()`, 4–64 chars). The code travels ONCE,
60
+ here; it is hashed at rest and never echoed back.
61
+ - **Response** — `200 {cart, gift_card}` — a MASKED confirmation:
62
+
63
+ ```jsonc
64
+ {
65
+ "cart": { "…": "decorated cart — gift_cards/gift_card_total/gift_card_remainder updated, total untouched" },
66
+ "gift_card": {
67
+ "id": "gift_…",
68
+ "last4": "PQRS", // last 4 chars of the code — never the code
69
+ "amount_applied": 25 // how much of THIS cart this card covers right now
70
+ }
71
+ }
72
+ ```
73
+
74
+ - **Errors** — deliberately a single generic answer, **no code-existence
75
+ oracle**: unknown, disabled, expired, depleted and foreign-tenant codes
76
+ are all `400 {code: "invalid_gift_card"}`. Attempts are recorded BEFORE
77
+ lookup and rate-limited per cart (10 / 15 min) AND per IP (30 / 15 min) →
78
+ `429 {code: "rate_limited"}` — a valid code inside a burned window is
79
+ refused the same way. Also 404 `cart_not_found`, 409 `cart_completed`,
80
+ 400 `validation_failed`.
81
+ - **SDK** — `giftCards.applyGiftCard(client, cartId, {code})`.
82
+ - **Components** — checkout gift-card field (show `last4` +
83
+ `amount_applied`; on 400 show one generic "code cannot be applied"
84
+ message — do NOT branch on failure reasons that the API deliberately
85
+ hides).
86
+ - **Settings** — cards are issued/disabled in the admin (gift-cards admin
87
+ surface); expiry + balance live on the card's ledger. Applying also syncs
88
+ the internal `pp_giftcard` payment session when a payment collection
89
+ already exists (creating one composes it — checkout.md).
90
+
91
+ ```bash
92
+ # Executable error contract: the generic-oracle answer. (A happy-path apply
93
+ # needs an admin-issued card — shown in the jsonc above; proven end-to-end
94
+ # by tests/store/gift-cards.test.ts.)
95
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
96
+ "$BASE/api/store/carts/$CART_ID/gift-cards" \
97
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
98
+ -H "x-forwarded-for: 10.90.$((RANDOM % 250)).$((RANDOM % 250))" \
99
+ -d '{"code":"NOPE-NOPE-NOPE-NOPE"}')
100
+ test "$STATUS" = 400
101
+ RES=$(curl -s -X POST "$BASE/api/store/carts/$CART_ID/gift-cards" \
102
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
103
+ -H "x-forwarded-for: 10.90.$((RANDOM % 250)).$((RANDOM % 250))" \
104
+ -d '{"code":"ALSO-NOT-A-REAL-CODE"}')
105
+ echo "$RES" | grep -q '"code":"invalid_gift_card"'
106
+ ```
107
+
108
+ ---
109
+
110
+ ## DELETE /api/store/carts/:id/gift-cards — remove an applied card
111
+
112
+ - **Purpose** — remove an applied card from an open cart (idempotent —
113
+ removing a never-applied id still returns the cart).
114
+ - **Auth** — anon `x-client-id`.
115
+ - **Request** — `{gift_card_id}` (`.strict()`; the id from
116
+ `cart.gift_cards[].id` or the apply confirmation).
117
+ - **Response** — `200 {cart}` (decoration recomputed; the `pp_giftcard`
118
+ session re-synced to the remaining tender).
119
+ - **Errors** — 404 `cart_not_found`, 409 `cart_completed`, 400
120
+ `validation_failed`.
121
+ - **SDK** — `giftCards.removeGiftCard(client, cartId, {gift_card_id})`.
122
+ - **Components** — checkout applied-cards list (remove chip).
123
+
124
+ ```bash
125
+ # Idempotent remove is executable without an issued card.
126
+ curl -sf -X DELETE "$BASE/api/store/carts/$CART_ID/gift-cards" \
127
+ -H "x-client-id: $CLIENT_ID" -H "content-type: application/json" \
128
+ -d '{"gift_card_id":"gift_never_applied"}' | grep -q '"gift_card_total":0'
129
+ ```
130
+
131
+ ---
132
+
133
+ ## What happens at complete (contract summary)
134
+
135
+ Redemption is atomic at `POST /carts/:id/complete` — idempotent per
136
+ (card, order); two carts racing one balance: exactly one completes, the
137
+ loser gets `402 gift_card_insufficient_balance` and its order is
138
+ compensated away. A remainder-payment failure reverses this order's
139
+ redemptions back to the cards before rethrowing. A gift session that no
140
+ longer covers its amount (card disabled/drained since apply) →
141
+ `402 payment_incomplete` / `402 gift_card_not_redeemable`, nothing burned.
142
+ Zero-remainder carts complete on the gift session alone — no provider
143
+ involved. Refunds/cancels always reverse tender **to the card**, never to
144
+ a bank. Buying gift-card products (digital issue-on-purchase, physical
145
+ activate-at-packing) is a catalog concern — see the product docs and the
146
+ gift-cards admin surface.
147
+
148
+ ## Cleanup / accretion note
149
+
150
+ This page creates carts only (no store-facing delete endpoint exists —
151
+ same inert accretion as the suite's own cart tests) and applies no real
152
+ cards. Invalid-code attempts land in the rate-limit ledger under a unique
153
+ random IP per run, far below the per-IP window.
@@ -0,0 +1,137 @@
1
+ # Store integrations config — carriers, COD, tracking, lockers
2
+
3
+ Store-public integration configuration for render/checkout time
4
+ (couriers-port + tracking-integrations cards). The payload is **composed**
5
+ from an ordered block registry — each block owns distinct top-level keys
6
+ (`carriers`, `cod`, `tracking` today; future blocks append). It is NOT
7
+ wrapped in an envelope: the blocks ARE the top-level keys.
8
+
9
+ **Security law (the allowlist rule)**: every block is an explicit
10
+ allowlist — objects are built field-by-field from capability descriptors
11
+ and the few named public config values. Credentials are never read into a
12
+ response object, so **secrets can never appear in this payload**: carrier
13
+ API keys, Meta CAPI `access_token`, GA4 `api_secret`, Klaviyo
14
+ `private_key`. The executable block below asserts their absence on the
15
+ live payload; `tests/store/store-integrations.test.ts` asserts it
16
+ key-by-key.
17
+
18
+ ## GET /api/store/integrations — the composed config
19
+
20
+ - **Purpose**: one call at storefront boot/checkout for carrier
21
+ capabilities, the COD fee row, and the public analytics tag IDs.
22
+ - **Auth**: anon (`x-client-id`); `x-publishable-api-key` is **validated
23
+ when present** (unknown/revoked/foreign-tenant key →
24
+ `400 invalid_publishable_key`) and may be omitted by single-channel
25
+ storefronts.
26
+ - **Request**: no params.
27
+ - **Response**:
28
+
29
+ ```jsonc
30
+ {
31
+ // Only ENABLED carriers appear — disabled/unconfigured are ABSENT,
32
+ // never `enabled: false`. Keyed by provider slug.
33
+ "carriers": {
34
+ "boxnow": {
35
+ "enabled": true,
36
+ "cod": true, // carrier collects cash on delivery
37
+ "pickup_points": false, // office/pickup-point delivery
38
+ "lockers": true, // locker/APM network
39
+ "lockers_url": "/api/store/integrations/boxnow/lockers" // only when lockers
40
+ }
41
+ },
42
+ // null unless the cod integration is enabled AND fee_amount > 0 — the
43
+ // exact gate the totals engine applies, so the storefront's optimistic
44
+ // fee row can never disagree with the charged total. EUR only.
45
+ "cod": {
46
+ "enabled": true,
47
+ "fee_amount": 3.5, // EUR major units
48
+ "fee_currency": "eur",
49
+ "fee_label": "Cash on delivery fee",
50
+ "description": null // admin's checkout note, or null
51
+ },
52
+ // Public tag config — only ENABLED providers with a public id appear.
53
+ "tracking": {
54
+ "facebookPixel": { "pixelId": "123…" },
55
+ "gtm": { "containerId": "GTM-…" },
56
+ "ga4": { "measurementId": "G-…" },
57
+ "klaviyo": { "publicKey": "…" },
58
+ "googleAds": { "conversionId": "AW-…", "conversionLabel": "…" },
59
+ "consent_required": true // always present — mirrors the consent CMP's enabled
60
+ }
61
+ }
62
+ ```
63
+
64
+ - **Errors**: `400 missing_client_id` · `400 invalid_publishable_key`.
65
+ - **SDK**: `integrations.getIntegrationsConfig(client)`
66
+ - **Components**: carrier/locker pickers, COD fee row in checkout, tracking
67
+ mounts (`<MetaPixel>/<GA4>/<Gtm>` behind the consent gate).
68
+ - **Settings**: admin → Settings → Integrations (per-provider enable +
69
+ config); consent settings drive `tracking.consent_required`; Rybbit is
70
+ deliberately ABSENT (platform analytics, not a tenant integration).
71
+
72
+ Tracking wiring contract: mount tags only through the consent gate when
73
+ `consent_required` (see [consent.md](consent.md)); Purchase events MUST use
74
+ `eventID = "purchase_" + order.display_id` so Meta dedupes browser Pixel vs
75
+ server CAPI; write TrackingAttribution keys into `cart.metadata`
76
+ (consent-gated) so server events inherit fbp/fbc/anon-id/ga signals.
77
+
78
+ ```bash
79
+ BODY=$(curl -sf "$BASE/api/store/integrations" -H "x-client-id: $CLIENT_ID")
80
+ echo "$BODY" | grep -q '"carriers"'
81
+ echo "$BODY" | grep -q '"tracking"'
82
+ echo "$BODY" | grep -q '"consent_required"'
83
+ # THE ALLOWLIST RULE, asserted negatively: no secret key names, ever.
84
+ if echo "$BODY" | grep -qE 'access_token|api_secret|private_key|client_secret|credentials'; then
85
+ echo "SECRET LEAK in /api/store/integrations payload"; exit 1
86
+ fi
87
+ # A valid publishable key passes validation (B2B channel-scoped dev key).
88
+ curl -sf "$BASE/api/store/integrations" -H "x-client-id: $CLIENT_ID" \
89
+ -H "x-publishable-api-key: $PUBLISHABLE_KEY" | grep -q '"tracking"'
90
+ # A bogus key → 400 invalid_publishable_key.
91
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/integrations" \
92
+ -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: pk_doc_bogus_$RUN")
93
+ test "$STATUS" = 400
94
+ ```
95
+
96
+ ## GET /api/store/integrations/boxnow/lockers — locker directory
97
+
98
+ - **Purpose**: the BoxNow APM directory for the checkout locker picker.
99
+ Discover availability via `carriers.boxnow.lockers_url` on the config
100
+ above — don't probe for the 503.
101
+ - **Auth**: anon (`x-client-id`).
102
+ - **Response**:
103
+
104
+ ```jsonc
105
+ {
106
+ "lockers": [
107
+ {
108
+ "id": "42",
109
+ "title": "BoxNow Sofia Center",
110
+ "addressLine1": "bul. Vitosha 1",
111
+ "addressLine2": "",
112
+ "postalCode": "1000",
113
+ "country": "BG",
114
+ "lat": 42.6977, // number or null — malformed carrier data → null, never NaN
115
+ "lng": 23.3219,
116
+ "note": ""
117
+ }
118
+ ]
119
+ }
120
+ ```
121
+
122
+ - **Errors**: `503 {message, lockers: []}` (BoxNow not configured/enabled
123
+ for the store) · `502 {message, lockers: []}` (carrier call failed).
124
+ Every state carries the `lockers` key — a picker can always map over it.
125
+ - **Caching**: in-process 10-min TTL per store +
126
+ `Cache-Control: public, max-age=600, stale-while-revalidate=3600`.
127
+ - **SDK**: `integrations.listBoxNowLockers(client)`
128
+ - **Components**: checkout locker picker (carrier picker family).
129
+ - **Settings**: BoxNow credentials + enable in admin → Settings →
130
+ Integrations.
131
+
132
+ ```bash
133
+ # The always-true contract: whatever the configuration state (200 with a
134
+ # directory, 503 unconfigured, 502 upstream), the payload carries `lockers`.
135
+ curl -s "$BASE/api/store/integrations/boxnow/lockers" -H "x-client-id: $CLIENT_ID" \
136
+ | grep -q '"lockers"'
137
+ ```
@@ -0,0 +1,73 @@
1
+ # Menus — backend-owned navigation
2
+
3
+ Shopify Storefront `Menu` shape **verbatim** (content-navigation card).
4
+ Items store a typed resource reference; `url` is **computed at read time**
5
+ from the live handle (`src/lib/content/paths.ts`) — a product/page handle
6
+ rename never breaks a menu. Items whose referenced resource is deleted or
7
+ not storefront-visible (draft page/post/product, inactive or internal
8
+ category) are **skipped, subtree included** — the payload only ever contains
9
+ renderable links. Nesting is 3 levels max.
10
+
11
+ ## GET /api/store/menus/:handle — one menu
12
+
13
+ - **Purpose**: render the storefront navigation (header `main-menu`, footer
14
+ menus — handles are merchant-chosen in admin → Content → Navigation).
15
+ - **Auth**: anon (`x-client-id`).
16
+ - **Request**: path handle only.
17
+ - **Response**:
18
+
19
+ ```jsonc
20
+ {
21
+ "menu": {
22
+ "handle": "main-menu",
23
+ "title": "Main menu",
24
+ "items": [
25
+ {
26
+ "title": "Shop",
27
+ "type": "collection", // frontpage|collection|product|category|page|blog_post|external
28
+ "url": "/collections/all", // computed at read time; "/" for frontpage; verbatim for external
29
+ "resourceId": "pcol_<hex>", // null for frontpage/external
30
+ "items": [ /* same shape, ≤ 3 levels total */ ]
31
+ }
32
+ ]
33
+ }
34
+ }
35
+ ```
36
+
37
+ - **Errors**: `404 {code: "not_found"}` — unknown OR deleted handle. A
38
+ store with zero menus 404s every handle; the storefront must handle this
39
+ gracefully (render no nav) — the API never 500s for it.
40
+ - **SDK**: `menus.getMenu(client, handle)`
41
+ - **Components**: header navigation, footer link lists, mobile drawer.
42
+ - **Settings**: menus are authored in admin → Content → Navigation;
43
+ deleting a referenced resource silently drops its item here.
44
+
45
+ ### ETag / caching semantics
46
+
47
+ - Every 200 carries `ETag` (sha1 of the payload) and
48
+ `Cache-Control: public, s-maxage=60, stale-while-revalidate=3600`.
49
+ - A request with `If-None-Match: <etag>` answers **304** with an empty body
50
+ when the menu is unchanged. Browsers do this transparently. Next.js RSC
51
+ callers: `menus.getMenu(client, handle)` takes no per-call
52
+ `RequestOptions` (none of the SDK domain functions forward them yet) —
53
+ cache at the route level (`export const revalidate = 60`) or supply a
54
+ caching `fetch` in `StorefrontClientConfig.fetch` instead of hand-rolling
55
+ ETag replay.
56
+ - The `menu.updated` domain event fires on every menu mutation — the future
57
+ cache-revalidation hook.
58
+
59
+ ```bash
60
+ # Unknown handle → the clean 404 contract with the stable code (the state
61
+ # every storefront must survive: a deleted menu it still references).
62
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
63
+ "$BASE/api/store/menus/doc-no-such-menu-$RUN" -H "x-client-id: $CLIENT_ID")
64
+ test "$STATUS" = 404
65
+ curl -s "$BASE/api/store/menus/doc-no-such-menu-$RUN" -H "x-client-id: $CLIENT_ID" \
66
+ | grep -q '"code":"not_found"'
67
+ ```
68
+
69
+ > Menus are admin-authored and the shared dev tenant seeds none, so the
70
+ > happy path (payload shape, ETag → 304 round-trip) is pinned executably by
71
+ > `tests/store/menus-store.test.ts` and the SDK contract test
72
+ > `tests/contract/sdk-content.contract.test.ts`, which create a menu with
73
+ > admin auth.
@@ -0,0 +1,126 @@
1
+ # Metaobjects — merchant-defined content types
2
+
3
+ Merchant-defined structured content (size charts, brand profiles, FAQ
4
+ blocks) served by TYPE + HANDLE (metaobjects card). **ACTIVE entries only —
5
+ drafts 404** (lib filter AND anon RLS, defense in depth).
6
+
7
+ Capabilities per definition:
8
+ - `renderable` → payload gains `seo {title, description}` (title falls back
9
+ to `displayName`, description to null);
10
+ - `online_store` → payload gains `url` (`/metaobjects/<type>/<handle>` —
11
+ the storefront prepends its origin).
12
+
13
+ References resolve at **read** time: a target the storefront cannot see
14
+ (deleted, or draft under anon RLS) yields `reference: null` — never
15
+ dropped, never a 500. `by-metafield` is a **reserved type slug** (the
16
+ static route shadows `/:type`; rejected at definition create).
17
+
18
+ Metaobject shape (all endpoints):
19
+
20
+ ```jsonc
21
+ {
22
+ "id": "uuid",
23
+ "type": "size-chart",
24
+ "handle": "shirts-eu",
25
+ "displayName": "Shirts (EU)",
26
+ "fields": [
27
+ { "key": "body", "kind": "rich_text", "value": "<table>…</table>" },
28
+ { "key": "product", "kind": "product_reference", "value": "prod_…",
29
+ "reference": { "id": "prod_…", "title": "Linen Shirt", "handle": "linen-shirt" } }
30
+ ],
31
+ "seo": { "title": "Shirts (EU)", "description": null }, // renderable only
32
+ "url": "/metaobjects/size-chart/shirts-eu", // online_store only
33
+ "updatedAt": "ISO-8601"
34
+ }
35
+ ```
36
+
37
+ > Definitions + entries are admin-authored; the shared dev tenant seeds
38
+ > none, so the executable blocks prove the 404/400 contracts and the happy
39
+ > paths are pinned by `tests/store/metaobjects-store.test.ts` (admin-auth
40
+ > fixtures).
41
+
42
+ ## GET /api/store/metaobjects/:type — list entries
43
+
44
+ - **Purpose**: list ACTIVE entries of one type (e.g. all FAQ blocks),
45
+ newest-updated first.
46
+ - **Auth**: anon (`x-client-id`).
47
+ - **Request**: query `{limit?, offset?}` — `limit` clamped to 1–100
48
+ (default 20; out-of-range values are clamped, not rejected).
49
+ - **Response**: `{metaobjects: [Metaobject…], count, offset, limit}` — list
50
+ payloads carry **raw** field values (`reference` NOT resolved; the
51
+ by-handle read is the resolve hot path). `seo`/`url` capabilities still
52
+ apply.
53
+ - **Errors**: `404 not_found` (unknown type).
54
+ - **SDK**: `metaobjects.listMetaobjects(client, type, query?)`
55
+ - **Components**: FAQ/content-block section renderers.
56
+ - **Settings**: definitions + entry status in admin → Content → Metaobjects.
57
+
58
+ ```bash
59
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
60
+ "$BASE/api/store/metaobjects/doc-no-such-type-$RUN" -H "x-client-id: $CLIENT_ID")
61
+ test "$STATUS" = 404
62
+ curl -s "$BASE/api/store/metaobjects/doc-no-such-type-$RUN" -H "x-client-id: $CLIENT_ID" \
63
+ | grep -q '"code":"not_found"'
64
+ ```
65
+
66
+ ## GET /api/store/metaobjects/:type/:handle — one entry
67
+
68
+ - **Purpose**: render one entry — references RESOLVED (invisible targets →
69
+ `reference: null`).
70
+ - **Auth**: anon (`x-client-id`).
71
+ - **Response**: `{metaobject: Metaobject}` (shape above, references
72
+ resolved).
73
+ - **Errors**: `404 not_found` — unknown type, unknown handle, or a DRAFT
74
+ entry (drafts are indistinguishable from missing, by design).
75
+ - **SDK**: `metaobjects.getMetaobject(client, type, handle)`
76
+ - **Components**: size-chart modal, brand-profile block.
77
+
78
+ ```bash
79
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
80
+ "$BASE/api/store/metaobjects/doc-no-such-type-$RUN/doc-h-$RUN" \
81
+ -H "x-client-id: $CLIENT_ID")
82
+ test "$STATUS" = 404
83
+ ```
84
+
85
+ ## GET /api/store/metaobjects/by-metafield — the product→chart chain
86
+
87
+ - **Purpose**: the canonical fetch chain — resolve an entity's
88
+ `metaobject_reference` METAFIELD into full metaobject payload(s) in ONE
89
+ call: storefront reads the product (id in hand) → this call with the
90
+ metafield key → the resolved chart.
91
+ - **Auth**: anon (`x-client-id`). Leak boundary (deliberate): only
92
+ `metaobject_reference` definitions resolve here — any other metafield key
93
+ 404s, so no other metafield value can exit through this route; entries
94
+ return ACTIVE-only.
95
+ - **Request**: query `{entity_type, entity_id, key}` — all three required.
96
+ - **Response**: `metaobjects` always present, in stored order. `metaobject`
97
+ (first entry or null) is present **only for single-valued definitions** —
98
+ LIST definitions return `{metaobjects}` alone:
99
+
100
+ ```jsonc
101
+ // single-valued definition
102
+ { "metaobject": { /* Metaobject */ }, "metaobjects": [ /* [it] */ ] }
103
+ // list definition
104
+ { "metaobjects": [ /* Metaobject[] in stored order */ ] }
105
+ ```
106
+
107
+ - **Errors**: `400 invalid_data` (missing entity_type/entity_id/key) ·
108
+ `404 not_found` (no such definition, wrong field_type, or no stored
109
+ value for the entity).
110
+ - **SDK**: `metaobjects.getMetaobjectsByMetafield(client, query)`
111
+ - **Components**: PDP size-chart trigger; any metafield-driven block.
112
+ - **Settings**: the metafield definition (entity type + key +
113
+ `metaobject_reference` field type) in admin → Settings → Metafields.
114
+
115
+ ```bash
116
+ # Missing params → 400 invalid_data.
117
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
118
+ "$BASE/api/store/metaobjects/by-metafield?entity_type=product" \
119
+ -H "x-client-id: $CLIENT_ID")
120
+ test "$STATUS" = 400
121
+ # Unknown key on a real seeded product → 404 (the leak-boundary contract).
122
+ STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
123
+ "$BASE/api/store/metaobjects/by-metafield?entity_type=product&entity_id=prod_01tst00000000000000000001&key=doc-nokey-$RUN" \
124
+ -H "x-client-id: $CLIENT_ID")
125
+ test "$STATUS" = 404
126
+ ```