create-cartbase 0.1.15 → 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 (35) 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 +50 -13
  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/next.config.ts +6 -4
  23. package/template/app/package.json +1 -1
  24. package/template/app/postcss.config.cjs +11 -11
  25. package/template/app/src/app/checkout/checkout-page-client.tsx +73 -73
  26. package/template/app/src/app/checkout/page.tsx +48 -48
  27. package/template/app/src/app/globals.css +26 -26
  28. package/template/app/src/app/page.tsx +28 -28
  29. package/template/app/src/app/products/[handle]/page.tsx +87 -87
  30. package/template/app/src/app/providers.tsx +64 -64
  31. package/template/app/src/app/search/page.tsx +23 -23
  32. package/template/app/src/lib/browser-client.ts +35 -35
  33. package/template/app/src/lib/config.ts +41 -41
  34. package/template/app/src/lib/server-client.ts +25 -25
  35. package/template/app/src/lib/cart-actions.ts +0 -47
@@ -1,153 +1,153 @@
1
- # Gift cards
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.
1
+ # Gift cards
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.
@@ -1,126 +1,126 @@
1
- # Metaobjects
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
- ```
1
+ # Metaobjects
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
+ ```