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:
{
  "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.
# 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.