@rebuy/rebuy 3.4.0 → 3.5.1

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
  });
@@ -4662,6 +4686,7 @@ var convertOfferToV2 = ({ id, name, settings }) => {
4662
4686
  sectionType: "carousel"
4663
4687
  };
4664
4688
  return CABRootSection.parse({
4689
+ alignment: { horizontal: "center", vertical: "top" },
4665
4690
  direction: "rows",
4666
4691
  editorMode: LOCATION_TO_EDITOR_MODE[settings.location ?? "checkout"],
4667
4692
  // The widget name is required by the admin save schema; carry the legacy widget's name through.
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
  });
@@ -4400,6 +4424,7 @@ var convertOfferToV2 = ({ id, name, settings }) => {
4400
4424
  sectionType: "carousel"
4401
4425
  };
4402
4426
  return CABRootSection.parse({
4427
+ alignment: { horizontal: "center", vertical: "top" },
4403
4428
  direction: "rows",
4404
4429
  editorMode: LOCATION_TO_EDITOR_MODE[settings.location ?? "checkout"],
4405
4430
  // The widget name is required by the admin save schema; carry the legacy widget's name through.
@@ -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
  });
@@ -3212,6 +3236,7 @@ var convertOfferToV2 = ({ id, name, settings }) => {
3212
3236
  sectionType: "carousel"
3213
3237
  };
3214
3238
  return CABRootSection.parse({
3239
+ alignment: { horizontal: "center", vertical: "top" },
3215
3240
  direction: "rows",
3216
3241
  editorMode: LOCATION_TO_EDITOR_MODE[settings.location ?? "checkout"],
3217
3242
  // The widget name is required by the admin save schema; carry the legacy widget's name through.
@@ -3580,7 +3605,14 @@ var fetchUserConfig = async (input, ctx) => {
3580
3605
  }
3581
3606
  const data = unwrapData(upstream);
3582
3607
  if (isMissingShopPayload(data)) throw new NotFoundError(`Shop config not found: ${input.shop}`);
3583
- return parseShielded(CabUserConfig, data);
3608
+ const config = parseShielded(CabUserConfig, data);
3609
+ if (config.shop.markets === DRIFTED_MARKETS) {
3610
+ ctx.reportDrift?.({
3611
+ detail: "shop config markets field drifted to disabled (fail-closed salvage) \u2014 market context withheld",
3612
+ kind: "shopConfig"
3613
+ });
3614
+ }
3615
+ return config;
3584
3616
  };
3585
3617
 
3586
3618
  // src/utilities.ts
@@ -3681,19 +3713,16 @@ var reportUnreadableVerdict = (metadata, report) => {
3681
3713
  var dedupeById = (products) => (0, import_es_toolkit6.uniqBy)(products, (product) => product.id);
3682
3714
  var fetchDataSourceResults = async (input, ctx) => {
3683
3715
  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
- };
3716
+ const locale = input.language?.split("-")[0]?.toLowerCase();
3717
+ const marketParams = shop.markets.enabled ? { ...input.country && { country_code: input.country }, ...locale && { locale } } : {};
3688
3718
  let upstream;
3689
3719
  try {
3690
3720
  upstream = await ctx.fetchUpstream(
3691
3721
  `/api/v1${input.dataSourcePath}`,
3692
3722
  {},
3693
3723
  {
3694
- ...Object.keys(marketHeaders).length > 0 && { headers: marketHeaders },
3695
3724
  host: ctx.host,
3696
- search: serialize(buildEngineParams(input, shop.apiKey))
3725
+ search: serialize({ ...buildEngineParams(input, shop.apiKey), ...marketParams })
3697
3726
  }
3698
3727
  );
3699
3728
  } catch (err) {
@@ -3864,7 +3893,7 @@ var validateGifts = async (input, ctx) => {
3864
3893
  const { metadata } = await fetchDataSourceResults(
3865
3894
  {
3866
3895
  cart: input.cart,
3867
- /** Market context threads through so a market-gated gift rule answers for the buyer's market (REB-21102). */
3896
+ /** 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
3897
  country: input.country,
3869
3898
  customerId: input.customerId,
3870
3899
  dataSourcePath,
@@ -4313,13 +4342,12 @@ var DataSourceInputSchema = import_zod28.z.object({
4313
4342
  */
4314
4343
  companyLocationId: import_zod28.z.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4315
4344
  /**
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.
4345
+ * Buyer's market country (ISO 3166-1 alpha-2) — forwarded to the engine as the `country_code` PARAM
4346
+ * when the shop's markets toggle is on (REB-21688; the sink was the `X-Country-Code` header under
4347
+ * REB-21102, which is why the regex exists kept because a two-letter bound still turns garbage into
4348
+ * a clean 422 on this public, unauthenticated route instead of an engine round-trip). Normalized to
4349
+ * uppercase (the wire value is forwarded verbatim as the param) so a case-sensitive engine lookup
4350
+ * can't silently miss a lowercase code `adsEngine.ts` uppercases for the same reason.
4323
4351
  */
4324
4352
  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
4353
  customerId: import_zod28.z.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
@@ -4334,8 +4362,11 @@ var DataSourceInputSchema = import_zod28.z.object({
4334
4362
  message: `Too many integration keys (max ${REQUEST_BOUNDS.INTEGRATION_KEYS_MAX})`
4335
4363
  }).optional(),
4336
4364
  /**
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
4365
+ * Buyer's checkout language (BCP 47) — normalized server-side (the whole subtag tail dropped, region
4366
+ * and script alike, then lowercased: `en-US` `en`, `zh-Hant` `zh` PEV2 feeds key on the bare
4367
+ * 2-letter language, and the market half comes from `country`) and forwarded to the engine as the
4368
+ * `locale` PARAM when the shop's markets toggle is on (REB-21688); regex-bound for the same reason as
4369
+ * `country` above. MUST be a single tag
4339
4370
  * (e.g. `fr-CA`) — a q-value list like `fr-CA,fr;q=0.9` (the raw shape of a browser `Accept-Language`
4340
4371
  * request header) fails the regex. Callers should send the buyer's resolved locale (e.g. Shopify
4341
4372
  * checkout's `localization.language.isoCode`), not the request header verbatim.
@@ -4356,16 +4387,19 @@ var GiftInputSchema = import_zod28.z.object({
4356
4387
  var GiftValidationInputSchema = import_zod28.z.object({
4357
4388
  cart: CartInputSchema,
4358
4389
  /**
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
4390
+ * Buyer's market country — threaded into the per-widget data-source evaluations, which forward it to
4391
+ * the engine as the `country_code` param when the shop's markets toggle is on (REB-21688 — gift
4392
+ * validation inherits the gate, so a market-gated gift rule answers market-blind when the merchant
4393
+ * turns markets off); regex-bound and uppercase-normalized for the same reasons as
4361
4394
  * `DataSourceInputSchema`'s `country`.
4362
4395
  */
4363
4396
  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
4397
  customerId: import_zod28.z.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4365
4398
  gifts: import_zod28.z.array(GiftInputSchema).max(REQUEST_BOUNDS.GIFTS_MAX),
4366
4399
  /**
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.
4400
+ * Buyer's checkout language — threaded into the per-widget data-source evaluations, which normalize
4401
+ * and forward it as the engine `locale` param under the same markets-toggle gate (REB-21688). MUST be
4402
+ * a single BCP-47 tag, for the same reason as `DataSourceInputSchema`'s `language` above.
4369
4403
  */
4370
4404
  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
4405
  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
  });
@@ -3164,6 +3188,7 @@ var convertOfferToV2 = ({ id, name, settings }) => {
3164
3188
  sectionType: "carousel"
3165
3189
  };
3166
3190
  return CABRootSection.parse({
3191
+ alignment: { horizontal: "center", vertical: "top" },
3167
3192
  direction: "rows",
3168
3193
  editorMode: LOCATION_TO_EDITOR_MODE[settings.location ?? "checkout"],
3169
3194
  // The widget name is required by the admin save schema; carry the legacy widget's name through.
@@ -3532,7 +3557,14 @@ var fetchUserConfig = async (input, ctx) => {
3532
3557
  }
3533
3558
  const data = unwrapData(upstream);
3534
3559
  if (isMissingShopPayload(data)) throw new NotFoundError(`Shop config not found: ${input.shop}`);
3535
- return parseShielded(CabUserConfig, data);
3560
+ const config = parseShielded(CabUserConfig, data);
3561
+ if (config.shop.markets === DRIFTED_MARKETS) {
3562
+ ctx.reportDrift?.({
3563
+ detail: "shop config markets field drifted to disabled (fail-closed salvage) \u2014 market context withheld",
3564
+ kind: "shopConfig"
3565
+ });
3566
+ }
3567
+ return config;
3536
3568
  };
3537
3569
 
3538
3570
  // src/utilities.ts
@@ -3633,19 +3665,16 @@ var reportUnreadableVerdict = (metadata, report) => {
3633
3665
  var dedupeById = (products) => uniqBy2(products, (product) => product.id);
3634
3666
  var fetchDataSourceResults = async (input, ctx) => {
3635
3667
  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
- };
3668
+ const locale = input.language?.split("-")[0]?.toLowerCase();
3669
+ const marketParams = shop.markets.enabled ? { ...input.country && { country_code: input.country }, ...locale && { locale } } : {};
3640
3670
  let upstream;
3641
3671
  try {
3642
3672
  upstream = await ctx.fetchUpstream(
3643
3673
  `/api/v1${input.dataSourcePath}`,
3644
3674
  {},
3645
3675
  {
3646
- ...Object.keys(marketHeaders).length > 0 && { headers: marketHeaders },
3647
3676
  host: ctx.host,
3648
- search: serialize(buildEngineParams(input, shop.apiKey))
3677
+ search: serialize({ ...buildEngineParams(input, shop.apiKey), ...marketParams })
3649
3678
  }
3650
3679
  );
3651
3680
  } catch (err) {
@@ -3816,7 +3845,7 @@ var validateGifts = async (input, ctx) => {
3816
3845
  const { metadata } = await fetchDataSourceResults(
3817
3846
  {
3818
3847
  cart: input.cart,
3819
- /** Market context threads through so a market-gated gift rule answers for the buyer's market (REB-21102). */
3848
+ /** 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
3849
  country: input.country,
3821
3850
  customerId: input.customerId,
3822
3851
  dataSourcePath,
@@ -4265,13 +4294,12 @@ var DataSourceInputSchema = z28.object({
4265
4294
  */
4266
4295
  companyLocationId: z28.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4267
4296
  /**
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.
4297
+ * Buyer's market country (ISO 3166-1 alpha-2) — forwarded to the engine as the `country_code` PARAM
4298
+ * when the shop's markets toggle is on (REB-21688; the sink was the `X-Country-Code` header under
4299
+ * REB-21102, which is why the regex exists kept because a two-letter bound still turns garbage into
4300
+ * a clean 422 on this public, unauthenticated route instead of an engine round-trip). Normalized to
4301
+ * uppercase (the wire value is forwarded verbatim as the param) so a case-sensitive engine lookup
4302
+ * can't silently miss a lowercase code `adsEngine.ts` uppercases for the same reason.
4275
4303
  */
4276
4304
  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
4305
  customerId: z28.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
@@ -4286,8 +4314,11 @@ var DataSourceInputSchema = z28.object({
4286
4314
  message: `Too many integration keys (max ${REQUEST_BOUNDS.INTEGRATION_KEYS_MAX})`
4287
4315
  }).optional(),
4288
4316
  /**
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
4317
+ * Buyer's checkout language (BCP 47) — normalized server-side (the whole subtag tail dropped, region
4318
+ * and script alike, then lowercased: `en-US` `en`, `zh-Hant` `zh` PEV2 feeds key on the bare
4319
+ * 2-letter language, and the market half comes from `country`) and forwarded to the engine as the
4320
+ * `locale` PARAM when the shop's markets toggle is on (REB-21688); regex-bound for the same reason as
4321
+ * `country` above. MUST be a single tag
4291
4322
  * (e.g. `fr-CA`) — a q-value list like `fr-CA,fr;q=0.9` (the raw shape of a browser `Accept-Language`
4292
4323
  * request header) fails the regex. Callers should send the buyer's resolved locale (e.g. Shopify
4293
4324
  * checkout's `localization.language.isoCode`), not the request header verbatim.
@@ -4308,16 +4339,19 @@ var GiftInputSchema = z28.object({
4308
4339
  var GiftValidationInputSchema = z28.object({
4309
4340
  cart: CartInputSchema,
4310
4341
  /**
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
4342
+ * Buyer's market country — threaded into the per-widget data-source evaluations, which forward it to
4343
+ * the engine as the `country_code` param when the shop's markets toggle is on (REB-21688 — gift
4344
+ * validation inherits the gate, so a market-gated gift rule answers market-blind when the merchant
4345
+ * turns markets off); regex-bound and uppercase-normalized for the same reasons as
4313
4346
  * `DataSourceInputSchema`'s `country`.
4314
4347
  */
4315
4348
  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
4349
  customerId: z28.string().max(REQUEST_BOUNDS.STRING_ID_MAX).optional(),
4317
4350
  gifts: z28.array(GiftInputSchema).max(REQUEST_BOUNDS.GIFTS_MAX),
4318
4351
  /**
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.
4352
+ * Buyer's checkout language — threaded into the per-widget data-source evaluations, which normalize
4353
+ * and forward it as the engine `locale` param under the same markets-toggle gate (REB-21688). MUST be
4354
+ * a single BCP-47 tag, for the same reason as `DataSourceInputSchema`'s `language` above.
4321
4355
  */
4322
4356
  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
4357
  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;
@@ -3140,6 +3140,7 @@ var convertOfferToV2 = ({ id, name, settings }) => {
3140
3140
  sectionType: "carousel"
3141
3141
  };
3142
3142
  return CABRootSection.parse({
3143
+ alignment: { horizontal: "center", vertical: "top" },
3143
3144
  direction: "rows",
3144
3145
  editorMode: LOCATION_TO_EDITOR_MODE[settings.location ?? "checkout"],
3145
3146
  // The widget name is required by the admin save schema; carry the legacy widget's name through.
@@ -3083,6 +3083,7 @@ var convertOfferToV2 = ({ id, name, settings }) => {
3083
3083
  sectionType: "carousel"
3084
3084
  };
3085
3085
  return CABRootSection.parse({
3086
+ alignment: { horizontal: "center", vertical: "top" },
3086
3087
  direction: "rows",
3087
3088
  editorMode: LOCATION_TO_EDITOR_MODE[settings.location ?? "checkout"],
3088
3089
  // The widget name is required by the admin save schema; carry the legacy widget's name through.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rebuy/rebuy",
3
- "version": "3.4.0",
3
+ "version": "3.5.1",
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.",