Template
Initial commit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22Ms5Cxam5UHyxaCrtZzu
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
const FIRST_FORWARDED_IP_INDEX = 0;
|
||||
|
||||
export const BUYER_IP_HEADERS = ["oxygen-buyer-ip", "cf-connecting-ip", "x-forwarded-for"] as const;
|
||||
export const DEVELOPMENT_BUYER_IP = "127.0.0.1";
|
||||
|
||||
export function getBuyerIp(headers: Pick<Headers, "get">): string {
|
||||
for (const header of BUYER_IP_HEADERS) {
|
||||
const buyerIp = headers.get(header)?.split(",")[FIRST_FORWARDED_IP_INDEX]?.trim();
|
||||
if (buyerIp) return buyerIp;
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV !== "production") return DEVELOPMENT_BUYER_IP;
|
||||
|
||||
throw new Error(`${BUYER_IP_HEADERS.join(", ")} is required for private Storefront API clients`);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
type StorefrontConfigShape = {
|
||||
storeDomain: string;
|
||||
publicStorefrontToken?: string;
|
||||
privateStorefrontTokenEnvKey: PrivateStorefrontTokenEnvKey;
|
||||
};
|
||||
|
||||
type I18nShape = { country: "US"; language: "EN" };
|
||||
|
||||
type PrivateStorefrontTokenEnvKey =
|
||||
| "PRIVATE_STOREFRONT_API_TOKEN_HYDROGEN_PREVIEW"
|
||||
| "PRIVATE_STOREFRONT_API_TOKEN_OXYGEN_COOKIE";
|
||||
|
||||
type ShopifyScriptsShopShape = {
|
||||
shopId: string;
|
||||
storefrontId: string;
|
||||
};
|
||||
|
||||
type AnalyticsShopShape = {
|
||||
shopId: string;
|
||||
acceptedLanguage: string;
|
||||
currency: string;
|
||||
hydrogenSubchannelId: string;
|
||||
};
|
||||
|
||||
type AnalyticsConsentShape = {
|
||||
consentDomain?: string;
|
||||
publicStorefrontAccessToken?: string;
|
||||
mode: "default-banner" | "custom-banner" | "no-banner";
|
||||
};
|
||||
|
||||
export type CustomerAccountConfigShape = {
|
||||
shopId: string;
|
||||
customerAccountApiClientId: string;
|
||||
sessionSecret: string;
|
||||
};
|
||||
|
||||
// Local examples only. Production apps must use a private, per-app secret or server-side sessions.
|
||||
const LOCAL_CUSTOMER_SESSION_SECRET = "local-dev-customer-session-secret-32plus";
|
||||
|
||||
// Use this for consent testing
|
||||
const _oxygenCookiesStore = {
|
||||
storeDomain: "oxygencookies.myshopify.com",
|
||||
publicStorefrontToken: "8eece95833df895900c1b285987c7f40",
|
||||
privateStorefrontTokenEnvKey: "PRIVATE_STOREFRONT_API_TOKEN_OXYGEN_COOKIE",
|
||||
publicCustomerAccountApiClientId: "1156385c-3f73-484c-95cd-8900737d3fcf",
|
||||
publicStorefrontId: "1000070242",
|
||||
shopId: "56819351626",
|
||||
} as const;
|
||||
|
||||
const hydrogenPreviewStore = {
|
||||
storeDomain: "hydrogen-preview.myshopify.com",
|
||||
publicStorefrontToken: "b97a750a8afa8fe33f2b4012cb3a9f6f",
|
||||
privateStorefrontTokenEnvKey: "PRIVATE_STOREFRONT_API_TOKEN_HYDROGEN_PREVIEW",
|
||||
publicCustomerAccountApiClientId: "shp_e2b55f4e-9dd6-48aa-91f8-5fa419fda119",
|
||||
publicStorefrontId: "1000014875",
|
||||
shopId: "55145660472",
|
||||
} as const;
|
||||
|
||||
const ACTIVE_STORE = hydrogenPreviewStore;
|
||||
|
||||
export const storefrontConfig = {
|
||||
storeDomain: ACTIVE_STORE.storeDomain,
|
||||
publicStorefrontToken: ACTIVE_STORE.publicStorefrontToken,
|
||||
privateStorefrontTokenEnvKey: ACTIVE_STORE.privateStorefrontTokenEnvKey,
|
||||
} satisfies StorefrontConfigShape;
|
||||
|
||||
export const customerAccountConfig = {
|
||||
shopId: ACTIVE_STORE.shopId,
|
||||
customerAccountApiClientId: ACTIVE_STORE.publicCustomerAccountApiClientId,
|
||||
sessionSecret: LOCAL_CUSTOMER_SESSION_SECRET,
|
||||
} satisfies CustomerAccountConfigShape;
|
||||
|
||||
export const defaultI18n = {
|
||||
country: "US",
|
||||
language: "EN",
|
||||
} satisfies I18nShape;
|
||||
|
||||
export const shop = {
|
||||
shopId: ACTIVE_STORE.shopId,
|
||||
storefrontId: ACTIVE_STORE.publicStorefrontId,
|
||||
} satisfies ShopifyScriptsShopShape;
|
||||
|
||||
export const analyticsShop = {
|
||||
shopId: `gid://shopify/Shop/${ACTIVE_STORE.shopId}`,
|
||||
acceptedLanguage: "EN",
|
||||
currency: "USD",
|
||||
hydrogenSubchannelId: ACTIVE_STORE.publicStorefrontId,
|
||||
} satisfies AnalyticsShopShape;
|
||||
|
||||
export const analyticsConsent = {
|
||||
mode: "default-banner",
|
||||
publicStorefrontAccessToken: ACTIVE_STORE.publicStorefrontToken,
|
||||
} satisfies AnalyticsConsentShape;
|
||||
@@ -0,0 +1,190 @@
|
||||
const COOKIE_NAME = "__Host-hydrogen_customer_session";
|
||||
const COOKIE_PATH = "/";
|
||||
const COOKIE_VERSION = "v1";
|
||||
const COOKIE_SEPARATOR = ".";
|
||||
const AES_GCM_ALGORITHM = "AES-GCM";
|
||||
const AES_GCM_IV_LENGTH_IN_BYTES = 12;
|
||||
const SECRET_MIN_LENGTH = 32;
|
||||
const MAX_COOKIE_LENGTH_IN_BYTES = 4_096;
|
||||
const BYTE_CHUNK_SIZE = 32_768;
|
||||
const EXPIRED_COOKIE_DATE = "Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
|
||||
type SessionRecord = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Portable encrypted cookie session for these examples. Production apps should
|
||||
* prefer opaque server-side session storage when their framework offers it.
|
||||
*/
|
||||
export class EncryptedCookieCustomerSession {
|
||||
#data: SessionRecord;
|
||||
#origin: string;
|
||||
#secret: string;
|
||||
#isDirty = false;
|
||||
|
||||
private constructor(data: SessionRecord, origin: string, secret: string) {
|
||||
this.#data = data;
|
||||
this.#origin = origin;
|
||||
this.#secret = secret;
|
||||
}
|
||||
|
||||
static async init(request: Request, secret: string) {
|
||||
assertSessionSecret(secret);
|
||||
const cookieValue = getCookieValue(request.headers.get("cookie"), COOKIE_NAME);
|
||||
const data = cookieValue ? await decryptSessionCookie(cookieValue, secret) : {};
|
||||
return new EncryptedCookieCustomerSession(data, new URL(request.url).origin, secret);
|
||||
}
|
||||
|
||||
getSessionItem(key: string) {
|
||||
return this.#data[key];
|
||||
}
|
||||
|
||||
getSessionOrigin() {
|
||||
return this.#origin;
|
||||
}
|
||||
|
||||
setSessionItem(key: string, value: unknown) {
|
||||
this.#data[key] = value;
|
||||
this.#isDirty = true;
|
||||
}
|
||||
|
||||
removeSessionItem(key: string) {
|
||||
delete this.#data[key];
|
||||
this.#isDirty = true;
|
||||
}
|
||||
|
||||
async commit() {
|
||||
if (!this.#isDirty) return;
|
||||
|
||||
const headers = new Headers();
|
||||
const cookie = hasSessionData(this.#data)
|
||||
? await serializeSessionCookie(await encryptSessionCookie(this.#data, this.#secret))
|
||||
: serializeExpiredSessionCookie();
|
||||
assertCookieFitsBrowserLimit(cookie);
|
||||
|
||||
headers.set("Set-Cookie", cookie);
|
||||
this.#isDirty = false;
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
||||
async function encryptSessionCookie(data: SessionRecord, secret: string): Promise<string> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(AES_GCM_IV_LENGTH_IN_BYTES));
|
||||
const key = await deriveKey(secret);
|
||||
const plaintext = new TextEncoder().encode(JSON.stringify(data));
|
||||
const ciphertext = new Uint8Array(
|
||||
await crypto.subtle.encrypt({ name: AES_GCM_ALGORITHM, iv }, key, plaintext),
|
||||
);
|
||||
|
||||
return [COOKIE_VERSION, base64UrlEncodeBytes(iv), base64UrlEncodeBytes(ciphertext)].join(
|
||||
COOKIE_SEPARATOR,
|
||||
);
|
||||
}
|
||||
|
||||
async function decryptSessionCookie(value: string, secret: string): Promise<SessionRecord> {
|
||||
try {
|
||||
const [version, iv, ciphertext] = value.split(COOKIE_SEPARATOR);
|
||||
if (version !== COOKIE_VERSION || !iv || !ciphertext) return {};
|
||||
|
||||
const ivBytes = base64UrlDecodeBytes(iv);
|
||||
const ciphertextBytes = base64UrlDecodeBytes(ciphertext);
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: AES_GCM_ALGORITHM, iv: toArrayBuffer(ivBytes) },
|
||||
await deriveKey(secret),
|
||||
toArrayBuffer(ciphertextBytes),
|
||||
);
|
||||
const parsed = JSON.parse(new TextDecoder().decode(decrypted));
|
||||
return isSessionRecord(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function deriveKey(secret: string): Promise<CryptoKey> {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(secret));
|
||||
return crypto.subtle.importKey("raw", digest, AES_GCM_ALGORITHM, false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
async function serializeSessionCookie(value: string): Promise<string> {
|
||||
return serializeCookie(COOKIE_NAME, value, [
|
||||
`Path=${COOKIE_PATH}`,
|
||||
"HttpOnly",
|
||||
"Secure",
|
||||
"SameSite=Lax",
|
||||
]);
|
||||
}
|
||||
|
||||
function serializeExpiredSessionCookie(): string {
|
||||
return serializeCookie(COOKIE_NAME, "", [
|
||||
`Path=${COOKIE_PATH}`,
|
||||
"HttpOnly",
|
||||
"Secure",
|
||||
"SameSite=Lax",
|
||||
"Max-Age=0",
|
||||
`Expires=${EXPIRED_COOKIE_DATE}`,
|
||||
]);
|
||||
}
|
||||
|
||||
function serializeCookie(name: string, value: string, attributes: string[]) {
|
||||
return [`${name}=${value}`, ...attributes].join("; ");
|
||||
}
|
||||
|
||||
function assertCookieFitsBrowserLimit(cookie: string) {
|
||||
if (new TextEncoder().encode(cookie).byteLength <= MAX_COOKIE_LENGTH_IN_BYTES) return;
|
||||
|
||||
throw new Error(
|
||||
`Customer Account session cookie exceeds ${MAX_COOKIE_LENGTH_IN_BYTES} bytes. Use opaque server-side session storage for production account sessions.`,
|
||||
);
|
||||
}
|
||||
|
||||
function getCookieValue(cookieHeader: string | null, name: string): string | undefined {
|
||||
if (!cookieHeader) return undefined;
|
||||
|
||||
for (const cookie of cookieHeader.split(";")) {
|
||||
const [cookieName, ...valueParts] = cookie.trim().split("=");
|
||||
if (cookieName === name) return valueParts.join("=");
|
||||
}
|
||||
}
|
||||
|
||||
function base64UrlEncodeBytes(bytes: Uint8Array): string {
|
||||
const binary = bytesToBinary(bytes);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||
}
|
||||
|
||||
function base64UrlDecodeBytes(value: string): Uint8Array {
|
||||
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const paddingLength = (4 - (base64.length % 4)) % 4;
|
||||
const binary = atob(base64.padEnd(base64.length + paddingLength, "="));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
const buffer = new ArrayBuffer(bytes.byteLength);
|
||||
new Uint8Array(buffer).set(bytes);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function bytesToBinary(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (let index = 0; index < bytes.length; index += BYTE_CHUNK_SIZE) {
|
||||
binary += String.fromCharCode(...bytes.slice(index, index + BYTE_CHUNK_SIZE));
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
function hasSessionData(data: SessionRecord) {
|
||||
return Object.keys(data).length > 0;
|
||||
}
|
||||
|
||||
function isSessionRecord(value: unknown): value is SessionRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertSessionSecret(secret: string) {
|
||||
if (secret.length < SECRET_MIN_LENGTH) {
|
||||
throw new Error(`SESSION_SECRET must be at least ${SECRET_MIN_LENGTH} characters long.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const PRIVATE_STOREFRONT_TOKEN_KEY = "PRIVATE_STOREFRONT_API_TOKEN";
|
||||
|
||||
type RuntimeEnv = Record<string, string | undefined>;
|
||||
|
||||
export function getPrivateStorefrontToken(env?: object | null): string {
|
||||
return getSharedSecret(PRIVATE_STOREFRONT_TOKEN_KEY, env);
|
||||
}
|
||||
|
||||
export function getSharedSecret(key: string, env?: object | null): string {
|
||||
const value = getOptionalSharedSecret(key, env);
|
||||
if (!value) {
|
||||
throw new Error(
|
||||
`${key} is required. ` +
|
||||
`Set it in .env.local or in the environment.`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getOptionalSharedSecret(key: string, env?: object | null): string | undefined {
|
||||
const value = getRuntimeEnv(env)[key];
|
||||
return isPresentString(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function getRuntimeEnv(env?: object | null): RuntimeEnv {
|
||||
const globalEnv = (
|
||||
globalThis as {
|
||||
process?: { env?: RuntimeEnv };
|
||||
}
|
||||
).process?.env;
|
||||
|
||||
const metaEnv = (import.meta as ImportMeta & { env?: RuntimeEnv }).env;
|
||||
const explicitEnv = env as RuntimeEnv | undefined;
|
||||
|
||||
return { ...metaEnv, ...globalEnv, ...explicitEnv };
|
||||
}
|
||||
|
||||
function isPresentString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
Reference in New Issue
Block a user