@faststore/api 4.9.2 → 4.9.3-dev.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.
@@ -1,3 +1,5 @@
1
+ /** OpenTelemetry log emitter; a no-op while telemetry is disabled. */
2
+ export declare const OTELLogger: (severity: import('@faststore/diagnostics').LogSeverity, ...args: unknown[]) => void;
1
3
  export declare const ResolverTrace: <TContext extends {
2
4
  OTEL_ENABLED: boolean;
3
5
  account: string;
@@ -1 +1 @@
1
- {"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../../../src/observability/telemetry.ts"],"names":[],"mappings":"AAKA,eAAO,MAAM,aAAa,GACxB,QAAQ,SAAS;IACf,YAAY,EAAE,OAAO,CAAA;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CACrC,EACD,OAAO,GAAG,GAAG,EACb,KAAK,GAAG,GAAG,EACX,OAAO,GAAG,GAAG,EAEb,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,KAAK,OAAO,EAC3E,qBAAyC,MAGvC,QAAQ,OAAO,EACf,MAAM,KAAK,EACX,gBAAgB,QAAQ,EACxB,MAAM,GAAG,KACR,OA2CJ,CAAA"}
1
+ {"version":3,"file":"telemetry.d.ts","sourceRoot":"","sources":["../../../src/observability/telemetry.ts"],"names":[],"mappings":"AAGA,sEAAsE;AACtE,eAAO,MAAM,UAAU,sFAA2B,CAAA;AAElD,eAAO,MAAM,aAAa,GACxB,QAAQ,SAAS;IACf,YAAY,EAAE,OAAO,CAAA;IACrB,OAAO,EAAE,MAAM,CAAA;IACf,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CACrC,EACD,OAAO,GAAG,GAAG,EACb,KAAK,GAAG,GAAG,EACX,OAAO,GAAG,GAAG,EAEb,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,KAAK,OAAO,EAC3E,qBAAyC,MAGvC,QAAQ,OAAO,EACf,MAAM,KAAK,EACX,gBAAgB,QAAQ,EACxB,MAAM,GAAG,KACR,OA2CJ,CAAA"}
@@ -330,7 +330,7 @@ export declare function getResolvers(): {
330
330
  status: any;
331
331
  }[];
332
332
  } | null>;
333
- validateSession: (_: any, { session: oldSession, search }: import('../..').MutationValidateSessionArgs, { clients, headers, account }: GraphqlContext) => Promise<import('../..').StoreSession | null>;
333
+ validateSession: (_: any, { session: oldSession, search }: import('../..').MutationValidateSessionArgs, { clients, headers, account, storage, discoveryConfig }: GraphqlContext) => Promise<import('../..').StoreSession | null>;
334
334
  subscribeToNewsletter: (_: any, { data }: import('../..').MutationSubscribeToNewsletterArgs, { clients: { commerce } }: GraphqlContext) => Promise<import('../..').PersonNewsletter | null>;
335
335
  cancelOrder: (_: any, { data }: import('../..').MutationCancelOrderArgs, { clients: { commerce } }: GraphqlContext) => Promise<import('../..').UserOrderCancel | null>;
336
336
  processOrderAuthorization: (_: any, { data }: {
@@ -289,7 +289,7 @@ export declare const getResolvers: () => {
289
289
  status: any;
290
290
  }[];
291
291
  } | null>;
292
- validateSession: (_: any, { session: oldSession, search }: import('../../..').MutationValidateSessionArgs, { clients, headers, account }: import('..').GraphqlContext) => Promise<import('../../..').StoreSession | null>;
292
+ validateSession: (_: any, { session: oldSession, search }: import('../../..').MutationValidateSessionArgs, { clients, headers, account, storage, discoveryConfig }: import('..').GraphqlContext) => Promise<import('../../..').StoreSession | null>;
293
293
  subscribeToNewsletter: (_: any, { data }: import('../../..').MutationSubscribeToNewsletterArgs, { clients: { commerce } }: import('..').GraphqlContext) => Promise<import('../../..').PersonNewsletter | null>;
294
294
  cancelOrder: (_: any, { data }: import('../../..').MutationCancelOrderArgs, { clients: { commerce } }: import('..').GraphqlContext) => Promise<import('../../..').UserOrderCancel | null>;
295
295
  processOrderAuthorization: (_: any, { data }: {
@@ -57,7 +57,7 @@ export declare const Mutation: {
57
57
  status: any;
58
58
  }[];
59
59
  } | null>;
60
- validateSession: (_: any, { session: oldSession, search }: import('../../..').MutationValidateSessionArgs, { clients, headers, account }: import('..').GraphqlContext) => Promise<import('../../..').StoreSession | null>;
60
+ validateSession: (_: any, { session: oldSession, search }: import('../../..').MutationValidateSessionArgs, { clients, headers, account, storage, discoveryConfig }: import('..').GraphqlContext) => Promise<import('../../..').StoreSession | null>;
61
61
  subscribeToNewsletter: (_: any, { data }: import('../../..').MutationSubscribeToNewsletterArgs, { clients: { commerce } }: import('..').GraphqlContext) => Promise<import('../../..').PersonNewsletter | null>;
62
62
  cancelOrder: (_: any, { data }: import('../../..').MutationCancelOrderArgs, { clients: { commerce } }: import('..').GraphqlContext) => Promise<import('../../..').UserOrderCancel | null>;
63
63
  processOrderAuthorization: (_: any, { data }: {
@@ -1 +1 @@
1
- {"version":3,"file":"validateCart.d.ts","sourceRoot":"","sources":["../../../../../src/platforms/vtex/resolvers/validateCart.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,IAAI,CAAA;AACxC,OAAO,KAAK,EAMV,wBAAwB,EACzB,MAAM,+BAA+B,CAAA;AAyYtC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,YAAY,GACvB,GAAG,OAAO,EACV,8BAA8B,wBAAwB,EACtD,KAAK,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;SA6OpB,CAAA"}
1
+ {"version":3,"file":"validateCart.d.ts","sourceRoot":"","sources":["../../../../../src/platforms/vtex/resolvers/validateCart.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,IAAI,CAAA;AACxC,OAAO,KAAK,EAMV,wBAAwB,EACzB,MAAM,+BAA+B,CAAA;AAybtC;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,YAAY,GACvB,GAAG,OAAO,EACV,8BAA8B,wBAAwB,EACtD,KAAK,cAAc;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;SAqPpB,CAAA"}
@@ -1,4 +1,4 @@
1
1
  import { GraphqlContext } from '..';
2
2
  import { MutationValidateSessionArgs, StoreSession } from '../../../__generated__/schema';
3
- export declare const validateSession: (_: any, { session: oldSession, search }: MutationValidateSessionArgs, { clients, headers, account }: GraphqlContext) => Promise<StoreSession | null>;
3
+ export declare const validateSession: (_: any, { session: oldSession, search }: MutationValidateSessionArgs, { clients, headers, account, storage, discoveryConfig }: GraphqlContext) => Promise<StoreSession | null>;
4
4
  //# sourceMappingURL=validateSession.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"validateSession.d.ts","sourceRoot":"","sources":["../../../../../src/platforms/vtex/resolvers/validateSession.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,IAAI,CAAA;AACxC,OAAO,KAAK,EACV,2BAA2B,EAC3B,YAAY,EACb,MAAM,+BAA+B,CAAA;AAwBtC,eAAO,MAAM,eAAe,GAC1B,GAAG,GAAG,EACN,iCAAiC,2BAA2B,EAC5D,+BAA+B,cAAc,KAC5C,OAAO,CAAC,YAAY,GAAG,IAAI,CAoH7B,CAAA"}
1
+ {"version":3,"file":"validateSession.d.ts","sourceRoot":"","sources":["../../../../../src/platforms/vtex/resolvers/validateSession.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,IAAI,CAAA;AACxC,OAAO,KAAK,EACV,2BAA2B,EAC3B,YAAY,EACb,MAAM,+BAA+B,CAAA;AAsItC,eAAO,MAAM,eAAe,GAC1B,GAAG,GAAG,EACN,iCAAiC,2BAA2B,EAC5D,yDAAyD,cAAc,KACtE,OAAO,CAAC,YAAY,GAAG,IAAI,CAgJ7B,CAAA"}
@@ -1,3 +1,4 @@
1
+ import { OrderForm } from '../clients/commerce/types/OrderForm';
1
2
  import { Channel } from './channel';
2
3
  /**
3
4
  * When session SC and orderForm SC diverge, keep Checkout on the orderForm
@@ -8,4 +9,11 @@ import { Channel } from './channel';
8
9
  * items that exist only on the orderForm's sales channel.
9
10
  */
10
11
  export declare function channelWhenSessionDivergesFromOrderForm(currentChannel: Required<Channel>, orderFormSalesChannel: string | null | undefined): string | null;
12
+ /**
13
+ * Checkout stores an SC on the orderForm only after an items mutation sent
14
+ * with `sc`. An SC-less request on an orderForm that never had items falls
15
+ * back to the platform default (SC 1), so its SC is meaningful only when the
16
+ * orderForm has items or already matches the session.
17
+ */
18
+ export declare function shouldTrustOrderFormSalesChannel(orderForm: Pick<OrderForm, 'items' | 'salesChannel'>, sessionSalesChannel: string | number, rejectedSalesChannel?: string): boolean;
11
19
  //# sourceMappingURL=cartSalesChannel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"cartSalesChannel.d.ts","sourceRoot":"","sources":["../../../../../src/platforms/vtex/utils/cartSalesChannel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAGxC;;;;;;;GAOG;AACH,wBAAgB,uCAAuC,CACrD,cAAc,EAAE,QAAQ,CAAC,OAAO,CAAC,EACjC,qBAAqB,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC/C,MAAM,GAAG,IAAI,CAgBf"}
1
+ {"version":3,"file":"cartSalesChannel.d.ts","sourceRoot":"","sources":["../../../../../src/platforms/vtex/utils/cartSalesChannel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qCAAqC,CAAA;AACpE,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAGxC;;;;;;;GAOG;AACH,wBAAgB,uCAAuC,CACrD,cAAc,EAAE,QAAQ,CAAC,OAAO,CAAC,EACjC,qBAAqB,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAC/C,MAAM,GAAG,IAAI,CAgBf;AAED;;;;;GAKG;AACH,wBAAgB,gCAAgC,CAC9C,SAAS,EAAE,IAAI,CAAC,SAAS,EAAE,OAAO,GAAG,cAAc,CAAC,EAEpD,mBAAmB,EAAE,MAAM,GAAG,MAAM,EAEpC,oBAAoB,CAAC,EAAE,MAAM,GAC5B,OAAO,CAkBT"}
@@ -1,10 +1,45 @@
1
1
  import { Channel } from './channel';
2
+ /**
3
+ * Why the session SC must not be replaced by Session Manager:
4
+ * - `orderForm`: adopted from an orderForm with items (e.g. after Quick Order);
5
+ * kept until the cart is empty.
6
+ * - `url`: derived from the URL by localization; always sent to Checkout.
7
+ *
8
+ * Lives only in the raw `channel` JSON string so the public `Channel` type
9
+ * stays unchanged.
10
+ */
11
+ export type SalesChannelSource = 'orderForm' | 'url';
12
+ /**
13
+ * Reads the marker from the raw channel string. A `url` marker only means
14
+ * something while localization is enabled; a leftover one (e.g. after a store
15
+ * turns localization off) is ignored so Session Manager decides again.
16
+ */
17
+ export declare function salesChannelSourceOf(channel: string | null | undefined, { localizationEnabled }?: {
18
+ localizationEnabled?: boolean;
19
+ }): SalesChannelSource | undefined;
20
+ /** Whether the store config enables localization (URL-derived SC). */
21
+ export declare const isLocalizationEnabled: (discoveryConfig: Record<string, unknown> | undefined) => boolean;
22
+ /**
23
+ * SC that Session Manager rejected (401/403) for this shopper after it was
24
+ * adopted from the orderForm. validateCart must not adopt it again, otherwise
25
+ * adoption and rejection would loop.
26
+ */
27
+ export declare function rejectedSalesChannelOf(channel: string | null | undefined): string | undefined;
2
28
  /**
3
29
  * Builds the session `channel` string after Session Manager responds.
4
30
  *
5
- * When the client already pinned an explicit SC (e.g. adopted from the
6
- * orderForm after Quick Order), keep it. Session Manager often still reports
7
- * the default SC and would otherwise overwrite the adoption.
31
+ * Session Manager is the SC authority, except when the client channel carries
32
+ * a `salesChannelSource` marker: then the client SC is kept and the marker is
33
+ * re-emitted so it survives this validation. A recorded rejection is also
34
+ * re-emitted until Session Manager resolves that SC for the shopper.
35
+ */
36
+ export declare function channelAfterSessionManager(channel: Required<Channel>, storeChannelValue: string | null | undefined, regionId: string | null | undefined, sellerId: string | undefined, salesChannelSource?: SalesChannelSource, rejectedSalesChannel?: string): string;
37
+ /**
38
+ * SC to request from Session Manager. FastStore 4.6–4.9 could move a session
39
+ * to the SC 1 fallback of an empty orderForm and pin it there. When an
40
+ * unmarked session is on SC 1 but the store runs on another SC, ask for the
41
+ * store SC instead: if Session Manager assigns SC 1 on purpose (e.g. by
42
+ * profile), it still answers SC 1.
8
43
  */
9
- export declare function channelAfterSessionManager(channel: Required<Channel>, storeChannelValue: string | null | undefined, regionId: string | null | undefined, sellerId: string | undefined): string;
44
+ export declare function salesChannelToRequest(channel: Required<Channel>, salesChannelSource: SalesChannelSource | undefined, storeSalesChannel: string | undefined, search: string): string;
10
45
  //# sourceMappingURL=sessionChannel.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sessionChannel.d.ts","sourceRoot":"","sources":["../../../../../src/platforms/vtex/utils/sessionChannel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAGxC;;;;;;GAMG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,EAC1B,iBAAiB,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC5C,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,CAgBR"}
1
+ {"version":3,"file":"sessionChannel.d.ts","sourceRoot":"","sources":["../../../../../src/platforms/vtex/utils/sessionChannel.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAGxC;;;;;;;;GAQG;AACH,MAAM,MAAM,kBAAkB,GAAG,WAAW,GAAG,KAAK,CAAA;AAUpD;;;;GAIG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAClC,EAAE,mBAA0B,EAAE,GAAE;IAAE,mBAAmB,CAAC,EAAE,OAAO,CAAA;CAAO,GACrE,kBAAkB,GAAG,SAAS,CAQhC;AAED,sEAAsE;AACtE,eAAO,MAAM,qBAAqB,GAChC,iBAAiB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,YAKnD,CAAA;AAEH;;;;GAIG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GACjC,MAAM,GAAG,SAAS,CAMpB;AAED;;;;;;;GAOG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,EAC1B,iBAAiB,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAC5C,QAAQ,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EACnC,QAAQ,EAAE,MAAM,GAAG,SAAS,EAC5B,kBAAkB,CAAC,EAAE,kBAAkB,EACvC,oBAAoB,CAAC,EAAE,MAAM,GAC5B,MAAM,CAkBR;AAKD;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,QAAQ,CAAC,OAAO,CAAC,EAC1B,kBAAkB,EAAE,kBAAkB,GAAG,SAAS,EAClD,iBAAiB,EAAE,MAAM,GAAG,SAAS,EACrC,MAAM,EAAE,MAAM,GACb,MAAM,CAUR"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faststore/api",
3
- "version": "4.9.2",
3
+ "version": "4.9.3-dev.1",
4
4
  "license": "MIT",
5
5
  "main": "dist/cjs/index.js",
6
6
  "typings": "dist/src/index.d.ts",
@@ -58,11 +58,11 @@
58
58
  "vite": "7.1.5",
59
59
  "vite-plugin-dts": "4.5.4",
60
60
  "vitest": "4.0.18",
61
- "@faststore/diagnostics": "^4.9.2"
61
+ "@faststore/diagnostics": "^4.9.3-dev.1"
62
62
  },
63
63
  "peerDependencies": {
64
64
  "graphql": "16.11.0",
65
- "@faststore/diagnostics": "4.9.2"
65
+ "@faststore/diagnostics": "4.9.3-dev.1"
66
66
  },
67
67
  "publishConfig": {
68
68
  "access": "public"
@@ -1,7 +1,8 @@
1
1
  import { logger, OTELAPI } from '@faststore/diagnostics'
2
2
  import { name, version } from '../../package.json' with { type: 'json' }
3
3
 
4
- const OTELLogger = logger('@faststore/api')
4
+ /** OpenTelemetry log emitter; a no-op while telemetry is disabled. */
5
+ export const OTELLogger = logger('@faststore/api')
5
6
 
6
7
  export const ResolverTrace = <
7
8
  TContext extends {
@@ -1,7 +1,15 @@
1
1
  import deepEquals from 'fast-deep-equal'
2
2
 
3
3
  import { parse } from 'cookie'
4
- import { channelWhenSessionDivergesFromOrderForm } from '../utils/cartSalesChannel'
4
+ import {
5
+ channelWhenSessionDivergesFromOrderForm,
6
+ shouldTrustOrderFormSalesChannel,
7
+ } from '../utils/cartSalesChannel'
8
+ import {
9
+ isLocalizationEnabled,
10
+ rejectedSalesChannelOf,
11
+ salesChannelSourceOf,
12
+ } from '../utils/sessionChannel'
5
13
  import { mutateChannelContext, mutateLocaleContext } from '../utils/contex'
6
14
  import { md5 } from '../utils/md5'
7
15
  import {
@@ -381,11 +389,59 @@ const getCookieCheckoutOrderNumber = (ctx: string, nameCookie: string) => {
381
389
  return cookieValue ? cookieValue.split('=')[1] : ''
382
390
  }
383
391
 
384
- /** Keep Checkout on the orderForm SC when the browser session lags behind it. */
392
+ /**
393
+ * Fetches the orderForm, omitting `sc` for existing carts so Checkout keeps the
394
+ * SC stored on the cart (unless the session SC comes from the URL).
395
+ *
396
+ * Checkout only stores an SC after an items mutation with `sc`. An empty
397
+ * orderForm fetched without `sc` may report the platform default (SC 1):
398
+ * refetch it with the session SC instead of trusting (and adopting) it. Same
399
+ * for an orderForm on an SC Session Manager rejected for this shopper.
400
+ */
401
+ const getOrderForm = async (
402
+ ctx: GraphqlContext,
403
+ orderFormId: string | undefined,
404
+ isUrlSalesChannel: boolean,
405
+ rejectedSalesChannel: string | undefined
406
+ ) => {
407
+ const { commerce } = ctx.clients
408
+ const orderForm = await commerce.checkout.orderForm({
409
+ id: orderFormId,
410
+ channel: ctx.storage.channel,
411
+ preserveSalesChannel: Boolean(orderFormId) && !isUrlSalesChannel,
412
+ })
413
+
414
+ if (
415
+ !orderFormId ||
416
+ shouldTrustOrderFormSalesChannel(
417
+ orderForm,
418
+ ctx.storage.channel.salesChannel,
419
+ rejectedSalesChannel
420
+ )
421
+ ) {
422
+ return orderForm
423
+ }
424
+
425
+ return commerce.checkout.orderForm({
426
+ id: orderFormId,
427
+ channel: ctx.storage.channel,
428
+ })
429
+ }
430
+
431
+ /**
432
+ * Keep Checkout on the orderForm SC when the browser session lags behind it.
433
+ * Only orderForms with items have a stored SC worth protecting, and a
434
+ * URL-derived (localization) session SC always wins.
435
+ */
385
436
  const adoptOrderFormSalesChannelWhenSessionDiverges = (
386
437
  ctx: GraphqlContext,
387
- orderForm: OrderForm
438
+ orderForm: OrderForm,
439
+ canAdopt: boolean
388
440
  ): string | null => {
441
+ if (!canAdopt || orderForm.items.length === 0) {
442
+ return null
443
+ }
444
+
389
445
  const adoptedChannel = channelWhenSessionDivergesFromOrderForm(
390
446
  ctx.storage.channel,
391
447
  orderForm.salesChannel
@@ -442,6 +498,12 @@ export const validateCart = async (
442
498
 
443
499
  const channel = session?.channel
444
500
  const locale = session?.locale
501
+ // Localization derives the SC from the URL: never omit `sc` nor adopt.
502
+ const isUrlSalesChannel =
503
+ salesChannelSourceOf(channel, {
504
+ localizationEnabled: isLocalizationEnabled(ctx.discoveryConfig),
505
+ }) === 'url'
506
+ const rejectedSalesChannel = rejectedSalesChannelOf(channel)
445
507
 
446
508
  if (channel) {
447
509
  mutateChannelContext(ctx, channel)
@@ -457,12 +519,12 @@ export const validateCart = async (
457
519
  // session SC (e.g. after Quick Order) would recalculate the cart and drop
458
520
  // items only available in the orderForm's trade policy. New carts still
459
521
  // send `sc` from the session (see commerce.checkout.orderForm).
460
- const orderFormId = orderFormIdFromCookie || undefined
461
- const orderForm = await commerce.checkout.orderForm({
462
- id: orderFormId,
463
- channel: ctx.storage.channel,
464
- preserveSalesChannel: Boolean(orderFormId),
465
- })
522
+ const orderForm = await getOrderForm(
523
+ ctx,
524
+ orderFormIdFromCookie || undefined,
525
+ isUrlSalesChannel,
526
+ rejectedSalesChannel
527
+ )
466
528
  const orderNumber = orderForm.orderFormId
467
529
 
468
530
  // Clear messages so it doesn't keep populating toasts on a loop
@@ -486,7 +548,8 @@ export const validateCart = async (
486
548
  // on the trade policy that actually owns the items.
487
549
  const adoptedSalesChannel = adoptOrderFormSalesChannelWhenSessionDiverges(
488
550
  ctx,
489
- orderForm
551
+ orderForm,
552
+ !isUrlSalesChannel
490
553
  )
491
554
 
492
555
  const newOrderForm = await setOrderFormEtag(
@@ -509,7 +572,8 @@ export const validateCart = async (
509
572
  // items that exist only on the orderForm's sales channel.
510
573
  const adoptedSalesChannel = adoptOrderFormSalesChannelWhenSessionDiverges(
511
574
  ctx,
512
- orderForm
575
+ orderForm,
576
+ !isUrlSalesChannel
513
577
  )
514
578
 
515
579
  // Step2: Process items from both browser and checkout so they have the same shape
@@ -6,7 +6,16 @@ import type {
6
6
  StoreSession,
7
7
  } from '../../../__generated__/schema'
8
8
  import ChannelMarshal from '../utils/channel'
9
- import { channelAfterSessionManager } from '../utils/sessionChannel'
9
+ import { OTELLogger } from '../../../observability/telemetry'
10
+ import { FastStoreError } from '../../errors'
11
+ import {
12
+ channelAfterSessionManager,
13
+ isLocalizationEnabled,
14
+ rejectedSalesChannelOf,
15
+ type SalesChannelSource,
16
+ salesChannelSourceOf,
17
+ salesChannelToRequest,
18
+ } from '../utils/sessionChannel'
10
19
  import {
11
20
  buildB2bSession,
12
21
  buildMarketingData,
@@ -28,12 +37,127 @@ type SessionCheckoutNamespace = {
28
37
  regionId?: { value?: string | null } | null
29
38
  }
30
39
 
40
+ const isSalesChannelRejected = (error: unknown) =>
41
+ error instanceof FastStoreError &&
42
+ (error.extensions.status === 401 || error.extensions.status === 403)
43
+
44
+ /**
45
+ * Whether the rejection is about the requested SC (e.g. "You must be logged
46
+ * in to access the requested SalesChannel"), not an unrelated auth failure.
47
+ * Only then is an adopted SC recorded as rejected.
48
+ */
49
+ const isAboutSalesChannel = (error: unknown) =>
50
+ error instanceof Error && /sales\s*channel/i.test(error.message)
51
+
52
+ /**
53
+ * Whether a Session Manager rejection drops the `orderForm` adoption: only
54
+ * when the rejected `sc` is the adopted SC itself and the error is about the
55
+ * sales channel. A rejected `?sc=` from the page URL or an unrelated 401/403
56
+ * keeps the adoption.
57
+ */
58
+ const rejectsAdoption = (
59
+ error: unknown,
60
+ salesChannelSource: SalesChannelSource | undefined,
61
+ requestedSalesChannel: string | undefined,
62
+ clientSalesChannel: string
63
+ ) => {
64
+ if (
65
+ salesChannelSource !== 'orderForm' ||
66
+ requestedSalesChannel !== clientSalesChannel
67
+ ) {
68
+ return false
69
+ }
70
+
71
+ if (isAboutSalesChannel(error)) {
72
+ return true
73
+ }
74
+
75
+ // Session Manager has no structured code for this; make a wording change
76
+ // observable (stdout and OpenTelemetry logs) instead of silently keeping
77
+ // the adoption.
78
+ const message = `[validateSession] Session Manager rejected adopted sales channel ${requestedSalesChannel} with an unrecognized error; keeping the adoption.`
79
+ const detail = error instanceof Error ? error.message : error
80
+
81
+ console.warn(message, detail)
82
+ OTELLogger('warn', '%s %o', message, detail)
83
+
84
+ return false
85
+ }
86
+
87
+ /**
88
+ * Calls Session Manager. When it rejects the requested `sc` (401/403), retries
89
+ * once without it so Session Manager resolves an SC the shopper can use:
90
+ * - no marker: a stale SC (e.g. left by an older version) would stick forever;
91
+ * - `orderForm` marker: if the adopted SC itself is not available to this
92
+ * shopper, the adoption is dropped and the SC is recorded as rejected
93
+ * (see `rejectsAdoption`);
94
+ * - `url` marker: the URL SC is intentional, so no retry.
95
+ * If the retry also fails, the current channel is kept as is.
96
+ */
97
+ const fetchSessionData = async (
98
+ clients: GraphqlContext['clients'],
99
+ params: URLSearchParams,
100
+ salesChannelSource: SalesChannelSource | undefined,
101
+ clientSalesChannel: string
102
+ ) => {
103
+ const unchanged = { salesChannelSource, rejectedSalesChannel: undefined }
104
+
105
+ try {
106
+ return {
107
+ ...unchanged,
108
+ sessionData: await clients.commerce.session(params.toString()),
109
+ }
110
+ } catch (error) {
111
+ if (salesChannelSource === 'url' || !isSalesChannelRejected(error)) {
112
+ return { ...unchanged, sessionData: null }
113
+ }
114
+
115
+ const requestedSalesChannel = params.get('sc') ?? undefined
116
+ const dropsAdoption = rejectsAdoption(
117
+ error,
118
+ salesChannelSource,
119
+ requestedSalesChannel,
120
+ clientSalesChannel
121
+ )
122
+
123
+ params.delete('sc')
124
+
125
+ const sessionData = await clients.commerce
126
+ .session(params.toString())
127
+ .catch(() => null)
128
+
129
+ if (!sessionData || !dropsAdoption) {
130
+ return { ...unchanged, sessionData }
131
+ }
132
+
133
+ return {
134
+ sessionData,
135
+ salesChannelSource: undefined,
136
+ rejectedSalesChannel: requestedSalesChannel,
137
+ }
138
+ }
139
+ }
140
+
31
141
  export const validateSession = async (
32
142
  _: any,
33
143
  { session: oldSession, search }: MutationValidateSessionArgs,
34
- { clients, headers, account }: GraphqlContext
144
+ { clients, headers, account, storage, discoveryConfig }: GraphqlContext
35
145
  ): Promise<StoreSession | null> => {
36
- const channel = ChannelMarshal.parse(oldSession.channel ?? '')
146
+ const clientChannel = ChannelMarshal.parse(oldSession.channel ?? '')
147
+ const incomingSalesChannelSource = salesChannelSourceOf(oldSession.channel, {
148
+ localizationEnabled: isLocalizationEnabled(discoveryConfig),
149
+ })
150
+ const channel = {
151
+ ...clientChannel,
152
+ salesChannel: salesChannelToRequest(
153
+ clientChannel,
154
+ incomingSalesChannelSource,
155
+ storage?.channel?.salesChannel
156
+ ? String(storage.channel.salesChannel)
157
+ : undefined,
158
+ search
159
+ ),
160
+ }
37
161
  const postalCode = String(oldSession.postalCode ?? '')
38
162
  const country = oldSession.country ?? ''
39
163
  let city = oldSession.city ?? null
@@ -69,9 +193,13 @@ export const validateSession = async (
69
193
  account
70
194
  )
71
195
 
72
- const sessionData = await clients.commerce
73
- .session(params.toString())
74
- .catch(() => null)
196
+ const { sessionData, salesChannelSource, rejectedSalesChannel } =
197
+ await fetchSessionData(
198
+ clients,
199
+ params,
200
+ incomingSalesChannelSource,
201
+ String(channel.salesChannel ?? '')
202
+ )
75
203
 
76
204
  const profile = sessionData?.namespaces.profile ?? null
77
205
  const shopper = sessionData?.namespaces.shopper ?? null
@@ -101,9 +229,17 @@ export const validateSession = async (
101
229
  postalCode,
102
230
  geoCoordinates,
103
231
  country,
104
- params.get('sc') ?? channel.salesChannel
232
+ params.get('sc') ?? store?.channel?.value ?? channel.salesChannel
105
233
  )
106
234
 
235
+ const person = buildPersonFromProfile(profile)
236
+ // A rejection belongs to the shopper it was recorded for: forget it when
237
+ // they log in or out, so a new identity can have the SC adopted again.
238
+ const previousRejection =
239
+ (oldSession.person?.id ?? null) === (person?.id ?? null)
240
+ ? rejectedSalesChannelOf(oldSession.channel)
241
+ : undefined
242
+
107
243
  const newSession = {
108
244
  ...oldSession,
109
245
  currency: {
@@ -115,7 +251,9 @@ export const validateSession = async (
115
251
  channel,
116
252
  store?.channel?.value,
117
253
  checkout?.regionId?.value,
118
- sellerId
254
+ sellerId,
255
+ salesChannelSource,
256
+ rejectedSalesChannel ?? previousRejection
119
257
  ),
120
258
  /**
121
259
  * B2B data structure in Session:
@@ -134,7 +272,7 @@ export const validateSession = async (
134
272
  unitId,
135
273
  }),
136
274
  marketingData,
137
- person: buildPersonFromProfile(profile),
275
+ person,
138
276
  geoCoordinates:
139
277
  (geoCoordinates?.latitude &&
140
278
  geoCoordinates?.longitude &&
@@ -1,3 +1,4 @@
1
+ import type { OrderForm } from '../clients/commerce/types/OrderForm'
1
2
  import type { Channel } from './channel'
2
3
  import ChannelMarshal from './channel'
3
4
 
@@ -29,3 +30,35 @@ export function channelWhenSessionDivergesFromOrderForm(
29
30
  hasOnlyDefaultSalesChannel: false,
30
31
  })
31
32
  }
33
+
34
+ /**
35
+ * Checkout stores an SC on the orderForm only after an items mutation sent
36
+ * with `sc`. An SC-less request on an orderForm that never had items falls
37
+ * back to the platform default (SC 1), so its SC is meaningful only when the
38
+ * orderForm has items or already matches the session.
39
+ */
40
+ export function shouldTrustOrderFormSalesChannel(
41
+ orderForm: Pick<OrderForm, 'items' | 'salesChannel'>,
42
+ // Persisted sessions may carry a numeric SC (e.g. `{"salesChannel":2}`).
43
+ sessionSalesChannel: string | number,
44
+ // SC that Session Manager rejected for this shopper (see validateSession).
45
+ rejectedSalesChannel?: string
46
+ ): boolean {
47
+ if (
48
+ rejectedSalesChannel != null &&
49
+ String(orderForm.salesChannel ?? '') === rejectedSalesChannel &&
50
+ String(sessionSalesChannel ?? '') !== rejectedSalesChannel
51
+ ) {
52
+ return false
53
+ }
54
+
55
+ if (orderForm.items.length > 0) {
56
+ return true
57
+ }
58
+
59
+ if (orderForm.salesChannel == null || orderForm.salesChannel === '') {
60
+ return false
61
+ }
62
+
63
+ return String(orderForm.salesChannel) === String(sessionSalesChannel ?? '')
64
+ }