export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: corsHeaders() });
}
if (url.pathname === '/api/shopify' && request.method === 'POST') {
return handleShopify(request, url, env, ctx);
}
return new Response('Not found', { status: 404, headers: corsHeaders() });
}
};
async function handleShopify(request, url, env, ctx) {
const startMs = Date.now();
const hostname = request.headers.get('X-Storefront-Hostname')
|| url.searchParams.get('hostname')
|| url.hostname;
let storefrontId = null;
let hostnameId = null;
let row = null;
let hnKvStatus = 'miss';
let sfKvStatus = 'miss';
try {
// 1. Resolve hostname → StorefrontId via KV, fallback to D1
const hnKey = `hn:${hostname}`;
const hnCached = await env.KV_SHOPIFY.get(hnKey);
if (hnCached) {
const parsed = JSON.parse(hnCached);
storefrontId = parsed.StorefrontId;
hostnameId = parsed.HostnameId ?? null;
hnKvStatus = 'hit';
} else {
const hnRow = await env.D1_SHOPIFY.prepare(
`SELECT "HostnameId", "StorefrontId" FROM hostname WHERE "Hostname" = ?1`
).bind(hostname).first();
if (!hnRow) {
const resp = errResponse(`Hostname '${hostname}' not found. Run Sync KV.`, 404);
ctx.waitUntil(writeAudit(env, {
EventType: 'proxy_request', EventStatus: 'error',
RequestMethod: request.method, RequestPath: url.pathname, RequestHostname: hostname,
HttpStatusCode: 404, DurationMs: Date.now() - startMs,
KvLookupStatus: 'miss', D1LookupStatus: 'miss', CacheStatus: 'miss',
CfRay: request.headers.get('CF-Ray'), Colo: request.cf?.colo,
ErrorCode: 'HOSTNAME_NOT_FOUND', ErrorMessage: `Hostname '${hostname}' not found`,
DateCreatedUtc: new Date().toISOString(),
}));
return resp;
}
storefrontId = hnRow.StorefrontId;
hostnameId = hnRow.HostnameId;
await env.KV_SHOPIFY.put(hnKey, JSON.stringify({ StorefrontId: storefrontId, HostnameId: hostnameId }));
}
// 2. Resolve StorefrontId → storefront row via KV, fallback to D1
const kvKey = `sf:${storefrontId}`;
const cached = await env.KV_SHOPIFY.get(kvKey);
if (cached) {
row = JSON.parse(cached);
sfKvStatus = 'hit';
} else {
row = await env.D1_SHOPIFY.prepare(`
SELECT "StorefrontId", "MyShopifyDomain", "ShopifyApiVersion",
"CiphertextBase64", "DataNonceBase64", "DataAuthTagBase64",
"EncryptedDekBase64", "WrapNonceBase64", "WrapAuthTagBase64"
FROM storefront WHERE "StorefrontId" = ?1
`).bind(parseInt(storefrontId)).first();
if (!row) {
const resp = errResponse(`Storefront ${storefrontId} not found. Run Sync KV.`, 404);
ctx.waitUntil(writeAudit(env, {
StorefrontId: storefrontId, HostnameId: hostnameId,
EventType: 'proxy_request', EventStatus: 'error',
RequestMethod: request.method, RequestPath: url.pathname, RequestHostname: hostname,
HttpStatusCode: 404, DurationMs: Date.now() - startMs,
KvLookupStatus: hnKvStatus, D1LookupStatus: 'miss', CacheStatus: 'd1_fallback',
CfRay: request.headers.get('CF-Ray'), Colo: request.cf?.colo,
ErrorCode: 'STOREFRONT_NOT_FOUND', ErrorMessage: `Storefront ${storefrontId} not found`,
DateCreatedUtc: new Date().toISOString(),
}));
return resp;
}
await env.KV_SHOPIFY.put(kvKey, JSON.stringify(row));
}
// 3. Decrypt SAT inline with Web Crypto
const kek = await env.KEK_SHOPIFY_TOKEN.get();
if (!kek) {
const resp = errResponse('KEK_SHOPIFY_TOKEN not found in Secrets Store', 500);
ctx.waitUntil(writeAudit(env, {
StorefrontId: storefrontId, HostnameId: hostnameId,
EventType: 'proxy_request', EventStatus: 'error',
RequestMethod: request.method, RequestPath: url.pathname, RequestHostname: hostname,
HttpStatusCode: 500, DurationMs: Date.now() - startMs,
KvLookupStatus: hnKvStatus, D1LookupStatus: hnKvStatus === 'miss' || sfKvStatus === 'miss' ? 'hit' : null,
CacheStatus: hnKvStatus === 'hit' && sfKvStatus === 'hit' ? 'kv_hit' : 'd1_fallback',
SecretLookupStatus: 'missing',
CfRay: request.headers.get('CF-Ray'), Colo: request.cf?.colo,
ErrorCode: 'KEK_MISSING', ErrorMessage: 'KEK_SHOPIFY_TOKEN not set',
DateCreatedUtc: new Date().toISOString(),
}));
return resp;
}
const token = await decryptEnvelope(kek, row);
// 4. Forward request body to Shopify Storefront API
const apiVersion = row.ShopifyApiVersion || latestShopifyApiVersion();
const shopifyUrl = `https://${row.MyShopifyDomain}/api/${apiVersion}/graphql.json`;
const shopifyResp = await fetch(shopifyUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Shopify-Storefront-Private-Token': token,
},
body: request.body,
});
const durationMs = Date.now() - startMs;
const cacheStatus = hnKvStatus === 'hit' && sfKvStatus === 'hit' ? 'kv_hit' : 'd1_fallback';
const d1Status = hnKvStatus === 'miss' || sfKvStatus === 'miss' ? 'hit' : null;
if (!shopifyResp.ok) {
const errBody = await shopifyResp.text();
ctx.waitUntil(writeAudit(env, {
StorefrontId: storefrontId, HostnameId: hostnameId,
EventType: 'proxy_request', EventStatus: 'error',
RequestMethod: request.method, RequestPath: url.pathname, RequestHostname: hostname,
ShopifyApiVersion: apiVersion, MyShopifyDomain: row.MyShopifyDomain,
HttpStatusCode: shopifyResp.status, DurationMs: durationMs,
KvLookupStatus: hnKvStatus, D1LookupStatus: d1Status, CacheStatus: cacheStatus,
SecretLookupStatus: 'found',
CfRay: request.headers.get('CF-Ray'), Colo: request.cf?.colo,
ErrorCode: `SHOPIFY_HTTP_${shopifyResp.status}`,
ErrorMessage: errBody.substring(0, 500),
DateCreatedUtc: new Date().toISOString(),
}));
return errResponse(`Shopify HTTP ${shopifyResp.status}: ${errBody}`, 502);
}
const data = await shopifyResp.json();
ctx.waitUntil(writeAudit(env, {
StorefrontId: storefrontId, HostnameId: hostnameId,
EventType: 'proxy_request', EventStatus: 'success',
RequestMethod: request.method, RequestPath: url.pathname, RequestHostname: hostname,
ShopifyApiVersion: apiVersion, MyShopifyDomain: row.MyShopifyDomain,
HttpStatusCode: 200, DurationMs: durationMs,
KvLookupStatus: hnKvStatus, D1LookupStatus: d1Status, CacheStatus: cacheStatus,
SecretLookupStatus: 'found',
CfRay: request.headers.get('CF-Ray'), Colo: request.cf?.colo,
DateCreatedUtc: new Date().toISOString(),
}));
return new Response(JSON.stringify(data), {
headers: { 'Content-Type': 'application/json', ...corsHeaders() }
});
} catch (err) {
ctx.waitUntil(writeAudit(env, {
StorefrontId: storefrontId, HostnameId: hostnameId,
EventType: 'proxy_request', EventStatus: 'error',
RequestMethod: request.method, RequestPath: url.pathname, RequestHostname: hostname,
HttpStatusCode: 500, DurationMs: Date.now() - startMs,
KvLookupStatus: hnKvStatus, CacheStatus: 'error',
ErrorCode: 'INTERNAL_ERROR', ErrorMessage: err.message,
DateCreatedUtc: new Date().toISOString(),
}));
return errResponse(err.message, 500);
}
}
async function writeAudit(env, f) {
try {
await env.D1_SHOPIFY.prepare(`
INSERT INTO "auditLog" (
"StorefrontId","HostnameId","EventType","EventStatus",
"RequestMethod","RequestPath","RequestHostname",
"ShopifyApiVersion","MyShopifyDomain",
"HttpStatusCode","DurationMs","CacheStatus",
"KvLookupStatus","D1LookupStatus","SecretLookupStatus",
"CfRay","Colo","ErrorCode","ErrorMessage","DateCreatedUtc"
) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20)
`).bind(
f.StorefrontId ?? null, f.HostnameId ?? null,
f.EventType, f.EventStatus,
f.RequestMethod ?? null, f.RequestPath ?? null,
f.RequestHostname ?? null, f.ShopifyApiVersion ?? null,
f.MyShopifyDomain ?? null, f.HttpStatusCode ?? null,
f.DurationMs ?? null, f.CacheStatus ?? null,
f.KvLookupStatus ?? null, f.D1LookupStatus ?? null,
f.SecretLookupStatus ?? null, f.CfRay ?? null,
f.Colo ?? null, f.ErrorCode ?? null,
f.ErrorMessage ?? null, f.DateCreatedUtc
).run();
} catch (_) {}
}
async function decryptEnvelope(kek, sf) {
const kekBytes = new TextEncoder().encode(kek);
const kekHash = await crypto.subtle.digest('SHA-256', kekBytes);
const wrapKey = await crypto.subtle.importKey('raw', kekHash, { name: 'AES-GCM' }, false, ['decrypt']);
const dekBytes = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: fromB64(sf.WrapNonceBase64), tagLength: 128 },
wrapKey,
concat(fromB64(sf.EncryptedDekBase64), fromB64(sf.WrapAuthTagBase64))
);
const dek = await crypto.subtle.importKey('raw', dekBytes, { name: 'AES-GCM' }, false, ['decrypt']);
const tokenBytes = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: fromB64(sf.DataNonceBase64), tagLength: 128 },
dek,
concat(fromB64(sf.CiphertextBase64), fromB64(sf.DataAuthTagBase64))
);
return new TextDecoder().decode(tokenBytes);
}
function fromB64(b64) {
const bin = atob(b64);
const buf = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
return buf;
}
function concat(a, b) {
const out = new Uint8Array(a.length + b.length);
out.set(a, 0); out.set(b, a.length);
return out;
}
function latestShopifyApiVersion() {
const now = new Date();
const m = now.getUTCMonth() + 1;
const q = [1, 4, 7, 10].filter(x => x <= m).at(-1);
return `${now.getUTCFullYear()}-${String(q).padStart(2, '0')}`;
}
function corsHeaders() {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, X-Storefront-Hostname',
};
}
function errResponse(msg, status = 500) {
return new Response(JSON.stringify({ error: msg }), {
status,
headers: { 'Content-Type': 'application/json', ...corsHeaders() }
});
}