@rebuy/rebuy 3.4.0 → 3.5.0

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.
package/dist/index.cjs CHANGED
@@ -989,6 +989,9 @@ var RebuyClient = class {
989
989
  // src/schema/cabShopConfig.ts
990
990
  var import_zod2 = require("zod");
991
991
 
992
+ // src/schema/driftedMarkets.ts
993
+ var DRIFTED_MARKETS = Object.freeze({ enabled: false });
994
+
992
995
  // src/schema/smartCart.ts
993
996
  var import_zod = require("zod");
994
997
  var SmartCartTierProduct = import_zod.z.looseObject({
@@ -1128,6 +1131,27 @@ var CabShopConfig = import_zod2.z.object({
1128
1131
  * missing/renamed currency degrades to "unknown" (client falls back to buyer decimals), never a parse fail.
1129
1132
  */
1130
1133
  currency: import_zod2.z.string().optional().catch(void 0),
1134
+ /**
1135
+ * The merchant's "Use Markets in Data Sources" toggle. The engine (CI2) never reads it on the serve
1136
+ * path, so the proxy honouring it before sending market context is what makes the setting mean
1137
+ * anything (REB-21688). Fail-CLOSED, unlike the fail-open siblings: a missing/null/drifted field reads
1138
+ * as markets OFF — market context silently dropped (engine serves shop-default market), never sent
1139
+ * against the merchant's setting. The OFF-reading salvages stay distinguishable: missing AND null are
1140
+ * the engine's normal absent vocabulary (`.nullish()`, like `monetize`/`billingVersion`) and read as
1141
+ * the plain default — not drift — while only a drifted SHAPE `.catch`es, to the {@link DRIFTED_MARKETS}
1142
+ * identity sentinel, so `fetchUserConfig` can emit a `shopConfig` drift for the one case that means an
1143
+ * engine rename/retype; otherwise a markets-enabled merchant degrading to shop-default pricing would
1144
+ * be indistinguishable from markets genuinely off. The missing-key case is EXPLICIT via `.default()`
1145
+ * (a factory, so parses never share a mutable object) rather than inferred from zod running an
1146
+ * optional key's transform on `undefined`; `null` flows past the default through `.nullish()` into the
1147
+ * transform. `Readonly` on the output type because the drifted salvage IS frozen — an assignment to
1148
+ * `markets.enabled` downstream would throw only for drifted shops in production, so surface direct
1149
+ * writes to tsc (a guard for the direct case only: TS doesn't check `readonly` in assignability, so
1150
+ * laundering through a mutable-shaped alias still compiles).
1151
+ * The inner `enabled` stays strict on purpose: `{ enabled: null }` is a genuine retype and drifting is
1152
+ * the correct answer (the editor-path `ShopConfig.markets` would hard-fail the same payload).
1153
+ */
1154
+ markets: import_zod2.z.object({ enabled: import_zod2.z.boolean() }).nullish().catch(DRIFTED_MARKETS).default(() => ({ enabled: false })).transform((markets) => markets ?? { enabled: false }),
1131
1155
  monetize: import_zod2.z.object({ publisherKey: import_zod2.z.string().nullable() }).nullish().catch(void 0),
1132
1156
  shopId: import_zod2.z.number().optional().catch(void 0)
1133
1157
  });
package/dist/index.mjs CHANGED
@@ -727,6 +727,9 @@ var RebuyClient = class {
727
727
  // src/schema/cabShopConfig.ts
728
728
  import { z as z2 } from "zod";
729
729
 
730
+ // src/schema/driftedMarkets.ts
731
+ var DRIFTED_MARKETS = Object.freeze({ enabled: false });
732
+
730
733
  // src/schema/smartCart.ts
731
734
  import { z } from "zod";
732
735
  var SmartCartTierProduct = z.looseObject({
@@ -866,6 +869,27 @@ var CabShopConfig = z2.object({
866
869
  * missing/renamed currency degrades to "unknown" (client falls back to buyer decimals), never a parse fail.
867
870
  */
868
871
  currency: z2.string().optional().catch(void 0),
872
+ /**
873
+ * The merchant's "Use Markets in Data Sources" toggle. The engine (CI2) never reads it on the serve
874
+ * path, so the proxy honouring it before sending market context is what makes the setting mean
875
+ * anything (REB-21688). Fail-CLOSED, unlike the fail-open siblings: a missing/null/drifted field reads
876
+ * as markets OFF — market context silently dropped (engine serves shop-default market), never sent
877
+ * against the merchant's setting. The OFF-reading salvages stay distinguishable: missing AND null are
878
+ * the engine's normal absent vocabulary (`.nullish()`, like `monetize`/`billingVersion`) and read as
879
+ * the plain default — not drift — while only a drifted SHAPE `.catch`es, to the {@link DRIFTED_MARKETS}
880
+ * identity sentinel, so `fetchUserConfig` can emit a `shopConfig` drift for the one case that means an
881
+ * engine rename/retype; otherwise a markets-enabled merchant degrading to shop-default pricing would
882
+ * be indistinguishable from markets genuinely off. The missing-key case is EXPLICIT via `.default()`
883
+ * (a factory, so parses never share a mutable object) rather than inferred from zod running an
884
+ * optional key's transform on `undefined`; `null` flows past the default through `.nullish()` into the
885
+ * transform. `Readonly` on the output type because the drifted salvage IS frozen — an assignment to
886
+ * `markets.enabled` downstream would throw only for drifted shops in production, so surface direct
887
+ * writes to tsc (a guard for the direct case only: TS doesn't check `readonly` in assignability, so
888
+ * laundering through a mutable-shaped alias still compiles).
889
+ * The inner `enabled` stays strict on purpose: `{ enabled: null }` is a genuine retype and drifting is
890
+ * the correct answer (the editor-path `ShopConfig.markets` would hard-fail the same payload).
891
+ */
892
+ markets: z2.object({ enabled: z2.boolean() }).nullish().catch(DRIFTED_MARKETS).default(() => ({ enabled: false })).transform((markets) => markets ?? { enabled: false }),
869
893
  monetize: z2.object({ publisherKey: z2.string().nullable() }).nullish().catch(void 0),
870
894
  shopId: z2.number().optional().catch(void 0)
871
895
  });
@@ -16,7 +16,7 @@ export declare const CabActiveExperiment: z.ZodObject<{
16
16
  }, z.core.$strip>;
17
17
  export type CabActiveExperiment = z.infer<typeof CabActiveExperiment>;
18
18
  /**
19
- * The lean shop config CAB actually consumes — the five load-bearing fields, not the ~30 the
19
+ * The lean shop config CAB actually consumes — the handful of load-bearing fields, not the ~30 the
20
20
  * editor-facing `ShopConfig` carries. `apiKey` stays strict (every offers/monetize/gift/analytics
21
21
  * request authenticates with it); the rest use `.catch()` so a peripheral engine change (a renamed
22
22
  * field, a new `carousel` enum value like `'css'`, an experiment shape drift) degrades that one field
@@ -40,6 +40,13 @@ export declare const CabShopConfig: z.ZodObject<{
40
40
  apiKey: z.ZodString;
41
41
  billingVersion: z.ZodDefault<z.ZodCatch<z.ZodNullable<z.ZodString>>>;
42
42
  currency: z.ZodCatch<z.ZodOptional<z.ZodString>>;
43
+ markets: z.ZodPipe<z.ZodDefault<z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodObject<{
44
+ enabled: z.ZodBoolean;
45
+ }, z.core.$strip>>>>>, z.ZodTransform<Readonly<{
46
+ enabled: boolean;
47
+ }>, {
48
+ enabled: boolean;
49
+ } | null>>;
43
50
  monetize: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodObject<{
44
51
  publisherKey: z.ZodNullable<z.ZodString>;
45
52
  }, z.core.$strip>>>>;
@@ -70,6 +77,13 @@ export declare const CabUserConfig: z.ZodObject<{
70
77
  apiKey: z.ZodString;
71
78
  billingVersion: z.ZodDefault<z.ZodCatch<z.ZodNullable<z.ZodString>>>;
72
79
  currency: z.ZodCatch<z.ZodOptional<z.ZodString>>;
80
+ markets: z.ZodPipe<z.ZodDefault<z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodObject<{
81
+ enabled: z.ZodBoolean;
82
+ }, z.core.$strip>>>>>, z.ZodTransform<Readonly<{
83
+ enabled: boolean;
84
+ }>, {
85
+ enabled: boolean;
86
+ } | null>>;
73
87
  monetize: z.ZodCatch<z.ZodOptional<z.ZodNullable<z.ZodObject<{
74
88
  publisherKey: z.ZodNullable<z.ZodString>;
75
89
  }, z.core.$strip>>>>;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Identity sentinel for a drifted `CabShopConfig.markets` shape: the schema's `.catch` returns THIS
3
+ * frozen object, and `fetchUserConfig` detects drift by reference (`markets === DRIFTED_MARKETS`).
4
+ * Reference identity keeps the sentinel out of the wire vocabulary entirely — no key the engine could
5
+ * ever send (or forge) can mark or unmark drift, unlike a value marker in the object's own shape — and
6
+ * off the public type, which stays a plain readonly `{ enabled: boolean }`.
7
+ *
8
+ * Deliberately NOT re-exported from `src/schema/index.ts` (the barrel lists its files explicitly): each
9
+ * `exports` subpath bundles separately with no splitting, so identity only holds within one bundle, and
10
+ * a consumer-held copy from `@rebuy/rebuy` would silently never match a value from `@rebuy/rebuy/server`.
11
+ * Keeping the symbol off every public barrel makes that cross-bundle comparison unreachable instead of a
12
+ * documented caveat. Both importers (`cabShopConfig.ts`, `server/userConfig.ts`) land in the `./server`
13
+ * bundle, where the comparison is exact.
14
+ */
15
+ export declare const DRIFTED_MARKETS: Readonly<{
16
+ readonly enabled: false;
17
+ }>;
@@ -411,6 +411,9 @@ var NotFoundError = class extends Error {
411
411
  // src/schema/cabShopConfig.ts
412
412
  var import_zod6 = require("zod");
413
413
 
414
+ // src/schema/driftedMarkets.ts
415
+ var DRIFTED_MARKETS = Object.freeze({ enabled: false });
416
+
414
417
  // src/schema/smartCart.ts
415
418
  var import_zod5 = require("zod");
416
419
  var SmartCartTierProduct = import_zod5.z.looseObject({
@@ -550,6 +553,27 @@ var CabShopConfig = import_zod6.z.object({
550
553
  * missing/renamed currency degrades to "unknown" (client falls back to buyer decimals), never a parse fail.
551
554
  */
552
555
  currency: import_zod6.z.string().optional().catch(void 0),
556
+ /**
557
+ * The merchant's "Use Markets in Data Sources" toggle. The engine (CI2) never reads it on the serve
558
+ * path, so the proxy honouring it before sending market context is what makes the setting mean
559
+ * anything (REB-21688). Fail-CLOSED, unlike the fail-open siblings: a missing/null/drifted field reads
560
+ * as markets OFF — market context silently dropped (engine serves shop-default market), never sent
561
+ * against the merchant's setting. The OFF-reading salvages stay distinguishable: missing AND null are
562
+ * the engine's normal absent vocabulary (`.nullish()`, like `monetize`/`billingVersion`) and read as
563
+ * the plain default — not drift — while only a drifted SHAPE `.catch`es, to the {@link DRIFTED_MARKETS}
564
+ * identity sentinel, so `fetchUserConfig` can emit a `shopConfig` drift for the one case that means an
565
+ * engine rename/retype; otherwise a markets-enabled merchant degrading to shop-default pricing would
566
+ * be indistinguishable from markets genuinely off. The missing-key case is EXPLICIT via `.default()`
567
+ * (a factory, so parses never share a mutable object) rather than inferred from zod running an
568
+ * optional key's transform on `undefined`; `null` flows past the default through `.nullish()` into the
569
+ * transform. `Readonly` on the output type because the drifted salvage IS frozen — an assignment to
570
+ * `markets.enabled` downstream would throw only for drifted shops in production, so surface direct
571
+ * writes to tsc (a guard for the direct case only: TS doesn't check `readonly` in assignability, so
572
+ * laundering through a mutable-shaped alias still compiles).
573
+ * The inner `enabled` stays strict on purpose: `{ enabled: null }` is a genuine retype and drifting is
574
+ * the correct answer (the editor-path `ShopConfig.markets` would hard-fail the same payload).
575
+ */
576
+ markets: import_zod6.z.object({ enabled: import_zod6.z.boolean() }).nullish().catch(DRIFTED_MARKETS).default(() => ({ enabled: false })).transform((markets) => markets ?? { enabled: false }),
553
577
  monetize: import_zod6.z.object({ publisherKey: import_zod6.z.string().nullable() }).nullish().catch(void 0),
554
578
  shopId: import_zod6.z.number().optional().catch(void 0)
555
579
  });
@@ -3580,7 +3604,14 @@ var fetchUserConfig = async (input, ctx) => {
3580
3604
  }
3581
3605
  const data = unwrapData(upstream);
3582
3606
  if (isMissingShopPayload(data)) throw new NotFoundError(`Shop config not found: ${input.shop}`);
3583
- return parseShielded(CabUserConfig, data);
3607
+ const config = parseShielded(CabUserConfig, data);
3608
+ if (config.shop.markets === DRIFTED_MARKETS) {
3609
+ ctx.reportDrift?.({
3610
+ detail: "shop config markets field drifted to disabled (fail-closed salvage) \u2014 market context withheld",
3611
+ kind: "shopConfig"
3612
+ });
3613
+ }
3614
+ return config;
3584
3615
  };
3585
3616
 
3586
3617
  // src/utilities.ts
@@ -3681,19 +3712,16 @@ var reportUnreadableVerdict = (metadata, report) => {
3681
3712
  var dedupeById = (products) => (0, import_es_toolkit6.uniqBy)(products, (product) => product.id);
3682
3713
  var fetchDataSourceResults = async (input, ctx) => {
3683
3714
  const { shop } = await fetchUserConfig({ shop: input.shop }, ctx);
3684
- const marketHeaders = {
3685
- ...input.language && { "Accept-Language": input.language },
3686
- ...input.country && { "X-Country-Code": input.country }
3687
- };
3715
+ const locale = input.language?.split("-")[0]?.toLowerCase();
3716
+ const marketParams = shop.markets.enabled ? { ...input.country && { country_code: input.country }, ...locale && { locale } } : {};
3688
3717
  let upstream;
3689
3718
  try {
3690
3719
  upstream = await ctx.fetchUpstream(
3691
3720
  `/api/v1${input.dataSourcePath}`,
3692
3721
  {},
3693
3722
  {
3694
- ...Object.keys(marketHeaders).length > 0 && { headers: marketHeaders },
3695
3723
  host: ctx.host,
3696
- search: serialize(buildEngineParams(input, shop.apiKey))
3724
+ search: serialize({ ...buildEngineParams(input, shop.apiKey), ...marketParams })
3697
3725
  }
3698
3726
  );
3699
3727
  } catch (err) {
@@ -3864,7 +3892,7 @@ var validateGifts = async (input, ctx) => {
3864
3892
  const { metadata } = await fetchDataSourceResults(
3865
3893
  {
3866
3894
  cart: input.cart,
3867
- /** Market context threads through so a market-gated gift rule answers for the buyer's market (REB-21102). */
3895
+ /** Market context threads through so a market-gated gift rule answers for the buyer's market — as engine params, inheriting the shop's markets-toggle gate (REB-21688). */
3868
3896
  country: input.country,
3869
3897
  customerId: input.customerId,
3870
3898
  dataSourcePath,
@@ -4313,13 +4341,12 @@ var DataSourceInputSchema = import_zod28.z.object({
4313
4341
  */
4314
4342
  companyLocationId: import_zod28.z.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4315
4343
  /**
4316
- * Buyer's market country (ISO 3166-1 alpha-2) — forwarded to the engine as the `X-Country-Code`
4317
- * HEADER (REB-21102), so it's regex-bound (not just length-bound like this schema's other passthrough
4318
- * identifiers): an unfiltered value reaching a header sink can throw a raw `TypeError` (CRLF, or any
4319
- * non-Latin1 codepoint) that isn't an `UpstreamError`, escaping `fetchDataSourceResults`'s error
4320
- * handling as an unhandled 500 on this public, unauthenticated route instead of a clean 422 here.
4321
- * Normalized to uppercase (the wire value is forwarded verbatim as the header) so a case-sensitive
4322
- * engine lookup can't silently miss a lowercase code — `adsEngine.ts` uppercases for the same reason.
4344
+ * Buyer's market country (ISO 3166-1 alpha-2) — forwarded to the engine as the `country_code` PARAM
4345
+ * when the shop's markets toggle is on (REB-21688; the sink was the `X-Country-Code` header under
4346
+ * REB-21102, which is why the regex exists kept because a two-letter bound still turns garbage into
4347
+ * a clean 422 on this public, unauthenticated route instead of an engine round-trip). Normalized to
4348
+ * uppercase (the wire value is forwarded verbatim as the param) so a case-sensitive engine lookup
4349
+ * can't silently miss a lowercase code `adsEngine.ts` uppercases for the same reason.
4323
4350
  */
4324
4351
  country: import_zod28.z.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z]{2}$/, "must be an ISO 3166-1 alpha-2 country code (e.g. CA)").transform((value) => value.toUpperCase()).optional(),
4325
4352
  customerId: import_zod28.z.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
@@ -4334,8 +4361,11 @@ var DataSourceInputSchema = import_zod28.z.object({
4334
4361
  message: `Too many integration keys (max ${REQUEST_BOUNDS.INTEGRATION_KEYS_MAX})`
4335
4362
  }).optional(),
4336
4363
  /**
4337
- * Buyer's checkout language (BCP 47) — forwarded to the engine as the `Accept-Language` HEADER
4338
- * (REB-21102); regex-bound for the same header-sink reason as `country` above. MUST be a single tag
4364
+ * Buyer's checkout language (BCP 47) — normalized server-side (the whole subtag tail dropped, region
4365
+ * and script alike, then lowercased: `en-US` `en`, `zh-Hant` `zh` PEV2 feeds key on the bare
4366
+ * 2-letter language, and the market half comes from `country`) and forwarded to the engine as the
4367
+ * `locale` PARAM when the shop's markets toggle is on (REB-21688); regex-bound for the same reason as
4368
+ * `country` above. MUST be a single tag
4339
4369
  * (e.g. `fr-CA`) — a q-value list like `fr-CA,fr;q=0.9` (the raw shape of a browser `Accept-Language`
4340
4370
  * request header) fails the regex. Callers should send the buyer's resolved locale (e.g. Shopify
4341
4371
  * checkout's `localization.language.isoCode`), not the request header verbatim.
@@ -4356,16 +4386,19 @@ var GiftInputSchema = import_zod28.z.object({
4356
4386
  var GiftValidationInputSchema = import_zod28.z.object({
4357
4387
  cart: CartInputSchema,
4358
4388
  /**
4359
- * Buyer's market country — threaded into the per-widget data-source evaluations (REB-21102), which
4360
- * forward it to a header sink; regex-bound and uppercase-normalized for the same reasons as
4389
+ * Buyer's market country — threaded into the per-widget data-source evaluations, which forward it to
4390
+ * the engine as the `country_code` param when the shop's markets toggle is on (REB-21688 — gift
4391
+ * validation inherits the gate, so a market-gated gift rule answers market-blind when the merchant
4392
+ * turns markets off); regex-bound and uppercase-normalized for the same reasons as
4361
4393
  * `DataSourceInputSchema`'s `country`.
4362
4394
  */
4363
4395
  country: import_zod28.z.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z]{2}$/, "must be an ISO 3166-1 alpha-2 country code (e.g. CA)").transform((value) => value.toUpperCase()).optional(),
4364
4396
  customerId: import_zod28.z.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4365
4397
  gifts: import_zod28.z.array(GiftInputSchema).max(REQUEST_BOUNDS.GIFTS_MAX),
4366
4398
  /**
4367
- * Buyer's checkout language — threaded into the per-widget data-source evaluations (REB-21102). MUST
4368
- * be a single BCP-47 tag, for the same reason as `DataSourceInputSchema`'s `language` above.
4399
+ * Buyer's checkout language — threaded into the per-widget data-source evaluations, which normalize
4400
+ * and forward it as the engine `locale` param under the same markets-toggle gate (REB-21688). MUST be
4401
+ * a single BCP-47 tag, for the same reason as `DataSourceInputSchema`'s `language` above.
4369
4402
  */
4370
4403
  language: import_zod28.z.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z0-9-]+$/, "must be a single BCP-47 tag (e.g. fr-CA), not a q-value list").optional(),
4371
4404
  shop: import_zod28.z.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
@@ -363,6 +363,9 @@ var NotFoundError = class extends Error {
363
363
  // src/schema/cabShopConfig.ts
364
364
  import { z as z6 } from "zod";
365
365
 
366
+ // src/schema/driftedMarkets.ts
367
+ var DRIFTED_MARKETS = Object.freeze({ enabled: false });
368
+
366
369
  // src/schema/smartCart.ts
367
370
  import { z as z5 } from "zod";
368
371
  var SmartCartTierProduct = z5.looseObject({
@@ -502,6 +505,27 @@ var CabShopConfig = z6.object({
502
505
  * missing/renamed currency degrades to "unknown" (client falls back to buyer decimals), never a parse fail.
503
506
  */
504
507
  currency: z6.string().optional().catch(void 0),
508
+ /**
509
+ * The merchant's "Use Markets in Data Sources" toggle. The engine (CI2) never reads it on the serve
510
+ * path, so the proxy honouring it before sending market context is what makes the setting mean
511
+ * anything (REB-21688). Fail-CLOSED, unlike the fail-open siblings: a missing/null/drifted field reads
512
+ * as markets OFF — market context silently dropped (engine serves shop-default market), never sent
513
+ * against the merchant's setting. The OFF-reading salvages stay distinguishable: missing AND null are
514
+ * the engine's normal absent vocabulary (`.nullish()`, like `monetize`/`billingVersion`) and read as
515
+ * the plain default — not drift — while only a drifted SHAPE `.catch`es, to the {@link DRIFTED_MARKETS}
516
+ * identity sentinel, so `fetchUserConfig` can emit a `shopConfig` drift for the one case that means an
517
+ * engine rename/retype; otherwise a markets-enabled merchant degrading to shop-default pricing would
518
+ * be indistinguishable from markets genuinely off. The missing-key case is EXPLICIT via `.default()`
519
+ * (a factory, so parses never share a mutable object) rather than inferred from zod running an
520
+ * optional key's transform on `undefined`; `null` flows past the default through `.nullish()` into the
521
+ * transform. `Readonly` on the output type because the drifted salvage IS frozen — an assignment to
522
+ * `markets.enabled` downstream would throw only for drifted shops in production, so surface direct
523
+ * writes to tsc (a guard for the direct case only: TS doesn't check `readonly` in assignability, so
524
+ * laundering through a mutable-shaped alias still compiles).
525
+ * The inner `enabled` stays strict on purpose: `{ enabled: null }` is a genuine retype and drifting is
526
+ * the correct answer (the editor-path `ShopConfig.markets` would hard-fail the same payload).
527
+ */
528
+ markets: z6.object({ enabled: z6.boolean() }).nullish().catch(DRIFTED_MARKETS).default(() => ({ enabled: false })).transform((markets) => markets ?? { enabled: false }),
505
529
  monetize: z6.object({ publisherKey: z6.string().nullable() }).nullish().catch(void 0),
506
530
  shopId: z6.number().optional().catch(void 0)
507
531
  });
@@ -3532,7 +3556,14 @@ var fetchUserConfig = async (input, ctx) => {
3532
3556
  }
3533
3557
  const data = unwrapData(upstream);
3534
3558
  if (isMissingShopPayload(data)) throw new NotFoundError(`Shop config not found: ${input.shop}`);
3535
- return parseShielded(CabUserConfig, data);
3559
+ const config = parseShielded(CabUserConfig, data);
3560
+ if (config.shop.markets === DRIFTED_MARKETS) {
3561
+ ctx.reportDrift?.({
3562
+ detail: "shop config markets field drifted to disabled (fail-closed salvage) \u2014 market context withheld",
3563
+ kind: "shopConfig"
3564
+ });
3565
+ }
3566
+ return config;
3536
3567
  };
3537
3568
 
3538
3569
  // src/utilities.ts
@@ -3633,19 +3664,16 @@ var reportUnreadableVerdict = (metadata, report) => {
3633
3664
  var dedupeById = (products) => uniqBy2(products, (product) => product.id);
3634
3665
  var fetchDataSourceResults = async (input, ctx) => {
3635
3666
  const { shop } = await fetchUserConfig({ shop: input.shop }, ctx);
3636
- const marketHeaders = {
3637
- ...input.language && { "Accept-Language": input.language },
3638
- ...input.country && { "X-Country-Code": input.country }
3639
- };
3667
+ const locale = input.language?.split("-")[0]?.toLowerCase();
3668
+ const marketParams = shop.markets.enabled ? { ...input.country && { country_code: input.country }, ...locale && { locale } } : {};
3640
3669
  let upstream;
3641
3670
  try {
3642
3671
  upstream = await ctx.fetchUpstream(
3643
3672
  `/api/v1${input.dataSourcePath}`,
3644
3673
  {},
3645
3674
  {
3646
- ...Object.keys(marketHeaders).length > 0 && { headers: marketHeaders },
3647
3675
  host: ctx.host,
3648
- search: serialize(buildEngineParams(input, shop.apiKey))
3676
+ search: serialize({ ...buildEngineParams(input, shop.apiKey), ...marketParams })
3649
3677
  }
3650
3678
  );
3651
3679
  } catch (err) {
@@ -3816,7 +3844,7 @@ var validateGifts = async (input, ctx) => {
3816
3844
  const { metadata } = await fetchDataSourceResults(
3817
3845
  {
3818
3846
  cart: input.cart,
3819
- /** Market context threads through so a market-gated gift rule answers for the buyer's market (REB-21102). */
3847
+ /** Market context threads through so a market-gated gift rule answers for the buyer's market — as engine params, inheriting the shop's markets-toggle gate (REB-21688). */
3820
3848
  country: input.country,
3821
3849
  customerId: input.customerId,
3822
3850
  dataSourcePath,
@@ -4265,13 +4293,12 @@ var DataSourceInputSchema = z28.object({
4265
4293
  */
4266
4294
  companyLocationId: z28.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4267
4295
  /**
4268
- * Buyer's market country (ISO 3166-1 alpha-2) — forwarded to the engine as the `X-Country-Code`
4269
- * HEADER (REB-21102), so it's regex-bound (not just length-bound like this schema's other passthrough
4270
- * identifiers): an unfiltered value reaching a header sink can throw a raw `TypeError` (CRLF, or any
4271
- * non-Latin1 codepoint) that isn't an `UpstreamError`, escaping `fetchDataSourceResults`'s error
4272
- * handling as an unhandled 500 on this public, unauthenticated route instead of a clean 422 here.
4273
- * Normalized to uppercase (the wire value is forwarded verbatim as the header) so a case-sensitive
4274
- * engine lookup can't silently miss a lowercase code — `adsEngine.ts` uppercases for the same reason.
4296
+ * Buyer's market country (ISO 3166-1 alpha-2) — forwarded to the engine as the `country_code` PARAM
4297
+ * when the shop's markets toggle is on (REB-21688; the sink was the `X-Country-Code` header under
4298
+ * REB-21102, which is why the regex exists kept because a two-letter bound still turns garbage into
4299
+ * a clean 422 on this public, unauthenticated route instead of an engine round-trip). Normalized to
4300
+ * uppercase (the wire value is forwarded verbatim as the param) so a case-sensitive engine lookup
4301
+ * can't silently miss a lowercase code `adsEngine.ts` uppercases for the same reason.
4275
4302
  */
4276
4303
  country: z28.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z]{2}$/, "must be an ISO 3166-1 alpha-2 country code (e.g. CA)").transform((value) => value.toUpperCase()).optional(),
4277
4304
  customerId: z28.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
@@ -4286,8 +4313,11 @@ var DataSourceInputSchema = z28.object({
4286
4313
  message: `Too many integration keys (max ${REQUEST_BOUNDS.INTEGRATION_KEYS_MAX})`
4287
4314
  }).optional(),
4288
4315
  /**
4289
- * Buyer's checkout language (BCP 47) — forwarded to the engine as the `Accept-Language` HEADER
4290
- * (REB-21102); regex-bound for the same header-sink reason as `country` above. MUST be a single tag
4316
+ * Buyer's checkout language (BCP 47) — normalized server-side (the whole subtag tail dropped, region
4317
+ * and script alike, then lowercased: `en-US` `en`, `zh-Hant` `zh` PEV2 feeds key on the bare
4318
+ * 2-letter language, and the market half comes from `country`) and forwarded to the engine as the
4319
+ * `locale` PARAM when the shop's markets toggle is on (REB-21688); regex-bound for the same reason as
4320
+ * `country` above. MUST be a single tag
4291
4321
  * (e.g. `fr-CA`) — a q-value list like `fr-CA,fr;q=0.9` (the raw shape of a browser `Accept-Language`
4292
4322
  * request header) fails the regex. Callers should send the buyer's resolved locale (e.g. Shopify
4293
4323
  * checkout's `localization.language.isoCode`), not the request header verbatim.
@@ -4308,16 +4338,19 @@ var GiftInputSchema = z28.object({
4308
4338
  var GiftValidationInputSchema = z28.object({
4309
4339
  cart: CartInputSchema,
4310
4340
  /**
4311
- * Buyer's market country — threaded into the per-widget data-source evaluations (REB-21102), which
4312
- * forward it to a header sink; regex-bound and uppercase-normalized for the same reasons as
4341
+ * Buyer's market country — threaded into the per-widget data-source evaluations, which forward it to
4342
+ * the engine as the `country_code` param when the shop's markets toggle is on (REB-21688 — gift
4343
+ * validation inherits the gate, so a market-gated gift rule answers market-blind when the merchant
4344
+ * turns markets off); regex-bound and uppercase-normalized for the same reasons as
4313
4345
  * `DataSourceInputSchema`'s `country`.
4314
4346
  */
4315
4347
  country: z28.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z]{2}$/, "must be an ISO 3166-1 alpha-2 country code (e.g. CA)").transform((value) => value.toUpperCase()).optional(),
4316
4348
  customerId: z28.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4317
4349
  gifts: z28.array(GiftInputSchema).max(REQUEST_BOUNDS.GIFTS_MAX),
4318
4350
  /**
4319
- * Buyer's checkout language — threaded into the per-widget data-source evaluations (REB-21102). MUST
4320
- * be a single BCP-47 tag, for the same reason as `DataSourceInputSchema`'s `language` above.
4351
+ * Buyer's checkout language — threaded into the per-widget data-source evaluations, which normalize
4352
+ * and forward it as the engine `locale` param under the same markets-toggle gate (REB-21688). MUST be
4353
+ * a single BCP-47 tag, for the same reason as `DataSourceInputSchema`'s `language` above.
4321
4354
  */
4322
4355
  language: z28.string().max(REQUEST_BOUNDS.STRING_ID_MAX).regex(/^[A-Za-z0-9-]+$/, "must be a single BCP-47 tag (e.g. fr-CA), not a q-value list").optional(),
4323
4356
  shop: z28.string().max(REQUEST_BOUNDS.STRING_ID_MAX),
@@ -10,10 +10,13 @@ export declare const REBUY_ENGINE_HOST = "rebuyengine.com";
10
10
  * caller (rebuy-api owns the egress, timeouts, and the `X-Rebuy-Upstream` proxy header); MUST throw
11
11
  * {@link UpstreamError} on a non-2xx response so orchestrators can interpret the status.
12
12
  *
13
- * Defaults to GET with `params`/`search` as the query string; `headers` (e.g. an auth token, or the
14
- * market-context headers `fetchDataSourceResults` sends, REB-21102) apply on GET too implementations
15
- * must not gate `headers` behind a `method === 'POST'` branch. Pass `method: 'POST'` with `body` (JSON
16
- * body) for write-style calls like analytics ingest.
13
+ * Defaults to GET with `params`/`search` as the query string. `headers` apply regardless of method
14
+ * implementations must not gate them behind a `method === 'POST'` branch. Note the only current library
15
+ * caller sending `headers` is the analytics POST ingest (REB-21688 moved market context off the
16
+ * REB-21102 headers onto engine params, retiring the GET+headers case and the test that witnessed it),
17
+ * so a POST-gated implementation would pass every test in this repo today; the invariant stands so a
18
+ * future GET caller's headers can't silently drop. Pass `method: 'POST'` with `body` (JSON body) for
19
+ * write-style calls like analytics ingest.
17
20
  */
18
21
  export type UpstreamFetch = (path: string, params: Record<string, string>, options?: {
19
22
  body?: unknown;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rebuy/rebuy",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "Shared zod schemas, legacy-to-CAB widget transforms, and server orchestrators for Rebuy's Shopify consumers (rebuy-api, admin-nextjs, rebuy-shopify-extensions)",
5
5
  "license": "MIT",
6
6
  "author": "Rebuy, Inc.",