@shop-on/theme-server 0.1.0 → 0.3.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.
@@ -0,0 +1,55 @@
1
+ import { derivedCookieSecret } from "./cookie-secret.js";
2
+ import { createServerOnlyFn } from "@tanstack/react-start";
3
+ import { createShopBoundCookieConfig } from "@shop-on/customer-runtime/session-cookie";
4
+ import { ACCOUNT_STATE_FORMAT } from "@shop-on/customer-runtime/handoff-state";
5
+ //#region src/account-state.ts
6
+ const TTL_SECONDS = 1800;
7
+ const DOMAIN_LABEL = "account-state";
8
+ const sessionConfig = createServerOnlyFn(async () => {
9
+ return createShopBoundCookieConfig({
10
+ name: "account_state",
11
+ password: await derivedCookieSecret(DOMAIN_LABEL),
12
+ maxAge: TTL_SECONDS
13
+ });
14
+ });
15
+ const loadSession = createServerOnlyFn(async () => {
16
+ const { openSession } = await import("@shop-on/customer-runtime/sealed-session");
17
+ return openSession(await sessionConfig());
18
+ });
19
+ /** The state this browser holds, or a fresh one once it is past its 30
20
+ * minutes. Reused so a second tab bouncing to account doesn't void the state
21
+ * an in-flight sign-in will come back with, and rewritten on reuse: the
22
+ * cookie's expiry counts from its first write, so only clearing first lets
23
+ * it outlast the OTP round trip that reuse starts. */
24
+ const issueAccountState = createServerOnlyFn(async () => {
25
+ const session = await loadSession();
26
+ const { state: kept, issuedAt } = session.data;
27
+ if (kept && issuedAt && Date.now() - issuedAt < TTL_SECONDS * 1e3) {
28
+ await session.clear();
29
+ await session.update({
30
+ state: kept,
31
+ issuedAt
32
+ });
33
+ return kept;
34
+ }
35
+ const { randomBytes } = await import("node:crypto");
36
+ const state = randomBytes(32).toString("base64url");
37
+ await session.clear();
38
+ await session.update({
39
+ state,
40
+ issuedAt: Date.now()
41
+ });
42
+ return state;
43
+ });
44
+ /** Whether `state` is the one this browser was issued. Constant-time. */
45
+ const matchesAccountState = createServerOnlyFn(async (state) => {
46
+ if (!state || !ACCOUNT_STATE_FORMAT.test(state)) return false;
47
+ const issued = (await loadSession()).data.state;
48
+ if (!issued) return false;
49
+ const { timingSafeEqual } = await import("node:crypto");
50
+ const a = Buffer.from(issued);
51
+ const b = Buffer.from(state);
52
+ return a.length === b.length && timingSafeEqual(a, b);
53
+ });
54
+ //#endregion
55
+ export { issueAccountState, matchesAccountState };
package/dist/account.js CHANGED
@@ -22,7 +22,12 @@ async function getAccountRedirectUrl(data) {
22
22
  xForwardedHost: true,
23
23
  xForwardedProto: true
24
24
  }).origin;
25
- target.searchParams.set("return_to", `${origin}${safePath}`);
25
+ const returnTo = new URL(`${origin}${safePath}`);
26
+ const { issueAccountState } = await import("./account-state.js");
27
+ returnTo.searchParams.set("state", await issueAccountState());
28
+ const { markResponsePrivate } = await import("@shop-on/customer-runtime/handoff-state");
29
+ await markResponsePrivate();
30
+ target.searchParams.set("return_to", returnTo.toString());
26
31
  }
27
32
  return { url: target.toString() };
28
33
  }
@@ -1,3 +1,4 @@
1
+ import { requestTraceHeaders } from "./trace.js";
1
2
  import { getStorefrontToken, storefrontApiUrl } from "./graphql.js";
2
3
  import { isIP } from "node:net";
3
4
  //#region src/analytics-proxy.ts
@@ -8,7 +9,8 @@ const PATHS = {
8
9
  async function proxyAnalytics({ endpoint, body, visitToken, visitorToken, userAgent, clientIp, fbp, fbc, eventSourceUrl }) {
9
10
  const headers = {
10
11
  Authorization: `Bearer ${await getStorefrontToken()}`,
11
- "Content-Type": "application/json"
12
+ "Content-Type": "application/json",
13
+ ...requestTraceHeaders()
12
14
  };
13
15
  if (visitToken) headers["Ahoy-Visit"] = visitToken;
14
16
  if (visitorToken) headers["Ahoy-Visitor"] = visitorToken;
@@ -1,10 +1,8 @@
1
- import { useSession } from "@tanstack/react-start/server";
1
+ import { SealedSession } from "@shop-on/customer-runtime/sealed-session";
2
2
  //#region src/cart-session.d.ts
3
3
  export interface CartSessionData {
4
4
  token?: string;
5
5
  expiresAtMs?: number;
6
6
  }
7
- type CartSession = ReturnType<typeof useSession<CartSessionData>>;
8
- export declare const useCartSession: () => CartSession;
9
- //#endregion
10
- export {};
7
+ export declare const useCartSession: () => Promise<SealedSession<CartSessionData>>;
8
+ //#endregion
@@ -1,29 +1,19 @@
1
+ import { derivedCookieSecret } from "./cookie-secret.js";
1
2
  import { createServerOnlyFn } from "@tanstack/react-start";
3
+ import { createShopBoundCookieConfig } from "@shop-on/customer-runtime/session-cookie";
2
4
  //#region src/cart-session.ts
3
5
  const TTL_SECONDS = 2592e3;
4
6
  const DOMAIN_LABEL = "cart";
5
- const readSecret = createServerOnlyFn(async () => {
6
- const customer = process.env.STOREFRONT_CUSTOMER_SESSION_SECRET;
7
- if (!customer) throw new Error("cart-session: STOREFRONT_CUSTOMER_SESSION_SECRET missing — orchestrator did not inject it");
8
- const { createHmac } = await import("node:crypto");
9
- return createHmac("sha256", customer).update(DOMAIN_LABEL).digest("hex");
10
- });
11
7
  const sessionConfig = createServerOnlyFn(async () => {
12
- return {
13
- password: await readSecret(),
8
+ return createShopBoundCookieConfig({
14
9
  name: "cart_session",
15
- maxAge: TTL_SECONDS,
16
- cookie: {
17
- secure: process.env.NODE_ENV === "production",
18
- sameSite: "lax",
19
- httpOnly: true,
20
- path: "/"
21
- }
22
- };
10
+ password: await derivedCookieSecret(DOMAIN_LABEL),
11
+ maxAge: TTL_SECONDS
12
+ });
23
13
  });
24
14
  const useCartSession = createServerOnlyFn(async () => {
25
- const { useSession } = await import("@tanstack/react-start/server");
26
- return useSession(await sessionConfig());
15
+ const { openSession } = await import("@shop-on/customer-runtime/sealed-session");
16
+ return openSession(await sessionConfig());
27
17
  });
28
18
  //#endregion
29
19
  export { useCartSession };
@@ -4,21 +4,22 @@ export type CheckoutHandoffResult = {
4
4
  redirectTo: string;
5
5
  } | {
6
6
  ok: false;
7
- reason: "no_cart";
7
+ reason: "no_cart" | "bad_state";
8
8
  };
9
9
  /**
10
- * Read this shop's `cart_session` cookie, call Rails `cartCheckout` (forwarding
11
- * the customer-session token as `X-Customer-Session` when provided so Rails
12
- * folds the customer into the handoff payload in one trip), and return the URL
13
- * on the checkout app that consumes it.
10
+ * The theme's `/checkout`, both passes. `state` is the URL's `checkout_state`,
11
+ * a REQUIRED key even when `undefined`: a caller that never passed it would
12
+ * bounce theme ↔ `/begin` forever, and this way that is a compile error.
14
13
  *
15
- * `no_cart` covers both "no cart cookie" and Rails' `NOT_FOUND` (cart digest
16
- * miss / cross-shop) / `CART_EMPTY` — the central app's "no cart" StatusPage is
17
- * the recovery surface for either. Any other userError code is unexpected and
18
- * throws; the calling route's error boundary handles it (matching `request`'s
19
- * retry-once-then-throw contract for top-level GraphQL errors).
14
+ * - No state: the URL of checkout's `/begin`, which binds this browser and
15
+ * returns the buyer here with one. Mints nothing.
16
+ * - A state: mint the cart handoff with it (`mintCheckoutHandoff`).
17
+ *
18
+ * The customer-session token is injected: forwarded as `X-Customer-Session` on
19
+ * the mint so Rails folds the buyer into the handoff payload in one trip.
20
20
  */
21
- export declare function startCheckoutHandoff(opts?: {
21
+ export declare function startCheckoutHandoff(opts: {
22
+ state: string | undefined;
22
23
  customerToken?: string;
23
24
  }): Promise<CheckoutHandoffResult>;
24
25
  //#endregion
package/dist/checkout.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import { graphql, request } from "./graphql.js";
2
+ import { fetchShopInfo } from "./shop.js";
2
3
  import { useCartSession } from "./cart-session.js";
3
4
  import { parseMarketingCookies } from "./marketing-cookies.js";
4
5
  import { createServerOnlyFn } from "@tanstack/react-start";
6
+ import { CHECKOUT_STATE_FORMAT, isLoopback, markResponsePrivate } from "@shop-on/customer-runtime/handoff-state";
5
7
  //#region src/checkout.ts
6
8
  const CartCheckoutMutation = graphql(`
7
9
  mutation CartCheckout($input: CartCheckoutInput!) {
@@ -25,24 +27,73 @@ const readMarketingAttribution = createServerOnlyFn(async () => {
25
27
  return null;
26
28
  }
27
29
  });
30
+ function publicCheckoutUrl(origin) {
31
+ const url = new URL("/checkout", origin);
32
+ if (!isLoopback(url.hostname)) url.protocol = "https:";
33
+ return url.toString();
34
+ }
35
+ /**
36
+ * The theme's `/checkout`, both passes. `state` is the URL's `checkout_state`,
37
+ * a REQUIRED key even when `undefined`: a caller that never passed it would
38
+ * bounce theme ↔ `/begin` forever, and this way that is a compile error.
39
+ *
40
+ * - No state: the URL of checkout's `/begin`, which binds this browser and
41
+ * returns the buyer here with one. Mints nothing.
42
+ * - A state: mint the cart handoff with it (`mintCheckoutHandoff`).
43
+ *
44
+ * The customer-session token is injected: forwarded as `X-Customer-Session` on
45
+ * the mint so Rails folds the buyer into the handoff payload in one trip.
46
+ */
47
+ function startCheckoutHandoff(opts) {
48
+ return opts.state === void 0 ? beginCheckoutUrl() : mintCheckoutHandoff({
49
+ state: opts.state,
50
+ customerToken: opts.customerToken
51
+ });
52
+ }
53
+ async function beginCheckoutUrl() {
54
+ if (!(await useCartSession()).data.token) return {
55
+ ok: false,
56
+ reason: "no_cart"
57
+ };
58
+ await markResponsePrivate();
59
+ const shop = await fetchShopInfo();
60
+ if (!shop) throw new Error("[checkout] shop info unavailable from Rails");
61
+ const { getPublicOrigin } = await import("./seo-context.js");
62
+ const url = new URL(`${shop.checkoutUrl}/begin`);
63
+ url.searchParams.set("return_to", publicCheckoutUrl(getPublicOrigin()));
64
+ return {
65
+ ok: true,
66
+ redirectTo: url.toString()
67
+ };
68
+ }
28
69
  /**
29
- * Read this shop's `cart_session` cookie, call Rails `cartCheckout` (forwarding
30
- * the customer-session token as `X-Customer-Session` when provided so Rails
31
- * folds the customer into the handoff payload in one trip), and return the URL
32
- * on the checkout app that consumes it.
70
+ * Read this shop's `cart_session` cookie, call Rails `cartCheckout`, and return
71
+ * the URL on the checkout app that consumes it, `checkout_state` appended.
33
72
  *
34
- * `no_cart` covers both "no cart cookie" and Rails' `NOT_FOUND` (cart digest
35
- * miss / cross-shop) / `CART_EMPTY` — the central app's "no cart" StatusPage is
36
- * the recovery surface for either. Any other userError code is unexpected and
37
- * throws; the calling route's error boundary handles it (matching `request`'s
38
- * retry-once-then-throw contract for top-level GraphQL errors).
73
+ * `no_cart` covers "no cart cookie" and Rails' `NOT_FOUND` (cart digest miss /
74
+ * cross-shop) / `CART_EMPTY`; `bad_state` a state of the wrong shape (never
75
+ * bounced to `/begin` again, so it can't loop). Any other userError code is
76
+ * unexpected and throws (matching `request`'s retry-once-then-throw contract
77
+ * for top-level GraphQL errors); the calling route sends the buyer home rather
78
+ * than render on a URL holding a state.
39
79
  */
40
- async function startCheckoutHandoff(opts = {}) {
80
+ async function mintCheckoutHandoff(opts) {
41
81
  const cartToken = (await useCartSession()).data.token;
42
82
  if (!cartToken) return {
43
83
  ok: false,
44
84
  reason: "no_cart"
45
85
  };
86
+ await markResponsePrivate();
87
+ if (!CHECKOUT_STATE_FORMAT.test(opts.state)) {
88
+ console.warn(JSON.stringify({
89
+ event: "theme_checkout_bad_state",
90
+ ts: Date.now()
91
+ }));
92
+ return {
93
+ ok: false,
94
+ reason: "bad_state"
95
+ };
96
+ }
46
97
  const attribution = await readMarketingAttribution();
47
98
  const result = await request(CartCheckoutMutation, { input: attribution ? {
48
99
  cartToken,
@@ -57,9 +108,11 @@ async function startCheckoutHandoff(opts = {}) {
57
108
  };
58
109
  throw new Error(`[checkout] cartCheckout returned no URL (code=${code ?? "UNKNOWN"})`);
59
110
  }
111
+ const bound = new URL(checkoutUrl);
112
+ bound.searchParams.set("checkout_state", opts.state);
60
113
  return {
61
114
  ok: true,
62
- redirectTo: checkoutUrl
115
+ redirectTo: bound.toString()
63
116
  };
64
117
  }
65
118
  //#endregion
@@ -0,0 +1,10 @@
1
+ import { createServerOnlyFn } from "@tanstack/react-start";
2
+ //#region src/cookie-secret.ts
3
+ const derivedCookieSecret = createServerOnlyFn(async (label) => {
4
+ const secret = process.env.STOREFRONT_CUSTOMER_SESSION_SECRET;
5
+ if (!secret) throw new Error(`${label} cookie: STOREFRONT_CUSTOMER_SESSION_SECRET missing — orchestrator did not inject it`);
6
+ const { createHmac } = await import("node:crypto");
7
+ return createHmac("sha256", secret).update(label).digest("hex");
8
+ });
9
+ //#endregion
10
+ export { derivedCookieSecret };
@@ -9,5 +9,5 @@ export type ConsumeCustomerHandoffResult = {
9
9
  * Plain server-only helper. Tested directly; each theme wraps it in a thin
10
10
  * `createServerFn` for its root route's `beforeLoad`.
11
11
  */
12
- export declare const consumeCustomerHandoff: (nonce: string) => Promise<ConsumeCustomerHandoffResult>;
12
+ export declare const consumeCustomerHandoff: (nonce: string, state: string | undefined) => Promise<ConsumeCustomerHandoffResult>;
13
13
  //#endregion
@@ -1,13 +1,14 @@
1
1
  import { writeCustomerSession } from "./customer-session.js";
2
+ import { matchesAccountState } from "./account-state.js";
2
3
  import { createServerOnlyFn } from "@tanstack/react-start";
3
4
  //#region src/customer-handoff.ts
4
5
  /**
5
6
  * Plain server-only helper. Tested directly; each theme wraps it in a thin
6
7
  * `createServerFn` for its root route's `beforeLoad`.
7
8
  */
8
- const consumeCustomerHandoff = createServerOnlyFn(async (nonce) => {
9
+ const consumeCustomerHandoff = createServerOnlyFn(async (nonce, state) => {
9
10
  try {
10
- return await consumeAndStore(nonce);
11
+ return await consumeAndStore(nonce, state);
11
12
  } catch (err) {
12
13
  console.warn(JSON.stringify({
13
14
  event: "theme_customer_handoff_consume_error",
@@ -20,7 +21,18 @@ const consumeCustomerHandoff = createServerOnlyFn(async (nonce) => {
20
21
  };
21
22
  }
22
23
  });
23
- const consumeAndStore = createServerOnlyFn(async (nonce) => {
24
+ const consumeAndStore = createServerOnlyFn(async (nonce, state) => {
25
+ if (!await matchesAccountState(state)) {
26
+ console.warn(JSON.stringify({
27
+ event: "theme_customer_handoff_unbound",
28
+ hasState: Boolean(state),
29
+ ts: Date.now()
30
+ }));
31
+ return {
32
+ ok: false,
33
+ reason: "unbound"
34
+ };
35
+ }
24
36
  const { consumeCustomerSessionHandoff } = await import("@shop-on/customer-runtime/customer-session-handoff");
25
37
  const result = await consumeCustomerSessionHandoff(nonce);
26
38
  if (!result.ok) {
@@ -1,29 +1,19 @@
1
+ import { derivedCookieSecret } from "./cookie-secret.js";
1
2
  import { createServerOnlyFn } from "@tanstack/react-start";
3
+ import { createShopBoundCookieConfig } from "@shop-on/customer-runtime/session-cookie";
2
4
  //#region src/customer-session.ts
3
5
  const TTL_SECONDS = 2592e3;
4
6
  const DOMAIN_LABEL = "customer";
5
- const readSecret = createServerOnlyFn(async () => {
6
- const secret = process.env.STOREFRONT_CUSTOMER_SESSION_SECRET;
7
- if (!secret) throw new Error("customer-session: STOREFRONT_CUSTOMER_SESSION_SECRET missing — orchestrator did not inject it");
8
- const { createHmac } = await import("node:crypto");
9
- return createHmac("sha256", secret).update(DOMAIN_LABEL).digest("hex");
10
- });
11
7
  const sessionConfig = createServerOnlyFn(async () => {
12
- return {
13
- password: await readSecret(),
8
+ return createShopBoundCookieConfig({
14
9
  name: "customer_session",
15
- maxAge: TTL_SECONDS,
16
- cookie: {
17
- secure: process.env.NODE_ENV === "production",
18
- sameSite: "lax",
19
- httpOnly: true,
20
- path: "/"
21
- }
22
- };
10
+ password: await derivedCookieSecret(DOMAIN_LABEL),
11
+ maxAge: TTL_SECONDS
12
+ });
23
13
  });
24
14
  const loadSession = createServerOnlyFn(async () => {
25
- const { useSession } = await import("@tanstack/react-start/server");
26
- return useSession(await sessionConfig());
15
+ const { openSession } = await import("@shop-on/customer-runtime/sealed-session");
16
+ return openSession(await sessionConfig());
27
17
  });
28
18
  const readCustomerSession = createServerOnlyFn(async () => {
29
19
  try {
@@ -2158,6 +2158,22 @@ export type introspection_types = {
2158
2158
  ofType: null;
2159
2159
  };
2160
2160
  defaultValue: null;
2161
+ }, {
2162
+ name: 'latitude';
2163
+ type: {
2164
+ kind: 'SCALAR';
2165
+ name: 'Float';
2166
+ ofType: null;
2167
+ };
2168
+ defaultValue: null;
2169
+ }, {
2170
+ name: 'longitude';
2171
+ type: {
2172
+ kind: 'SCALAR';
2173
+ name: 'Float';
2174
+ ofType: null;
2175
+ };
2176
+ defaultValue: null;
2161
2177
  }, {
2162
2178
  name: 'phone';
2163
2179
  type: {
@@ -2794,6 +2810,14 @@ export type introspection_types = {
2794
2810
  };
2795
2811
  };
2796
2812
  defaultValue: null;
2813
+ }, {
2814
+ name: 'retryKey';
2815
+ type: {
2816
+ kind: 'SCALAR';
2817
+ name: 'String';
2818
+ ofType: null;
2819
+ };
2820
+ defaultValue: null;
2797
2821
  }];
2798
2822
  };
2799
2823
  'CheckoutRecoverPayload': {
@@ -2838,6 +2862,10 @@ export type introspection_types = {
2838
2862
  };
2839
2863
  };
2840
2864
  };
2865
+ 'CheckoutRecoveryStatusEnum': {
2866
+ name: 'CheckoutRecoveryStatusEnum';
2867
+ enumValues: 'EXPIRED' | 'INVALID' | 'VALID';
2868
+ };
2841
2869
  'CheckoutShippingAddress': {
2842
2870
  kind: 'OBJECT';
2843
2871
  name: 'CheckoutShippingAddress';
@@ -2942,6 +2970,22 @@ export type introspection_types = {
2942
2970
  ofType: null;
2943
2971
  };
2944
2972
  };
2973
+ 'latitude': {
2974
+ name: 'latitude';
2975
+ type: {
2976
+ kind: 'SCALAR';
2977
+ name: 'Float';
2978
+ ofType: null;
2979
+ };
2980
+ };
2981
+ 'longitude': {
2982
+ name: 'longitude';
2983
+ type: {
2984
+ kind: 'SCALAR';
2985
+ name: 'Float';
2986
+ ofType: null;
2987
+ };
2988
+ };
2945
2989
  'phone': {
2946
2990
  name: 'phone';
2947
2991
  type: {
@@ -3654,6 +3698,10 @@ export type introspection_types = {
3654
3698
  };
3655
3699
  };
3656
3700
  };
3701
+ 'CourierPromptEnum': {
3702
+ name: 'CourierPromptEnum';
3703
+ enumValues: 'PHONE' | 'PIN' | 'UNAVAILABLE';
3704
+ };
3657
3705
  'CropRegionEnum': {
3658
3706
  name: 'CropRegionEnum';
3659
3707
  enumValues: 'BOTTOM' | 'CENTER' | 'LEFT' | 'RIGHT' | 'TOP';
@@ -6583,6 +6631,22 @@ export type introspection_types = {
6583
6631
  ofType: null;
6584
6632
  };
6585
6633
  };
6634
+ 'latitude': {
6635
+ name: 'latitude';
6636
+ type: {
6637
+ kind: 'SCALAR';
6638
+ name: 'Float';
6639
+ ofType: null;
6640
+ };
6641
+ };
6642
+ 'longitude': {
6643
+ name: 'longitude';
6644
+ type: {
6645
+ kind: 'SCALAR';
6646
+ name: 'Float';
6647
+ ofType: null;
6648
+ };
6649
+ };
6586
6650
  'phone': {
6587
6651
  name: 'phone';
6588
6652
  type: {
@@ -7133,6 +7197,18 @@ export type introspection_types = {
7133
7197
  ofType: null;
7134
7198
  };
7135
7199
  };
7200
+ 'checkoutRecoveryStatus': {
7201
+ name: 'checkoutRecoveryStatus';
7202
+ type: {
7203
+ kind: 'NON_NULL';
7204
+ name: never;
7205
+ ofType: {
7206
+ kind: 'ENUM';
7207
+ name: 'CheckoutRecoveryStatusEnum';
7208
+ ofType: null;
7209
+ };
7210
+ };
7211
+ };
7136
7212
  'collection': {
7137
7213
  name: 'collection';
7138
7214
  type: {
@@ -8693,6 +8769,18 @@ export type introspection_types = {
8693
8769
  };
8694
8770
  };
8695
8771
  };
8772
+ 'checkoutUrl': {
8773
+ name: 'checkoutUrl';
8774
+ type: {
8775
+ kind: 'NON_NULL';
8776
+ name: never;
8777
+ ofType: {
8778
+ kind: 'SCALAR';
8779
+ name: 'String';
8780
+ ofType: null;
8781
+ };
8782
+ };
8783
+ };
8696
8784
  'city': {
8697
8785
  name: 'city';
8698
8786
  type: {
@@ -10473,6 +10561,14 @@ export type introspection_types = {
10473
10561
  ofType: null;
10474
10562
  };
10475
10563
  };
10564
+ 'deliveryCourierPrompt': {
10565
+ name: 'deliveryCourierPrompt';
10566
+ type: {
10567
+ kind: 'ENUM';
10568
+ name: 'CourierPromptEnum';
10569
+ ofType: null;
10570
+ };
10571
+ };
10476
10572
  'deliveryOptionHandle': {
10477
10573
  name: 'deliveryOptionHandle';
10478
10574
  type: {
@@ -11877,6 +11973,18 @@ export type introspection_types = {
11877
11973
  };
11878
11974
  };
11879
11975
  };
11976
+ 'cashOnDeliveryAllowed': {
11977
+ name: 'cashOnDeliveryAllowed';
11978
+ type: {
11979
+ kind: 'NON_NULL';
11980
+ name: never;
11981
+ ofType: {
11982
+ kind: 'SCALAR';
11983
+ name: 'Boolean';
11984
+ ofType: null;
11985
+ };
11986
+ };
11987
+ };
11880
11988
  'handle': {
11881
11989
  name: 'handle';
11882
11990
  type: {
@@ -12196,6 +12304,14 @@ export type introspection_types = {
12196
12304
  ofType: null;
12197
12305
  };
12198
12306
  };
12307
+ 'totalAmount': {
12308
+ name: 'totalAmount';
12309
+ type: {
12310
+ kind: 'SCALAR';
12311
+ name: 'Decimal';
12312
+ ofType: null;
12313
+ };
12314
+ };
12199
12315
  'updatedAt': {
12200
12316
  name: 'updatedAt';
12201
12317
  type: {
@@ -15726,6 +15842,18 @@ export type introspection_types = {
15726
15842
  };
15727
15843
  };
15728
15844
  };
15845
+ 'cashOnDeliveryAllowed': {
15846
+ name: 'cashOnDeliveryAllowed';
15847
+ type: {
15848
+ kind: 'NON_NULL';
15849
+ name: never;
15850
+ ofType: {
15851
+ kind: 'SCALAR';
15852
+ name: 'Boolean';
15853
+ ofType: null;
15854
+ };
15855
+ };
15856
+ };
15729
15857
  'handle': {
15730
15858
  name: 'handle';
15731
15859
  type: {
@@ -17848,6 +17976,18 @@ export type introspection_types = {
17848
17976
  };
17849
17977
  };
17850
17978
  };
17979
+ 'cashOnDeliveryAllowed': {
17980
+ name: 'cashOnDeliveryAllowed';
17981
+ type: {
17982
+ kind: 'NON_NULL';
17983
+ name: never;
17984
+ ofType: {
17985
+ kind: 'SCALAR';
17986
+ name: 'Boolean';
17987
+ ofType: null;
17988
+ };
17989
+ };
17990
+ };
17851
17991
  'deliveryDetails': {
17852
17992
  name: 'deliveryDetails';
17853
17993
  type: {
@@ -20026,6 +20166,14 @@ export type introspection_types = {
20026
20166
  name: 'ThemeFilter';
20027
20167
  isOneOf: false;
20028
20168
  inputFields: [{
20169
+ name: 'createdAt';
20170
+ type: {
20171
+ kind: 'INPUT_OBJECT';
20172
+ name: 'DateFilter';
20173
+ ofType: null;
20174
+ };
20175
+ defaultValue: null;
20176
+ }, {
20029
20177
  name: 'handleCont';
20030
20178
  type: {
20031
20179
  kind: 'SCALAR';
@@ -20057,6 +20205,30 @@ export type introspection_types = {
20057
20205
  ofType: null;
20058
20206
  };
20059
20207
  defaultValue: null;
20208
+ }, {
20209
+ name: 'query';
20210
+ type: {
20211
+ kind: 'INPUT_OBJECT';
20212
+ name: 'StringFilter';
20213
+ ofType: null;
20214
+ };
20215
+ defaultValue: null;
20216
+ }, {
20217
+ name: 'status';
20218
+ type: {
20219
+ kind: 'INPUT_OBJECT';
20220
+ name: 'EnumStringFilter';
20221
+ ofType: null;
20222
+ };
20223
+ defaultValue: null;
20224
+ }, {
20225
+ name: 'updatedAt';
20226
+ type: {
20227
+ kind: 'INPUT_OBJECT';
20228
+ name: 'DateFilter';
20229
+ ofType: null;
20230
+ };
20231
+ defaultValue: null;
20060
20232
  }];
20061
20233
  };
20062
20234
  'ThemePreset': {
package/dist/graphql.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createLease } from "./lease.js";
2
2
  import { requestSlot } from "./request-scope.js";
3
+ import { requestTraceHeaders } from "./trace.js";
3
4
  import { initGraphQLTada } from "gql.tada";
4
5
  import { Kind, print } from "graphql";
5
6
  import { ClientError, GraphQLClient } from "graphql-request";
@@ -145,17 +146,18 @@ function extraHeaders(options) {
145
146
  if (options?.acceptLanguage) extra["Accept-Language"] = options.acceptLanguage;
146
147
  return extra;
147
148
  }
148
- function bearerHeaders(token, options) {
149
+ function bearerHeaders(token, options, trace) {
149
150
  return {
150
151
  Authorization: `Bearer ${token}`,
151
- ...extraHeaders(options)
152
+ ...extraHeaders(options),
153
+ ...trace
152
154
  };
153
155
  }
154
156
  function invalidateToken(used) {
155
157
  if (cached?.accessToken === used) cached = null;
156
158
  }
157
- async function executeSingle(document, variables, options) {
158
- const send = async (token) => await buildClient(bearerHeaders(token, options)).request(document, variables);
159
+ async function executeSingle(document, variables, options, trace) {
160
+ const send = async (token) => await buildClient(bearerHeaders(token, options, trace)).request(document, variables);
159
161
  const token = await getToken();
160
162
  try {
161
163
  return await send(token);
@@ -179,11 +181,12 @@ function enqueueBatched(queues, query, variables, options) {
179
181
  let queue = queues.get(key);
180
182
  if (!queue) {
181
183
  const scheduled = [];
184
+ const trace = requestTraceHeaders();
182
185
  queue = scheduled;
183
186
  queues.set(key, scheduled);
184
187
  setTimeout(() => {
185
188
  queues.delete(key);
186
- flushBatch(scheduled, options);
189
+ flushBatch(scheduled, options, trace);
187
190
  }, 0);
188
191
  }
189
192
  queue.push({
@@ -194,15 +197,15 @@ function enqueueBatched(queues, query, variables, options) {
194
197
  });
195
198
  });
196
199
  }
197
- async function flushBatch(entries, options) {
200
+ async function flushBatch(entries, options, trace) {
198
201
  const chunks = [];
199
202
  for (let i = 0; i < entries.length; i += BATCH_MAX_QUERIES) chunks.push(entries.slice(i, i + BATCH_MAX_QUERIES));
200
203
  await Promise.all(chunks.map(async (chunk) => {
201
204
  try {
202
205
  if (chunk.length === 1) {
203
206
  const only = chunk[0];
204
- only.resolve(await executeSingle(only.query, only.variables, options));
205
- } else await executeBatchChunk(chunk, options);
207
+ only.resolve(await executeSingle(only.query, only.variables, options, trace));
208
+ } else await executeBatchChunk(chunk, options, trace);
206
209
  } catch (err) {
207
210
  for (const entry of chunk) entry.reject(err);
208
211
  }
@@ -223,7 +226,7 @@ async function postWithAuthRetry(buildInit) {
223
226
  invalidateToken(token);
224
227
  return await post(await getToken());
225
228
  }
226
- async function executeBatchChunk(chunk, options) {
229
+ async function executeBatchChunk(chunk, options, trace) {
227
230
  const body = JSON.stringify(chunk.map(({ query, variables }) => ({
228
231
  query,
229
232
  variables
@@ -231,7 +234,7 @@ async function executeBatchChunk(chunk, options) {
231
234
  const res = await postWithAuthRetry((token) => ({
232
235
  headers: {
233
236
  "Content-Type": "application/json",
234
- ...bearerHeaders(token, options)
237
+ ...bearerHeaders(token, options, trace)
235
238
  },
236
239
  body
237
240
  }));
@@ -266,7 +269,7 @@ async function executeBatchChunk(chunk, options) {
266
269
  async function request(document, variables, options) {
267
270
  assertServerOnly();
268
271
  const queues = isBatchableQuery(document) ? requestSlot(BATCH_QUEUES, () => /* @__PURE__ */ new Map()) : void 0;
269
- if (!queues) return await executeSingle(document, variables, options);
272
+ if (!queues) return await executeSingle(document, variables, options, requestTraceHeaders());
270
273
  return await enqueueBatched(queues, print(document), variables, options);
271
274
  }
272
275
  /**
@@ -285,6 +288,7 @@ async function request(document, variables, options) {
285
288
  */
286
289
  async function requestUpload(document, variables, files, filesPath, options) {
287
290
  assertServerOnly();
291
+ const trace = requestTraceHeaders();
288
292
  const query = print(document);
289
293
  const map = {};
290
294
  files.forEach((_, i) => {
@@ -305,7 +309,7 @@ async function requestUpload(document, variables, files, filesPath, options) {
305
309
  return form;
306
310
  };
307
311
  const res = await postWithAuthRetry((token) => ({
308
- headers: bearerHeaders(token, options),
312
+ headers: bearerHeaders(token, options, trace),
309
313
  body: buildBody()
310
314
  }));
311
315
  if (!res.ok) throw batchClientError({
@@ -1,16 +1,11 @@
1
+ import { derivedCookieSecret } from "./cookie-secret.js";
1
2
  import { createServerOnlyFn } from "@tanstack/react-start";
2
3
  //#region src/password-session.ts
3
4
  const TTL_SECONDS = 2592e3;
4
5
  const DOMAIN_LABEL = "password";
5
- const readSecret = createServerOnlyFn(async () => {
6
- const secret = process.env.STOREFRONT_CUSTOMER_SESSION_SECRET;
7
- if (!secret) throw new Error("password-session: STOREFRONT_CUSTOMER_SESSION_SECRET missing — orchestrator did not inject it");
8
- const { createHmac } = await import("node:crypto");
9
- return createHmac("sha256", secret).update(DOMAIN_LABEL).digest("hex");
10
- });
11
6
  const sessionConfig = createServerOnlyFn(async () => {
12
7
  return {
13
- password: await readSecret(),
8
+ password: await derivedCookieSecret(DOMAIN_LABEL),
14
9
  name: "storefront_unlock",
15
10
  maxAge: TTL_SECONDS,
16
11
  cookie: {
@@ -22,8 +17,8 @@ const sessionConfig = createServerOnlyFn(async () => {
22
17
  };
23
18
  });
24
19
  const loadSession = createServerOnlyFn(async () => {
25
- const { useSession } = await import("@tanstack/react-start/server");
26
- return useSession(await sessionConfig());
20
+ const { openSession } = await import("@shop-on/customer-runtime/sealed-session");
21
+ return openSession(await sessionConfig());
27
22
  });
28
23
  const readPasswordSession = createServerOnlyFn(async () => {
29
24
  try {
@@ -408,6 +408,10 @@ input CartCheckoutInput {
408
408
  Autogenerated return type of CartCheckout.
409
409
  """
410
410
  type CartCheckoutPayload {
411
+ """
412
+ Where to send the buyer. Append `checkout_state` from the
413
+ `shop.checkoutUrl/begin` bounce before redirecting; checkout rejects a handoff without it.
414
+ """
411
415
  checkoutUrl: String
412
416
 
413
417
  """
@@ -714,6 +718,16 @@ input CheckoutAddressInput {
714
718
  firstName: String
715
719
  khorooId: ID
716
720
  lastName: String
721
+
722
+ """
723
+ Delivery pin. Send both or neither.
724
+ """
725
+ latitude: Float
726
+
727
+ """
728
+ Delivery pin. Send both or neither.
729
+ """
730
+ longitude: Float
717
731
  phone: String
718
732
  provinceId: ID
719
733
  zip: String
@@ -929,6 +943,12 @@ input CheckoutRecoverInput {
929
943
  """
930
944
  clientMutationId: String
931
945
  recoveryToken: String!
946
+
947
+ """
948
+ A random key the caller keeps for this link. If the reply to the first use is
949
+ lost, the same key may resume the checkout again for a few minutes.
950
+ """
951
+ retryKey: String
932
952
  }
933
953
 
934
954
  """
@@ -944,6 +964,26 @@ type CheckoutRecoverPayload {
944
964
  userErrors: [UserError!]!
945
965
  }
946
966
 
967
+ """
968
+ Whether a checkout recovery link can still resume its checkout.
969
+ """
970
+ enum CheckoutRecoveryStatusEnum {
971
+ """
972
+ The checkout was completed or has expired.
973
+ """
974
+ EXPIRED
975
+
976
+ """
977
+ The link is unknown, already used, or replaced by a newer one.
978
+ """
979
+ INVALID
980
+
981
+ """
982
+ The link resumes the checkout.
983
+ """
984
+ VALID
985
+ }
986
+
947
987
  """
948
988
  Buyer-facing snapshot of the shipping address on a checkout.
949
989
  """
@@ -963,6 +1003,8 @@ type CheckoutShippingAddress {
963
1003
  formatted(locale: String): [String!]!
964
1004
  khoroo: AddressKhoroo
965
1005
  lastName: String
1006
+ latitude: Float
1007
+ longitude: Float
966
1008
  phone: String
967
1009
  province: AddressProvince
968
1010
  provinceCode: String
@@ -1346,6 +1388,26 @@ type CourierDriverLoginPayload {
1346
1388
  userErrors: [UserError!]!
1347
1389
  }
1348
1390
 
1391
+ """
1392
+ What the buyer must do, or wait for, to see a delivery a courier prices.
1393
+ """
1394
+ enum CourierPromptEnum {
1395
+ """
1396
+ Add a phone number the courier can call.
1397
+ """
1398
+ PHONE
1399
+
1400
+ """
1401
+ Drop a pin on the map: the courier prices from it.
1402
+ """
1403
+ PIN
1404
+
1405
+ """
1406
+ The courier cannot be reached right now; choose another delivery.
1407
+ """
1408
+ UNAVAILABLE
1409
+ }
1410
+
1349
1411
  """
1350
1412
  Crop region for image transforms
1351
1413
  """
@@ -2404,6 +2466,9 @@ type Mutation {
2404
2466
 
2405
2467
  """
2406
2468
  Mint a checkout handoff for the given cart and return the URL to redirect to.
2469
+ Checkout consumes the handoff only in the browser that started it: bounce the
2470
+ buyer through `shop.checkoutUrl` + `/begin` first, and append the
2471
+ `checkout_state` it sends back to `checkoutUrl`, or checkout rejects it.
2407
2472
  """
2408
2473
  cartCheckout(
2409
2474
  """
@@ -2869,6 +2934,8 @@ type OrderShippingAddress {
2869
2934
  formatted(locale: String): [String!]!
2870
2935
  khoroo: AddressKhoroo
2871
2936
  lastName: String
2937
+ latitude: Float
2938
+ longitude: Float
2872
2939
  phone: String
2873
2940
  province: AddressProvince
2874
2941
  provinceCode: String
@@ -3238,6 +3305,18 @@ type Query {
3238
3305
  """
3239
3306
  checkout(checkoutToken: String!): ShopCheckout
3240
3307
 
3308
+ """
3309
+ Whether a recovery link can still resume its checkout. Read-only: it never spends the link.
3310
+ """
3311
+ checkoutRecoveryStatus(
3312
+ recoveryToken: String!
3313
+
3314
+ """
3315
+ The key sent with an earlier use of this link.
3316
+ """
3317
+ retryKey: String
3318
+ ): CheckoutRecoveryStatusEnum!
3319
+
3241
3320
  """
3242
3321
  Find a collection by ID or handle
3243
3322
  """
@@ -4392,6 +4471,14 @@ type Shop implements Node {
4392
4471
  due (PAYMENT_REQUIRED).
4393
4472
  """
4394
4473
  cashOnDeliveryEnabled: Boolean!
4474
+
4475
+ """
4476
+ URL of this shop's section on the central checkout app
4477
+ (`{checkoutHost}/shops/{handle}/checkout`). Theme sends the buyer to
4478
+ `{checkoutUrl}/begin` before minting a cart handoff, so the handoff is bound
4479
+ to the browser that started it.
4480
+ """
4481
+ checkoutUrl: String!
4395
4482
  city: String
4396
4483
 
4397
4484
  """
@@ -4432,8 +4519,8 @@ type Shop implements Node {
4432
4519
 
4433
4520
  """
4434
4521
  Whether this shop issues eBarimt fiscal receipts (a fiscal engine is enabled).
4435
- Buyer surfaces read this to decide whether to ask the Иргэн / Байгууллага
4436
- receipt question at checkout.
4522
+ Buyer surfaces and the register read this to decide whether to ask the Иргэн /
4523
+ Байгууллага receipt question at checkout.
4437
4524
  """
4438
4525
  issuesFiscalReceipts: Boolean!
4439
4526
  khoroo: AddressKhoroo
@@ -5061,7 +5148,18 @@ type ShopCheckout implements Node {
5061
5148
  availableDeliveryOptions: [ShopDeliveryOption!]!
5062
5149
  completedAt: ISO8601DateTime
5063
5150
  createdAt: ISO8601DateTime!
5151
+
5152
+ """
5153
+ The checkout's customer. Storefront callers get null without that customer's session.
5154
+ """
5064
5155
  customer: ShopCustomer
5156
+
5157
+ """
5158
+ What stands between the buyer and a delivery a courier prices (drop a pin, add
5159
+ a phone, or the courier is unavailable). Null when nothing does, or no
5160
+ shipping address is set yet.
5161
+ """
5162
+ deliveryCourierPrompt: CourierPromptEnum
5065
5163
  deliveryOptionHandle: String
5066
5164
 
5067
5165
  """
@@ -5597,6 +5695,11 @@ A storefront delivery option.
5597
5695
  interface ShopDeliveryOption {
5598
5696
  amount: Money!
5599
5697
 
5698
+ """
5699
+ False for a delivery a courier prices: the courier carries paid orders only, so the buyer pays online.
5700
+ """
5701
+ cashOnDeliveryAllowed: Boolean!
5702
+
5600
5703
  """
5601
5704
  Stable identifier passed back via deliveryOptionHandle to select this option.
5602
5705
  """
@@ -5861,6 +5964,13 @@ type ShopFiscalReceipt {
5861
5964
  which is the only one `ShopOrder.fiscalReceipt` ever returns.
5862
5965
  """
5863
5966
  supersededAt: ISO8601DateTime
5967
+
5968
+ """
5969
+ What the receipt declared to the tax authority — less than the order total
5970
+ when gift cards paid part of it. With the lottery number, it is what the buyer
5971
+ registers the receipt by hand with when the QR cannot be scanned. Null until issued.
5972
+ """
5973
+ totalAmount: Decimal
5864
5974
  updatedAt: ISO8601DateTime!
5865
5975
  }
5866
5976
 
@@ -6827,9 +6937,10 @@ type ShopOrder implements Node {
6827
6937
 
6828
6938
  """
6829
6939
  How the buyer is paying the balance — the oldest non-credit payment row’s
6830
- tender (`manual`, `cash_on_delivery`, `cody_pay`). Null when store credit /
6831
- gift cards covered the whole order and no such row exists. Buyer surfaces read
6832
- it to tell a cash-on-delivery order from a gateway one on the thanks page.
6940
+ tender (`manual`, `cash_on_delivery`, `cash`, `bank_card`, `bank_transfer`,
6941
+ `cody_pay`). Null when store credit / gift cards covered the whole order and
6942
+ no such row exists. Buyer surfaces read it to tell a cash-on-delivery order
6943
+ from a gateway one on the thanks page.
6833
6944
  """
6834
6945
  paymentMethod: String
6835
6946
  phone: String
@@ -7299,6 +7410,7 @@ type ShopPickupDeliveryOption implements ShopDeliveryOption {
7299
7410
  """
7300
7411
  addressLines: [String!]!
7301
7412
  amount: Money!
7413
+ cashOnDeliveryAllowed: Boolean!
7302
7414
  handle: String!
7303
7415
  instructions: String!
7304
7416
  kind: ShopDeliveryKindEnum!
@@ -8303,6 +8415,7 @@ A storefront ship delivery option (quote result).
8303
8415
  """
8304
8416
  type ShopShipDeliveryOption implements ShopDeliveryOption {
8305
8417
  amount: Money!
8418
+ cashOnDeliveryAllowed: Boolean!
8306
8419
  deliveryDetails: String
8307
8420
  handle: String!
8308
8421
  kind: ShopDeliveryKindEnum!
@@ -9099,10 +9212,29 @@ type ThemeEdge {
9099
9212
  }
9100
9213
 
9101
9214
  input ThemeFilter {
9215
+ """
9216
+ Bounds on the creation time.
9217
+ """
9218
+ createdAt: DateFilter
9102
9219
  handleCont: String
9103
9220
  priceEq: Float
9104
9221
  priceGteq: Float
9105
9222
  priceLteq: Float
9223
+
9224
+ """
9225
+ Match against the theme's name or its handle.
9226
+ """
9227
+ query: StringFilter
9228
+
9229
+ """
9230
+ Predicates on the status, keyed by its lowercase label ("draft", "published", "archived").
9231
+ """
9232
+ status: EnumStringFilter
9233
+
9234
+ """
9235
+ Bounds on the last-update time.
9236
+ """
9237
+ updatedAt: DateFilter
9106
9238
  }
9107
9239
 
9108
9240
  """
package/dist/shop.d.ts CHANGED
@@ -3,6 +3,7 @@ import { ResultOf } from "./graphql.js";
3
3
  declare const ShopQuery: import("gql.tada").TadaDocumentNode<{
4
4
  shop: {
5
5
  accountUrl: string;
6
+ checkoutUrl: string;
6
7
  currency: string;
7
8
  handle: string;
8
9
  id: string;
package/dist/shop.js CHANGED
@@ -18,6 +18,7 @@ const ShopQuery = graphql(`
18
18
  }
19
19
  turnstileSitekey
20
20
  accountUrl
21
+ checkoutUrl
21
22
  primaryDomain {
22
23
  fqdn
23
24
  turnstileSitekey
package/dist/trace.js ADDED
@@ -0,0 +1,5 @@
1
+ import { traceHeaders } from "@shop-on/customer-runtime/trace";
2
+ //#region src/trace.ts
3
+ const requestTraceHeaders = () => traceHeaders({ continueIncoming: true });
4
+ //#endregion
5
+ export { requestTraceHeaders };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shop-on/theme-server",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "license": "SEE LICENSE IN dist/LICENSE.md",
5
5
  "files": [
6
6
  "dist"
@@ -54,24 +54,24 @@
54
54
  "./package.json": "./package.json"
55
55
  },
56
56
  "dependencies": {
57
- "gql.tada": "1.11.3",
57
+ "gql.tada": "1.11.4",
58
58
  "graphql": "17.0.2",
59
59
  "graphql-request": "7.4.0",
60
- "liquidjs": "10.29.0",
61
- "@shop-on/customer-runtime": "0.1.0",
62
- "@shop-on/theme-runtime": "0.1.0"
60
+ "liquidjs": "10.30.0",
61
+ "@shop-on/customer-runtime": "0.3.0",
62
+ "@shop-on/theme-runtime": "0.3.0"
63
63
  },
64
64
  "devDependencies": {
65
- "@tanstack/react-start": "1.168.60",
66
- "@types/node": "26.6.3",
65
+ "@tanstack/react-start": "1.169.3",
66
+ "@types/node": "26.6.5",
67
67
  "typescript": "7.0.2",
68
- "vitest": "5.0.3"
68
+ "vitest": "5.0.5"
69
69
  },
70
70
  "peerDependencies": {
71
- "@tanstack/react-start": "~1.168.60"
71
+ "@tanstack/react-start": "~1.169.3"
72
72
  },
73
73
  "scripts": {
74
74
  "check-types": "tsc --noEmit",
75
- "test": "vitest run"
75
+ "test": "vitest run --maxWorkers=2"
76
76
  }
77
77
  }