# Menus — backend-owned navigation

Shopify Storefront `Menu` shape **verbatim** (content-navigation card).
Items store a typed resource reference; `url` is **computed at read time**
from the live handle (`src/lib/content/paths.ts`) — a product/page handle
rename never breaks a menu. Items whose referenced resource is deleted or
not storefront-visible (draft page/post/product, inactive or internal
category) are **skipped, subtree included** — the payload only ever contains
renderable links. Nesting is 3 levels max.

## GET /api/store/menus/:handle — one menu

- **Purpose**: render the storefront navigation (header `main-menu`, footer
  menus — handles are merchant-chosen in admin → Content → Navigation).
- **Auth**: anon (`x-client-id`).
- **Request**: path handle only.
- **Response**:

```jsonc
{
  "menu": {
    "handle": "main-menu",
    "title": "Main menu",
    "items": [
      {
        "title": "Shop",
        "type": "collection",       // frontpage|collection|product|category|page|blog_post|external
        "url": "/collections/all",  // computed at read time; "/" for frontpage; verbatim for external
        "resourceId": "pcol_<hex>", // null for frontpage/external
        "items": [ /* same shape, ≤ 3 levels total */ ]
      }
    ]
  }
}
```

- **Errors**: `404 {code: "not_found"}` — unknown OR deleted handle. A
  store with zero menus 404s every handle; the storefront must handle this
  gracefully (render no nav) — the API never 500s for it.
- **SDK**: `menus.getMenu(client, handle)`
- **Components**: header navigation, footer link lists, mobile drawer.
- **Settings**: menus are authored in admin → Content → Navigation;
  deleting a referenced resource silently drops its item here.

### ETag / caching semantics

- Every 200 carries `ETag` (sha1 of the payload) and
  `Cache-Control: public, s-maxage=60, stale-while-revalidate=3600`.
- A request with `If-None-Match: <etag>` answers **304** with an empty body
  when the menu is unchanged. Browsers do this transparently. Next.js RSC
  callers: `menus.getMenu(client, handle)` takes no per-call
  `RequestOptions` (none of the SDK domain functions forward them yet) —
  cache at the route level (`export const revalidate = 60`) or supply a
  caching `fetch` in `StorefrontClientConfig.fetch` instead of hand-rolling
  ETag replay.
- The `menu.updated` domain event fires on every menu mutation — the future
  cache-revalidation hook.

```bash
# Unknown handle → the clean 404 contract with the stable code (the state
# every storefront must survive: a deleted menu it still references).
STATUS=$(curl -s -o /dev/null -w '%{http_code}' \
  "$BASE/api/store/menus/doc-no-such-menu-$RUN" -H "x-client-id: $CLIENT_ID")
test "$STATUS" = 404
curl -s "$BASE/api/store/menus/doc-no-such-menu-$RUN" -H "x-client-id: $CLIENT_ID" \
  | grep -q '"code":"not_found"'
```

> Menus are admin-authored and the shared dev tenant seeds none, so the
> happy path (payload shape, ETag → 304 round-trip) is pinned executably by
> `tests/store/menus-store.test.ts` and the SDK contract test
> `tests/contract/sdk-content.contract.test.ts`, which create a menu with
> admin auth.
