# Store integrations config — carriers, COD, tracking, lockers

Store-public integration configuration for render/checkout time
(couriers-port + tracking-integrations cards). The payload is **composed**
from an ordered block registry — each block owns distinct top-level keys
(`carriers`, `cod`, `tracking` today; future blocks append). It is NOT
wrapped in an envelope: the blocks ARE the top-level keys.

**Security law (the allowlist rule)**: every block is an explicit
allowlist — objects are built field-by-field from capability descriptors
and the few named public config values. Credentials are never read into a
response object, so **secrets can never appear in this payload**: carrier
API keys, Meta CAPI `access_token`, GA4 `api_secret`, Klaviyo
`private_key`. The executable block below asserts their absence on the
live payload; `tests/store/store-integrations.test.ts` asserts it
key-by-key.

## GET /api/store/integrations — the composed config

- **Purpose**: one call at storefront boot/checkout for carrier
  capabilities, the COD fee row, and the public analytics tag IDs.
- **Auth**: anon (`x-client-id`); `x-publishable-api-key` is **validated
  when present** (unknown/revoked/foreign-tenant key →
  `400 invalid_publishable_key`) and may be omitted by single-channel
  storefronts.
- **Request**: no params.
- **Response**:

```jsonc
{
  // Only ENABLED carriers appear — disabled/unconfigured are ABSENT,
  // never `enabled: false`. Keyed by provider slug.
  "carriers": {
    "boxnow": {
      "enabled": true,
      "cod": true,             // carrier collects cash on delivery
      "pickup_points": false,  // office/pickup-point delivery
      "lockers": true,         // locker/APM network
      "lockers_url": "/api/store/integrations/boxnow/lockers"  // only when lockers
    }
  },
  // null unless the cod integration is enabled AND fee_amount > 0 — the
  // exact gate the totals engine applies, so the storefront's optimistic
  // fee row can never disagree with the charged total. EUR only.
  "cod": {
    "enabled": true,
    "fee_amount": 3.5,          // EUR major units
    "fee_currency": "eur",
    "fee_label": "Cash on delivery fee",
    "description": null          // admin's checkout note, or null
  },
  // Public tag config — only ENABLED providers with a public id appear.
  "tracking": {
    "facebookPixel": { "pixelId": "123…" },
    "gtm": { "containerId": "GTM-…" },
    "ga4": { "measurementId": "G-…" },
    "klaviyo": { "publicKey": "…" },
    "googleAds": { "conversionId": "AW-…", "conversionLabel": "…" },
    "consent_required": true     // always present — mirrors the consent CMP's enabled
  }
}
```

- **Errors**: `400 missing_client_id` · `400 invalid_publishable_key`.
- **SDK**: `integrations.getIntegrationsConfig(client)`
- **Components**: carrier/locker pickers, COD fee row in checkout, tracking
  mounts (`<MetaPixel>/<GA4>/<Gtm>` behind the consent gate).
- **Settings**: admin → Settings → Integrations (per-provider enable +
  config); consent settings drive `tracking.consent_required`; Rybbit is
  deliberately ABSENT (platform analytics, not a tenant integration).

Tracking wiring contract: mount tags only through the consent gate when
`consent_required` (see [consent.md](consent.md)); Purchase events MUST use
`eventID = "purchase_" + order.display_id` so Meta dedupes browser Pixel vs
server CAPI; write TrackingAttribution keys into `cart.metadata`
(consent-gated) so server events inherit fbp/fbc/anon-id/ga signals.

```bash
BODY=$(curl -sf "$BASE/api/store/integrations" -H "x-client-id: $CLIENT_ID")
echo "$BODY" | grep -q '"carriers"'
echo "$BODY" | grep -q '"tracking"'
echo "$BODY" | grep -q '"consent_required"'
# THE ALLOWLIST RULE, asserted negatively: no secret key names, ever.
if echo "$BODY" | grep -qE 'access_token|api_secret|private_key|client_secret|credentials'; then
  echo "SECRET LEAK in /api/store/integrations payload"; exit 1
fi
# A valid publishable key passes validation (B2B channel-scoped dev key).
curl -sf "$BASE/api/store/integrations" -H "x-client-id: $CLIENT_ID" \
  -H "x-publishable-api-key: $PUBLISHABLE_KEY" | grep -q '"tracking"'
# A bogus key → 400 invalid_publishable_key.
STATUS=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/store/integrations" \
  -H "x-client-id: $CLIENT_ID" -H "x-publishable-api-key: pk_doc_bogus_$RUN")
test "$STATUS" = 400
```

## GET /api/store/integrations/boxnow/lockers — locker directory

- **Purpose**: the BoxNow APM directory for the checkout locker picker.
  Discover availability via `carriers.boxnow.lockers_url` on the config
  above — don't probe for the 503.
- **Auth**: anon (`x-client-id`).
- **Response**:

```jsonc
{
  "lockers": [
    {
      "id": "42",
      "title": "BoxNow Sofia Center",
      "addressLine1": "bul. Vitosha 1",
      "addressLine2": "",
      "postalCode": "1000",
      "country": "BG",
      "lat": 42.6977,   // number or null — malformed carrier data → null, never NaN
      "lng": 23.3219,
      "note": ""
    }
  ]
}
```

- **Errors**: `503 {message, lockers: []}` (BoxNow not configured/enabled
  for the store) · `502 {message, lockers: []}` (carrier call failed).
  Every state carries the `lockers` key — a picker can always map over it.
- **Caching**: in-process 10-min TTL per store +
  `Cache-Control: public, max-age=600, stale-while-revalidate=3600`.
- **SDK**: `integrations.listBoxNowLockers(client)`
- **Components**: checkout locker picker (carrier picker family).
- **Settings**: BoxNow credentials + enable in admin → Settings →
  Integrations.

```bash
# The always-true contract: whatever the configuration state (200 with a
# directory, 503 unconfigured, 502 upstream), the payload carries `lockers`.
curl -s "$BASE/api/store/integrations/boxnow/lockers" -H "x-client-id: $CLIENT_ID" \
  | grep -q '"lockers"'
```
