# Metaobjects — merchant-defined content types

Merchant-defined structured content (size charts, brand profiles, FAQ
blocks) served by TYPE + HANDLE (metaobjects card). **ACTIVE entries only —
drafts 404** (lib filter AND anon RLS, defense in depth).

Capabilities per definition:
- `renderable` → payload gains `seo {title, description}` (title falls back
  to `displayName`, description to null);
- `online_store` → payload gains `url` (`/metaobjects/<type>/<handle>` —
  the storefront prepends its origin).

References resolve at **read** time: a target the storefront cannot see
(deleted, or draft under anon RLS) yields `reference: null` — never
dropped, never a 500. `by-metafield` is a **reserved type slug** (the
static route shadows `/:type`; rejected at definition create).

Metaobject shape (all endpoints):

```jsonc
{
  "id": "uuid",
  "type": "size-chart",
  "handle": "shirts-eu",
  "displayName": "Shirts (EU)",
  "fields": [
    { "key": "body", "kind": "rich_text", "value": "<table>…</table>" },
    { "key": "product", "kind": "product_reference", "value": "prod_…",
      "reference": { "id": "prod_…", "title": "Linen Shirt", "handle": "linen-shirt" } }
  ],
  "seo": { "title": "Shirts (EU)", "description": null },  // renderable only
  "url": "/metaobjects/size-chart/shirts-eu",              // online_store only
  "updatedAt": "ISO-8601"
}
```

> Definitions + entries are admin-authored; the shared dev tenant seeds
> none, so the executable blocks prove the 404/400 contracts and the happy
> paths are pinned by `tests/store/metaobjects-store.test.ts` (admin-auth
> fixtures).

## GET /api/store/metaobjects/:type — list entries

- **Purpose**: list ACTIVE entries of one type (e.g. all FAQ blocks),
  newest-updated first.
- **Auth**: anon (`x-client-id`).
- **Request**: query `{limit?, offset?}` — `limit` clamped to 1–100
  (default 20; out-of-range values are clamped, not rejected).
- **Response**: `{metaobjects: [Metaobject…], count, offset, limit}` — list
  payloads carry **raw** field values (`reference` NOT resolved; the
  by-handle read is the resolve hot path). `seo`/`url` capabilities still
  apply.
- **Errors**: `404 not_found` (unknown type).
- **SDK**: `metaobjects.listMetaobjects(client, type, query?)`
- **Components**: FAQ/content-block section renderers.
- **Settings**: definitions + entry status in admin → Content → Metaobjects.

```bash
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/metaobjects/doc-no-such-type-$RUN" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
curl -s "$BASE/api/store/metaobjects/doc-no-such-type-$RUN" -H "x-client-id: $CLIENT_ID" \
  | grep -q '"code":"not_found"'
```

## GET /api/store/metaobjects/:type/:handle — one entry

- **Purpose**: render one entry — references RESOLVED (invisible targets →
  `reference: null`).
- **Auth**: anon (`x-client-id`).
- **Response**: `{metaobject: Metaobject}` (shape above, references
  resolved).
- **Errors**: `404 not_found` — unknown type, unknown handle, or a DRAFT
  entry (drafts are indistinguishable from missing, by design).
- **SDK**: `metaobjects.getMetaobject(client, type, handle)`
- **Components**: size-chart modal, brand-profile block.

```bash
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/metaobjects/doc-no-such-type-$RUN/doc-h-$RUN" \
  -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
```

## GET /api/store/metaobjects/by-metafield — the product→chart chain

- **Purpose**: the canonical fetch chain — resolve an entity's
  `metaobject_reference` METAFIELD into full metaobject payload(s) in ONE
  call: storefront reads the product (id in hand) → this call with the
  metafield key → the resolved chart.
- **Auth**: anon (`x-client-id`). Leak boundary (deliberate): only
  `metaobject_reference` definitions resolve here — any other metafield key
  404s, so no other metafield value can exit through this route; entries
  return ACTIVE-only.
- **Request**: query `{entity_type, entity_id, key}` — all three required.
- **Response**: `metaobjects` always present, in stored order. `metaobject`
  (first entry or null) is present **only for single-valued definitions** —
  LIST definitions return `{metaobjects}` alone:

```jsonc
// single-valued definition
{ "metaobject": { /* Metaobject */ }, "metaobjects": [ /* [it] */ ] }
// list definition
{ "metaobjects": [ /* Metaobject[] in stored order */ ] }
```

- **Errors**: `400 invalid_data` (missing entity_type/entity_id/key) ·
  `404 not_found` (no such definition, wrong field_type, or no stored
  value for the entity).
- **SDK**: `metaobjects.getMetaobjectsByMetafield(client, query)`
- **Components**: PDP size-chart trigger; any metafield-driven block.
- **Settings**: the metafield definition (entity type + key +
  `metaobject_reference` field type) in admin → Settings → Metafields.

```bash
# Missing params → 400 invalid_data.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/metaobjects/by-metafield?entity_type=product" \
  -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 400
# Unknown key on a real seeded product → 404 (the leak-boundary contract).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/metaobjects/by-metafield?entity_type=product&entity_id=prod_01tst00000000000000000001&key=doc-nokey-$RUN" \
  -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
```
