@shop-on/theme-server 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/account-state.js +55 -0
- package/dist/account.js +6 -1
- package/dist/availability.d.ts +10 -0
- package/dist/availability.js +59 -0
- package/dist/cart-session.js +6 -16
- package/dist/checkout.d.ts +12 -11
- package/dist/checkout.js +64 -11
- package/dist/collection.d.ts +1 -0
- package/dist/collection.js +1 -0
- package/dist/cookie-secret.js +10 -0
- package/dist/customer-handoff.d.ts +1 -1
- package/dist/customer-handoff.js +15 -3
- package/dist/customer-session.js +6 -16
- package/dist/files.d.ts +1 -0
- package/dist/files.js +39 -28
- package/dist/graphql-env.d.ts +36 -0
- package/dist/password-session.js +2 -7
- package/dist/schema.storefront.graphql +57 -0
- package/dist/shop.d.ts +2 -0
- package/dist/shop.js +2 -0
- package/package.json +4 -3
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
//#region src/availability.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Request middleware: every theme registers it in `src/start.ts` after
|
|
4
|
+
* `storefrontActivityMiddleware`, which answers `/health` before it. A closed
|
|
5
|
+
* or suspended store (see docs/shop-lifecycle.md) serves one uncacheable,
|
|
6
|
+
* unindexed 503 page in place of every route; the shop record is the
|
|
7
|
+
* memoised one the root loader reads, so this costs no extra request.
|
|
8
|
+
*/
|
|
9
|
+
export declare const storefrontAvailabilityMiddleware: import("@tanstack/react-start").RequestMiddlewareAfterServer<{}, undefined, undefined>;
|
|
10
|
+
//#endregion
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { fetchShopInfo } from "./shop.js";
|
|
2
|
+
import { escapeHtmlAttr } from "./html.js";
|
|
3
|
+
import { createMiddleware } from "@tanstack/react-start";
|
|
4
|
+
//#region src/availability.ts
|
|
5
|
+
/**
|
|
6
|
+
* Request middleware: every theme registers it in `src/start.ts` after
|
|
7
|
+
* `storefrontActivityMiddleware`, which answers `/health` before it. A closed
|
|
8
|
+
* or suspended store (see docs/shop-lifecycle.md) serves one uncacheable,
|
|
9
|
+
* unindexed 503 page in place of every route; the shop record is the
|
|
10
|
+
* memoised one the root loader reads, so this costs no extra request.
|
|
11
|
+
*/
|
|
12
|
+
const storefrontAvailabilityMiddleware = createMiddleware().server(async ({ next }) => {
|
|
13
|
+
let shop;
|
|
14
|
+
try {
|
|
15
|
+
shop = await fetchShopInfo();
|
|
16
|
+
} catch {
|
|
17
|
+
return next();
|
|
18
|
+
}
|
|
19
|
+
if (!shop || shop.status === "active") return next();
|
|
20
|
+
return unavailableResponse(shop.name);
|
|
21
|
+
});
|
|
22
|
+
function unavailableResponse(shopName) {
|
|
23
|
+
const name = escapeHtmlAttr(shopName);
|
|
24
|
+
const html = `<!doctype html>
|
|
25
|
+
<html lang="mn">
|
|
26
|
+
<head>
|
|
27
|
+
<meta charset="utf-8">
|
|
28
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
29
|
+
<meta name="robots" content="noindex">
|
|
30
|
+
<title>${name}</title>
|
|
31
|
+
<style>
|
|
32
|
+
:root{color-scheme:light dark;--bg:#fafafa;--fg:#171717;--muted:#737373}
|
|
33
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0a0a0a;--fg:#fafafa;--muted:#a3a3a3}}
|
|
34
|
+
body{margin:0;min-height:100svh;display:grid;place-items:center;padding:24px;box-sizing:border-box;background:var(--bg);color:var(--fg);font:16px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;text-align:center}
|
|
35
|
+
main{max-width:28rem}
|
|
36
|
+
h1{margin:0 0 12px;font-size:1.25rem;font-weight:600}
|
|
37
|
+
p{margin:4px 0;color:var(--muted)}
|
|
38
|
+
</style>
|
|
39
|
+
</head>
|
|
40
|
+
<body>
|
|
41
|
+
<main>
|
|
42
|
+
<h1>${name}</h1>
|
|
43
|
+
<p>Энэ дэлгүүр одоогоор түр хаалттай байна.</p>
|
|
44
|
+
<p lang="en">This store is currently unavailable.</p>
|
|
45
|
+
</main>
|
|
46
|
+
</body>
|
|
47
|
+
</html>
|
|
48
|
+
`;
|
|
49
|
+
return new Response(html, {
|
|
50
|
+
status: 503,
|
|
51
|
+
headers: {
|
|
52
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
53
|
+
"Cache-Control": "no-store",
|
|
54
|
+
"X-Robots-Tag": "noindex"
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
59
|
+
export { storefrontAvailabilityMiddleware };
|
package/dist/cart-session.js
CHANGED
|
@@ -1,25 +1,15 @@
|
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
15
|
const { openSession } = await import("@shop-on/customer-runtime/sealed-session");
|
package/dist/checkout.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
30
|
-
* the
|
|
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
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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
|
|
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:
|
|
115
|
+
redirectTo: bound.toString()
|
|
63
116
|
};
|
|
64
117
|
}
|
|
65
118
|
//#endregion
|
package/dist/collection.d.ts
CHANGED
|
@@ -184,6 +184,7 @@ export declare const CollectionImageFragment: import("gql.tada").TadaDocumentNod
|
|
|
184
184
|
thumbUrl: string | null;
|
|
185
185
|
thumbhash: string | null;
|
|
186
186
|
url: string | null;
|
|
187
|
+
url1_5x: string | null;
|
|
187
188
|
url2x: string | null;
|
|
188
189
|
url3x: string | null;
|
|
189
190
|
width: number | null;
|
package/dist/collection.js
CHANGED
|
@@ -134,6 +134,7 @@ const CollectionImageFragment = graphql(`
|
|
|
134
134
|
thumbhash
|
|
135
135
|
... on ShopMediaImage {
|
|
136
136
|
url(transform: { maxWidth: 640 })
|
|
137
|
+
url1_5x: url(transform: { maxWidth: 960 })
|
|
137
138
|
url2x: url(transform: { maxWidth: 640, scale: 2 })
|
|
138
139
|
url3x: url(transform: { maxWidth: 640, scale: 3 })
|
|
139
140
|
thumbUrl: url(transform: { maxWidth: 80 })
|
|
@@ -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
|
package/dist/customer-handoff.js
CHANGED
|
@@ -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) {
|
package/dist/customer-session.js
CHANGED
|
@@ -1,25 +1,15 @@
|
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
15
|
const { openSession } = await import("@shop-on/customer-runtime/sealed-session");
|
package/dist/files.d.ts
CHANGED
package/dist/files.js
CHANGED
|
@@ -6,6 +6,7 @@ const FileByIdQuery = graphql(`
|
|
|
6
6
|
query FileById(
|
|
7
7
|
$id: ID!
|
|
8
8
|
$transform: ImageTransformInput
|
|
9
|
+
$transform1_5x: ImageTransformInput
|
|
9
10
|
$transform2x: ImageTransformInput
|
|
10
11
|
$transform3x: ImageTransformInput
|
|
11
12
|
) {
|
|
@@ -18,6 +19,7 @@ const FileByIdQuery = graphql(`
|
|
|
18
19
|
width
|
|
19
20
|
height
|
|
20
21
|
url(transform: $transform)
|
|
22
|
+
url1_5x: url(transform: $transform1_5x)
|
|
21
23
|
url2x: url(transform: $transform2x)
|
|
22
24
|
url3x: url(transform: $transform3x)
|
|
23
25
|
focalPoint {
|
|
@@ -50,23 +52,30 @@ function buildSrcset(entries) {
|
|
|
50
52
|
}
|
|
51
53
|
return parts.length > 1 ? parts.join(", ") : null;
|
|
52
54
|
}
|
|
55
|
+
function ladderSrcset(node, baseWidth) {
|
|
56
|
+
return buildSrcset([
|
|
57
|
+
{
|
|
58
|
+
url: node.url,
|
|
59
|
+
w: baseWidth
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
url: node.url1_5x,
|
|
63
|
+
w: Math.round(baseWidth * 1.5)
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
url: node.url2x,
|
|
67
|
+
w: baseWidth * 2
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
url: node.url3x,
|
|
71
|
+
w: baseWidth * 3
|
|
72
|
+
}
|
|
73
|
+
]);
|
|
74
|
+
}
|
|
53
75
|
function toFileInfo(node, extra = {}) {
|
|
54
76
|
if (!node) return null;
|
|
55
77
|
if (node.__typename === "ShopMediaImage") {
|
|
56
|
-
const computedSrcset = extra.baseWidth != null ?
|
|
57
|
-
{
|
|
58
|
-
url: node.url,
|
|
59
|
-
w: extra.baseWidth
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
url: node.url2x,
|
|
63
|
-
w: extra.baseWidth * 2
|
|
64
|
-
},
|
|
65
|
-
{
|
|
66
|
-
url: node.url3x,
|
|
67
|
-
w: extra.baseWidth * 3
|
|
68
|
-
}
|
|
69
|
-
]) : null;
|
|
78
|
+
const computedSrcset = extra.baseWidth != null ? ladderSrcset(node, extra.baseWidth) : null;
|
|
70
79
|
return {
|
|
71
80
|
kind: "image",
|
|
72
81
|
id: node.id ?? "",
|
|
@@ -106,29 +115,31 @@ function scaled(t, scale) {
|
|
|
106
115
|
scale
|
|
107
116
|
};
|
|
108
117
|
}
|
|
118
|
+
function widened(t) {
|
|
119
|
+
if (!t?.maxWidth) return null;
|
|
120
|
+
const { scale: _, ...rest } = t;
|
|
121
|
+
return {
|
|
122
|
+
...rest,
|
|
123
|
+
maxWidth: Math.round(t.maxWidth * 1.5),
|
|
124
|
+
...t.maxHeight ? { maxHeight: Math.round(t.maxHeight * 1.5) } : {}
|
|
125
|
+
};
|
|
126
|
+
}
|
|
109
127
|
const fetchFileById = memoTTL(async ({ id, transform }) => {
|
|
110
128
|
const node = (await request(FileByIdQuery, {
|
|
111
129
|
id,
|
|
112
130
|
transform: transform ?? null,
|
|
131
|
+
transform1_5x: widened(transform),
|
|
113
132
|
transform2x: scaled(transform, 2),
|
|
114
133
|
transform3x: scaled(transform, 3)
|
|
115
134
|
})).node;
|
|
116
135
|
if (!node) return null;
|
|
117
136
|
const baseWidth = transform?.maxWidth;
|
|
118
|
-
return toFileInfo(node, { srcset: node.__typename === "ShopMediaImage" && baseWidth != null ?
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
url: node.url2x,
|
|
125
|
-
w: baseWidth * 2
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
url: node.url3x,
|
|
129
|
-
w: baseWidth * 3
|
|
130
|
-
}
|
|
131
|
-
]) : null });
|
|
137
|
+
return toFileInfo(node, { srcset: node.__typename === "ShopMediaImage" && baseWidth != null ? ladderSrcset({
|
|
138
|
+
url: node.url,
|
|
139
|
+
url1_5x: node.url1_5x,
|
|
140
|
+
url2x: node.url2x,
|
|
141
|
+
url3x: node.url3x
|
|
142
|
+
}, baseWidth) : null });
|
|
132
143
|
}, {
|
|
133
144
|
key: ({ id, transform }) => `${id}|${transform ? JSON.stringify(transform) : ""}`,
|
|
134
145
|
version: getContentVersion
|
package/dist/graphql-env.d.ts
CHANGED
|
@@ -2810,6 +2810,14 @@ export type introspection_types = {
|
|
|
2810
2810
|
};
|
|
2811
2811
|
};
|
|
2812
2812
|
defaultValue: null;
|
|
2813
|
+
}, {
|
|
2814
|
+
name: 'retryKey';
|
|
2815
|
+
type: {
|
|
2816
|
+
kind: 'SCALAR';
|
|
2817
|
+
name: 'String';
|
|
2818
|
+
ofType: null;
|
|
2819
|
+
};
|
|
2820
|
+
defaultValue: null;
|
|
2813
2821
|
}];
|
|
2814
2822
|
};
|
|
2815
2823
|
'CheckoutRecoverPayload': {
|
|
@@ -2854,6 +2862,10 @@ export type introspection_types = {
|
|
|
2854
2862
|
};
|
|
2855
2863
|
};
|
|
2856
2864
|
};
|
|
2865
|
+
'CheckoutRecoveryStatusEnum': {
|
|
2866
|
+
name: 'CheckoutRecoveryStatusEnum';
|
|
2867
|
+
enumValues: 'EXPIRED' | 'INVALID' | 'VALID';
|
|
2868
|
+
};
|
|
2857
2869
|
'CheckoutShippingAddress': {
|
|
2858
2870
|
kind: 'OBJECT';
|
|
2859
2871
|
name: 'CheckoutShippingAddress';
|
|
@@ -7185,6 +7197,18 @@ export type introspection_types = {
|
|
|
7185
7197
|
ofType: null;
|
|
7186
7198
|
};
|
|
7187
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
|
+
};
|
|
7188
7212
|
'collection': {
|
|
7189
7213
|
name: 'collection';
|
|
7190
7214
|
type: {
|
|
@@ -8745,6 +8769,18 @@ export type introspection_types = {
|
|
|
8745
8769
|
};
|
|
8746
8770
|
};
|
|
8747
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
|
+
};
|
|
8748
8784
|
'city': {
|
|
8749
8785
|
name: 'city';
|
|
8750
8786
|
type: {
|
package/dist/password-session.js
CHANGED
|
@@ -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
|
|
8
|
+
password: await derivedCookieSecret(DOMAIN_LABEL),
|
|
14
9
|
name: "storefront_unlock",
|
|
15
10
|
maxAge: TTL_SECONDS,
|
|
16
11
|
cookie: {
|
|
@@ -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
|
"""
|
|
@@ -939,6 +943,12 @@ input CheckoutRecoverInput {
|
|
|
939
943
|
"""
|
|
940
944
|
clientMutationId: String
|
|
941
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
|
|
942
952
|
}
|
|
943
953
|
|
|
944
954
|
"""
|
|
@@ -954,6 +964,26 @@ type CheckoutRecoverPayload {
|
|
|
954
964
|
userErrors: [UserError!]!
|
|
955
965
|
}
|
|
956
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
|
+
|
|
957
987
|
"""
|
|
958
988
|
Buyer-facing snapshot of the shipping address on a checkout.
|
|
959
989
|
"""
|
|
@@ -2436,6 +2466,9 @@ type Mutation {
|
|
|
2436
2466
|
|
|
2437
2467
|
"""
|
|
2438
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.
|
|
2439
2472
|
"""
|
|
2440
2473
|
cartCheckout(
|
|
2441
2474
|
"""
|
|
@@ -3272,6 +3305,18 @@ type Query {
|
|
|
3272
3305
|
"""
|
|
3273
3306
|
checkout(checkoutToken: String!): ShopCheckout
|
|
3274
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
|
+
|
|
3275
3320
|
"""
|
|
3276
3321
|
Find a collection by ID or handle
|
|
3277
3322
|
"""
|
|
@@ -4426,6 +4471,14 @@ type Shop implements Node {
|
|
|
4426
4471
|
due (PAYMENT_REQUIRED).
|
|
4427
4472
|
"""
|
|
4428
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!
|
|
4429
4482
|
city: String
|
|
4430
4483
|
|
|
4431
4484
|
"""
|
|
@@ -5095,6 +5148,10 @@ type ShopCheckout implements Node {
|
|
|
5095
5148
|
availableDeliveryOptions: [ShopDeliveryOption!]!
|
|
5096
5149
|
completedAt: ISO8601DateTime
|
|
5097
5150
|
createdAt: ISO8601DateTime!
|
|
5151
|
+
|
|
5152
|
+
"""
|
|
5153
|
+
The checkout's customer. Storefront callers get null without that customer's session.
|
|
5154
|
+
"""
|
|
5098
5155
|
customer: ShopCustomer
|
|
5099
5156
|
|
|
5100
5157
|
"""
|
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;
|
|
@@ -26,6 +27,7 @@ declare const ShopQuery: import("gql.tada").TadaDocumentNode<{
|
|
|
26
27
|
turnstileSitekey: string | null;
|
|
27
28
|
} | null;
|
|
28
29
|
primaryLocale: string;
|
|
30
|
+
status: string;
|
|
29
31
|
taxesIncluded: boolean;
|
|
30
32
|
timezone: string;
|
|
31
33
|
turnstileSitekey: string | null;
|
package/dist/shop.js
CHANGED
|
@@ -8,6 +8,7 @@ const ShopQuery = graphql(`
|
|
|
8
8
|
id
|
|
9
9
|
name
|
|
10
10
|
handle
|
|
11
|
+
status
|
|
11
12
|
currency
|
|
12
13
|
timezone
|
|
13
14
|
primaryLocale
|
|
@@ -18,6 +19,7 @@ const ShopQuery = graphql(`
|
|
|
18
19
|
}
|
|
19
20
|
turnstileSitekey
|
|
20
21
|
accountUrl
|
|
22
|
+
checkoutUrl
|
|
21
23
|
primaryDomain {
|
|
22
24
|
fqdn
|
|
23
25
|
turnstileSitekey
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shop-on/theme-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"license": "SEE LICENSE IN dist/LICENSE.md",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist"
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"./activity": "./dist/activity.js",
|
|
12
12
|
"./analytics-proxy": "./dist/analytics-proxy.js",
|
|
13
13
|
"./analytics-script-proxy": "./dist/analytics-script-proxy.js",
|
|
14
|
+
"./availability": "./dist/availability.js",
|
|
14
15
|
"./blog": "./dist/blog.js",
|
|
15
16
|
"./cache": "./dist/cache.js",
|
|
16
17
|
"./canonical-host": "./dist/canonical-host.js",
|
|
@@ -58,8 +59,8 @@
|
|
|
58
59
|
"graphql": "17.0.2",
|
|
59
60
|
"graphql-request": "7.4.0",
|
|
60
61
|
"liquidjs": "10.30.0",
|
|
61
|
-
"@shop-on/
|
|
62
|
-
"@shop-on/
|
|
62
|
+
"@shop-on/customer-runtime": "0.4.0",
|
|
63
|
+
"@shop-on/theme-runtime": "0.4.0"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
65
66
|
"@tanstack/react-start": "1.169.3",
|