From 8d590f2ece10d434fb8fdaed6c202ba0d672984b Mon Sep 17 00:00:00 2001 From: Coder-soft Date: Mon, 19 Oct 2026 16:50:11 +0500 Subject: [PATCH] fix(assets-gateway): accept bearer sessions so cookie-blocked browsers load resources The gateway session (`rdgw`) is a third-party cookie (renderdragon.org -> gateway.codersoft.xyz). Safari/ITP, Brave, Chrome incognito and strict ETP/Firefox drop it silently, so `hasSession()` and every catalog request returned 401 for those visitors. The client turned that failure into an empty list, so the page rendered "No resources found" permanently for them (same for Font Picker, Showcases and the Background Generator, which share the session). - Gateway: return the minted session token in the `POST /__session` body and accept `Authorization: Bearer ` anywhere a session is required, in addition to the cookie. Strip `authorization` before proxying upstream. - Client: persist the token (localStorage + memory, keyed by gateway base) and replay it as a bearer header; keep `credentials: include` so cookie-capable browsers are unchanged. Clear and re-establish once on 401. - Catalog reads no longer report failure as emptiness: fall back to the stale cache (then per-category caches) and surface a real error state with Retry on Resources Hub instead of "No resources found". --- src/components/resources/ResourcesList.tsx | 77 ++++++++- src/hooks/useResources.ts | 12 ++ src/lib/api.ts | 184 +++++++++++++++------ src/lib/assetsApi.ts | 70 +++++++- src/lib/cache.ts | 59 +++++-- src/pages/ResourcesHub.tsx | 4 + workers/assets-gateway/README.md | 13 +- workers/assets-gateway/src/index.ts | 28 +++- 8 files changed, 376 insertions(+), 71 deletions(-) diff --git a/src/components/resources/ResourcesList.tsx b/src/components/resources/ResourcesList.tsx index 8d724938..af30fb37 100644 --- a/src/components/resources/ResourcesList.tsx +++ b/src/components/resources/ResourcesList.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Resource } from '@/types/resources'; import ResourceCard from './ResourceCard'; -import { IconFolderX, IconX } from '@tabler/icons-react'; +import { IconAlertTriangle, IconFolderX, IconRefresh, IconX } from '@tabler/icons-react'; import { Button } from '@/components/ui/button'; import { motion } from 'framer-motion'; import { cn } from '@/lib/utils'; @@ -28,6 +28,8 @@ interface ResourcesListProps { filteredResources: Resource[]; onMusicLink?: (resource: Resource) => void; onAnimate?: (resource: Resource) => void; + loadError?: string | null; + onRetry?: () => void; } const ResourcesList = ({ @@ -42,6 +44,8 @@ const ResourcesList = ({ filteredResources, onMusicLink, onAnimate, + loadError, + onRetry, }: ResourcesListProps) => { const [currentPage, setCurrentPage] = React.useState(1); const itemsPerPage = 12; @@ -70,6 +74,64 @@ const ResourcesList = ({ ); } + // A failed catalog fetch must never be presented as "no resources": that reads + // as an empty library when in fact the request (usually the asset session) failed. + if (loadError && resources.length === 0) { + return ( + + + + + + Couldn't load resources + + + {loadError}. This is usually a blocked or interrupted connection to our asset + service. Check your connection, VPN or ad-blocker, then try again. + + + + + + + ); + } + const shouldShowNoResourcesMessage = (!isLoading && filteredResources.length === 0) || (!isLoading && @@ -169,6 +231,19 @@ const ResourcesList = ({ animate={{ opacity: 1 }} transition={{ duration: 0.5 }} > + {loadError && resources.length > 0 && ( +
+

+ Some resources could not be refreshed: {loadError} +

+ {onRetry && ( + + )} +
+ )} { const [loadedFonts, setLoadedFonts] = useState([]); const [fontPreviewText, setFontPreviewText] = useState("Aa Bb Cc"); const [availableCategories, setAvailableCategories] = useState([]); + const [loadError, setLoadError] = useState(null); const [refreshKey, setRefreshKey] = useState(0); const fetchIdRef = useRef(0); @@ -44,6 +45,7 @@ export const useResources = () => { const loadResources = async () => { setIsLoading(true); setResources([]); + setLoadError(null); try { const categories = getAvailableCategories(); @@ -66,6 +68,9 @@ export const useResources = () => { console.error("Error fetching resources:", error); if (currentFetchId === fetchIdRef.current) { setResources([]); + setLoadError( + error instanceof Error && error.message ? error.message : "Unable to load resources" + ); } } finally { if (currentFetchId === fetchIdRef.current) { @@ -267,6 +272,11 @@ export const useResources = () => { return cacheImage(url); }, []); + const retryResources = useCallback(() => { + fetchIdRef.current++; + setRefreshKey(prev => prev + 1); + }, []); + return { resources, selectedResource, @@ -286,6 +296,7 @@ export const useResources = () => { availableSubcategories, availableCategories, hasCategoryResources, + loadError, handleSearchSubmit, handleClearSearch, handleCategoryChange, @@ -302,5 +313,6 @@ export const useResources = () => { setRefreshKey(prev => prev + 1); setSelectedCategory(null); }, + retryResources, }; }; diff --git a/src/lib/api.ts b/src/lib/api.ts index 9fb5f4f3..d4ffd01a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,4 +1,4 @@ -import { readCache, writeCache, clearCache } from "@/lib/cache"; +import { readCache, readStaleCache, writeCache, clearCache } from "@/lib/cache"; import { fetchFromAssetsApi } from "@/lib/assetsApi"; import { ensureAssetsVersionSynced } from "@/lib/assetsVersion"; import { Resource } from "@/types/resources"; @@ -9,6 +9,18 @@ const CATEGORY_CACHE_PREFIX = "api:category:"; export const MCICONS_BLACKLISTED_SUBCATEGORIES = ['backgrounds']; +/** + * Thrown when the catalog could not be loaded at all (session/network failure and + * no usable cache). Callers must surface this instead of rendering an empty list, + * which is indistinguishable from "there are no resources". + */ +export class ResourceApiError extends Error { + constructor(message = "Unable to load resources") { + super(message); + this.name = "ResourceApiError"; + } +} + export interface ApiResource { id: number; title: string; @@ -92,6 +104,58 @@ const normalizeApiResource = (item: ApiResource, category: string): Resource | n }; }; +const flattenCategories = (categories: Record): Resource[] => + Object.entries(categories).flatMap(([category, items]) => + items.map(item => normalizeApiResource(item, category)).filter((r): r is Resource => r !== null) + ); + +const readCachedCatalog = (): ApiAllResources | null => { + const fresh = readCache(ALL_RESOURCES_CACHE_KEY); + if (fresh?.categories && Object.keys(fresh.categories).length > 0) return fresh; + const stale = readStaleCache(ALL_RESOURCES_CACHE_KEY); + if (stale?.categories && Object.keys(stale.categories).length > 0) return stale; + return null; +}; + +const resourceFromCatalog = (catalog: ApiAllResources, apiCategory: string): Resource[] => { + const items = catalog.categories?.[apiCategory]; + if (!items || items.length === 0) return []; + return items + .map(item => normalizeApiResource(item, apiCategory)) + .filter((r): r is Resource => r !== null); +}; + +const CATEGORY_NAMES = [ + "music", + "sfx", + "images", + "animations", + "fonts", + "presets", + "mcsounds", + "minecraft-icons", +]; + +/** + * Rebuilds the catalog from the per-category caches. Last resort when `/all` is + * unreachable but individual categories were cached earlier. + */ +const readResourcesFromCategoryCaches = (): Resource[] => { + const collected: Resource[] = []; + + for (const category of CATEGORY_NAMES) { + const cacheKey = `${CATEGORY_CACHE_PREFIX}${category}`; + const cached = readCache(cacheKey) ?? readStaleCache(cacheKey); + if (!cached || cached.length === 0) continue; + const apiCategory = toApiCategory(category); + collected.push( + ...cached.map(item => normalizeApiResource(item, apiCategory)).filter((r): r is Resource => r !== null) + ); + } + + return collected; +}; + export const fetchCategories = async (): Promise => { await ensureAssetsVersionSynced(); const cached = readCache(CATEGORIES_CACHE_KEY); @@ -102,7 +166,7 @@ export const fetchCategories = async (): Promise => { writeCache(CATEGORIES_CACHE_KEY, data); return data; } - return null; + return readStaleCache(CATEGORIES_CACHE_KEY); }; export const fetchCategory = async (category: string): Promise => { @@ -120,16 +184,32 @@ export const fetchCategory = async (category: string): Promise => { const data = await fetchJson<{ category: string; files: ApiResource[] }>( `/category/${apiCategory}` ); - if (data?.files && data.files.length > 0) { - try { - writeCache(cacheKey, data.files); - } catch (e) { - console.warn(`Failed to cache category ${normalized}:`, e); + if (data && Array.isArray(data.files)) { + // A successful response is authoritative, even when it is empty. + if (data.files.length > 0) { + try { + writeCache(cacheKey, data.files); + } catch (e) { + console.warn(`Failed to cache category ${normalized}:`, e); + } } return data.files.map(item => normalizeApiResource(item, normalized)).filter((r): r is Resource => r !== null); } - console.error(`Failed to fetch category ${normalized} from API`); - return []; + + // Unreachable upstream: serve what we already have rather than nothing. + const stale = readStaleCache(cacheKey); + if (stale && stale.length > 0) { + console.warn(`Using stale cache for category ${normalized}`); + return stale.map(item => normalizeApiResource(item, normalized)).filter((r): r is Resource => r !== null); + } + + const catalog = readCachedCatalog(); + if (catalog) { + const fromCatalog = resourceFromCatalog(catalog, apiCategory); + if (fromCatalog.length > 0) return fromCatalog; + } + + throw new ResourceApiError(`Unable to load the "${normalized}" category`); }; export const fetchAllResources = async (): Promise => { @@ -146,41 +226,54 @@ export const fetchAllResources = async (): Promise => { total: categoryNames.length }); } - return Object.entries(cached.categories).flatMap(([category, items]) => - items.map(item => normalizeApiResource(item, category)).filter((r): r is Resource => r !== null) - ); + return flattenCategories(cached.categories); } const data = await fetchJson("/all"); - if (data?.categories && Object.keys(data.categories).length > 0) { - try { - writeCache(ALL_RESOURCES_CACHE_KEY, data); - const categoryNames = Object.keys(data.categories) - .map(normalizeCategory) - .filter((c): c is Resource["category"] => c !== null); - writeCache(CATEGORIES_CACHE_KEY, { - categories: categoryNames, - total: categoryNames.length - }); - Object.entries(data.categories).forEach(([category, items]) => { - const normalized = normalizeCategory(category); - if (normalized && items && items.length > 0) { - try { - writeCache(`${CATEGORY_CACHE_PREFIX}${normalized}`, items); - } catch { - // Ignore quota errors for individual category caches + if (data?.categories && typeof data.categories === "object") { + // A successful response is authoritative, even when the catalog is empty. + if (Object.keys(data.categories).length > 0) { + try { + writeCache(ALL_RESOURCES_CACHE_KEY, data); + const categoryNames = Object.keys(data.categories) + .map(normalizeCategory) + .filter((c): c is Resource["category"] => c !== null); + writeCache(CATEGORIES_CACHE_KEY, { + categories: categoryNames, + total: categoryNames.length + }); + Object.entries(data.categories).forEach(([category, items]) => { + const normalized = normalizeCategory(category); + if (normalized && items && items.length > 0) { + try { + writeCache(`${CATEGORY_CACHE_PREFIX}${normalized}`, items); + } catch { + // Ignore quota errors for individual category caches + } } - } - }); - } catch (e) { - console.warn("Failed to cache all resources (likely quota exceeded):", e); + }); + } catch (e) { + console.warn("Failed to cache all resources (likely quota exceeded):", e); + } } - return Object.entries(data.categories).flatMap(([category, items]) => - items.map(item => normalizeApiResource(item, category)).filter((r): r is Resource => r !== null) - ); + return flattenCategories(data.categories); + } + + // The catalog could not be fetched (expired/blocked session, outage, offline): + // serve the cached copy and only report an error when there is nothing to show. + const stale = readStaleCache(ALL_RESOURCES_CACHE_KEY); + if (stale?.categories && Object.keys(stale.categories).length > 0) { + console.warn("Using stale all-resources cache after a failed catalog fetch"); + return flattenCategories(stale.categories); } - console.error("Failed to fetch all resources from API"); - return []; + + const fromCategoryCaches = readResourcesFromCategoryCaches(); + if (fromCategoryCaches.length > 0) { + console.warn("Rebuilt the catalog from category caches after a failed catalog fetch"); + return fromCategoryCaches; + } + + throw new ResourceApiError("Unable to load resources from the assets API"); }; export const refreshAllResources = async (): Promise => { @@ -188,21 +281,18 @@ export const refreshAllResources = async (): Promise => { const data = await fetchJson("/all"); if (data?.categories) { writeCache(ALL_RESOURCES_CACHE_KEY, data); - return Object.entries(data.categories).flatMap(([category, items]) => - items.map(item => normalizeApiResource(item, category)).filter((r): r is Resource => r !== null) - ); + return flattenCategories(data.categories); } - return []; + throw new ResourceApiError("Unable to refresh resources from the assets API"); }; export const getResourceByCategory = async (category: string): Promise => { await ensureAssetsVersionSynced(); const apiCategory = toApiCategory(category); - const allCached = readCache(ALL_RESOURCES_CACHE_KEY); - if (allCached?.categories?.[apiCategory]) { - return allCached.categories[apiCategory].map(item => - normalizeApiResource(item, category) - ).filter((r): r is Resource => r !== null); + const catalog = readCachedCatalog(); + if (catalog) { + const fromCatalog = resourceFromCatalog(catalog, apiCategory); + if (fromCatalog.length > 0) return fromCatalog; } return fetchCategory(category); diff --git a/src/lib/assetsApi.ts b/src/lib/assetsApi.ts index 25e71dda..707e5aef 100644 --- a/src/lib/assetsApi.ts +++ b/src/lib/assetsApi.ts @@ -33,9 +33,61 @@ export async function fetchAssetsVersion(): Promise { let sessionPromise: Promise | null = null; +// The gateway hands the session back as an HttpOnly cookie, but that cookie is +// third-party (renderdragon.org -> gateway.codersoft.xyz) and browsers that block +// third-party cookies (Safari/ITP, Brave, Chrome incognito, strict ETP) drop it, +// which used to break every catalog request for those visitors. The same signed +// token is also returned in the session response body and replayed here as a +// bearer token, so the session works with or without cookies. +const SESSION_STORAGE_KEY = "rd-assets-gateway-session"; + +let sessionToken: string | null = null; + +const readStoredToken = (): string | null => { + if (sessionToken) return sessionToken; + try { + const raw = localStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as { base?: string; token?: string }; + if (parsed?.base !== ASSETS_API_BASE_URL || typeof parsed.token !== "string" || !parsed.token) { + return null; + } + sessionToken = parsed.token; + return sessionToken; + } catch { + return null; + } +}; + +const storeToken = (token: string | null): void => { + sessionToken = token; + try { + if (token) { + localStorage.setItem( + SESSION_STORAGE_KEY, + JSON.stringify({ base: ASSETS_API_BASE_URL, token }) + ); + } else { + localStorage.removeItem(SESSION_STORAGE_KEY); + } + } catch { + // Private mode / quota: the in-memory token still covers this page load. + } +}; + +const withTokenHeader = (headers: HeadersInit | undefined, token: string | null): Headers => { + const merged = new Headers(headers); + if (token) merged.set("Authorization", `Bearer ${token}`); + return merged; +}; + async function hasSession(): Promise { + const token = readStoredToken(); try { - const response = await fetch(`${ASSETS_API_BASE_URL}/__session`, { credentials: "include" }); + const response = await fetch(`${ASSETS_API_BASE_URL}/__session`, { + credentials: "include", + headers: withTokenHeader(undefined, token), + }); return response.ok; } catch { return false; @@ -60,6 +112,13 @@ async function acquireSession(): Promise { if (!response.ok) { throw new Error(`Unable to establish an assets session (${response.status})`); } + + try { + const body = (await response.json()) as { token?: unknown }; + storeToken(typeof body?.token === "string" && body.token ? body.token : null); + } catch { + storeToken(null); + } } export async function ensureAssetsSession(): Promise { @@ -86,11 +145,18 @@ export async function fetchFromAssetsApi(path: string, init?: RequestInit): Prom await ensureAssetsSession(); - const send = () => fetch(assetsApiUrl(path), { ...init, credentials: "include" }); + const send = () => + fetch(assetsApiUrl(path), { + ...init, + headers: withTokenHeader(init?.headers, readStoredToken()), + credentials: "include", + }); + let response = await send(); if (response.status === 401) { // Session expired or was rejected; re-establish once and retry. + storeToken(null); sessionPromise = null; await ensureAssetsSession(); response = await send(); diff --git a/src/lib/cache.ts b/src/lib/cache.ts index 31618760..ebdcc25c 100644 --- a/src/lib/cache.ts +++ b/src/lib/cache.ts @@ -7,33 +7,64 @@ type CachedPayload = { data: T; }; -export const readCache = (key: string): T | null => { +const readRawCache = (key: string): CachedPayload | null => { try { const raw = localStorage.getItem(`${CACHE_PREFIX}${key}`); if (!raw) return null; const parsed = JSON.parse(raw) as CachedPayload; if (!parsed?.savedAt || parsed?.data === undefined) return null; - if (Date.now() - parsed.savedAt > CACHE_TTL_MS) { - localStorage.removeItem(`${CACHE_PREFIX}${key}`); - return null; - } - return parsed.data; + return parsed; } catch { return null; } }; +export const readCache = (key: string): T | null => { + const parsed = readRawCache(key); + if (!parsed) return null; + if (Date.now() - parsed.savedAt > CACHE_TTL_MS) return null; + return parsed.data; +}; + +// Reads an entry even when its TTL has passed. Used as a last-resort fallback so a +// visitor still sees the catalog when the asset API (or their session) is unavailable. +export const readStaleCache = (key: string): T | null => + readRawCache(key)?.data ?? null; + +const pruneExpiredCaches = (): void => { + try { + for (const key of Object.keys(localStorage)) { + if (!key.startsWith(CACHE_PREFIX)) continue; + try { + const parsed = JSON.parse(localStorage.getItem(key) || "") as CachedPayload; + if (!parsed?.savedAt || Date.now() - parsed.savedAt > CACHE_TTL_MS) { + localStorage.removeItem(key); + } + } catch { + localStorage.removeItem(key); + } + } + } catch { + // ignore + } +}; + export const writeCache = (key: string, data: T): void => { + const serialized = JSON.stringify({ + savedAt: Date.now(), + data, + } satisfies CachedPayload); + try { - localStorage.setItem( - `${CACHE_PREFIX}${key}`, - JSON.stringify({ - savedAt: Date.now(), - data, - } satisfies CachedPayload) - ); + localStorage.setItem(`${CACHE_PREFIX}${key}`, serialized); } catch { - // ignore quota/storage errors + // Most likely out of quota: drop expired entries and try once more. + try { + pruneExpiredCaches(); + localStorage.setItem(`${CACHE_PREFIX}${key}`, serialized); + } catch { + // ignore quota/storage errors + } } }; diff --git a/src/pages/ResourcesHub.tsx b/src/pages/ResourcesHub.tsx index 0ca3bc72..213ab043 100644 --- a/src/pages/ResourcesHub.tsx +++ b/src/pages/ResourcesHub.tsx @@ -73,6 +73,8 @@ const ResourcesHub = () => { handleSearch, handleDownload, availableSubcategories, + loadError, + retryResources, } = useResources(); const inputRef = useRef(null); @@ -384,6 +386,8 @@ const ResourcesHub = () => { hasCategoryResources={hasCategoryResources} onMusicLink={onMusicLink} onAnimate={setAnimatingResource} + loadError={loadError} + onRetry={retryResources} /> )} diff --git a/workers/assets-gateway/README.md b/workers/assets-gateway/README.md index 918c2935..884dcbc7 100644 --- a/workers/assets-gateway/README.md +++ b/workers/assets-gateway/README.md @@ -25,16 +25,23 @@ The gateway only signs requests for a caller that proves it is a real renderdrag - `{ "turnstileToken": "" }` for an anonymous visitor (verified server-side against Cloudflare Turnstile, including the hostname). On success the gateway returns `Set-Cookie: rdgw=…; HttpOnly; Secure; SameSite=None` - Path=/api/assets-gw` — a short-lived HMAC-signed session. -3. Any other `/api/assets-gw/` request must carry that cookie. The gateway signs it + Path=/` — a short-lived HMAC-signed session — **and** the same token in the response body + (`{ "ok": true, "expiresAt": …, "token": "v1.…" }`). +3. Any other `/api/assets-gw/` request must carry that session, either as the `rdgw` + cookie or as `Authorization: Bearer `. The gateway signs the request (HMAC-SHA256, same canonical string as the router) with `GATEWAY_SECRET` and proxies to `ROUTER_ORIGIN`. +The bearer form exists because `rdgw` is a **third-party** cookie (`renderdragon.org` → +`gateway.codersoft.xyz`): Safari/ITP, Brave, Chrome incognito and strict Firefox/ETP drop it, +which made every catalog request 401 for those visitors. The token is only readable by origins +allowed by CORS, and it is still handed out as an `HttpOnly` cookie wherever cookies work. + `GET /api/assets-gw/__version` is **public** — no session, no challenge. The SPA caches the catalog and compares this version; it only establishes a session (step 2) when the version changed, so returning visitors are not challenged. -Spoofed `Origin`, `Referer`, or `X-Auth-*` headers cannot mint a session: the cookie requires a +Spoofed `Origin`, `Referer`, or `X-Auth-*` headers cannot mint a session: the session requires a server-verified Turnstile result (or a Supabase login), and the router independently verifies the gateway's signature. Cookies are `HttpOnly` (no JS access), `Secure`, and `SameSite=None` (cross-site; `Lax` in local dev), which also blocks CSRF to the upload endpoint. diff --git a/workers/assets-gateway/src/index.ts b/workers/assets-gateway/src/index.ts index 17e4d7fd..f2d807e7 100644 --- a/workers/assets-gateway/src/index.ts +++ b/workers/assets-gateway/src/index.ts @@ -31,6 +31,7 @@ const STRIP_FROM_REQUEST = new Set([ "content-length", "accept-encoding", "cookie", + "authorization", "origin", "referer", "cf-connecting-ip", @@ -128,7 +129,7 @@ async function verifySession(secret: string, token: string): Promise { const header = request.headers.get("Cookie") ?? ""; @@ -141,6 +142,23 @@ const getCookie = (request: Request, name: string): string | null => { return null; }; +const getBearerToken = (request: Request): string | null => { + const header = request.headers.get("Authorization") ?? ""; + const match = /^Bearer[ \t]+(\S+)$/i.exec(header.trim()); + return match ? match[1] : null; +}; + +/** + * The session normally travels as the `rdgw` cookie, but that cookie is + * third-party (`renderdragon.org` -> `gateway.codersoft.xyz`) and browsers that + * block third-party cookies (Safari/ITP, Brave, Chrome incognito, strict ETP) + * silently drop it, which used to make every catalog request 401 for those + * users. The same signed token is therefore also accepted as + * `Authorization: Bearer `. + */ +const readSessionToken = (request: Request): string | null => + getBearerToken(request) ?? getCookie(request, COOKIE_NAME); + const sessionCookie = (value: string, maxAge: number, secure: boolean): string => `${COOKIE_NAME}=${value}; Path=/; HttpOnly;${secure ? " Secure;" : ""} SameSite=${secure ? "None" : "Lax"}; Max-Age=${maxAge}`; @@ -262,11 +280,13 @@ async function establishSession(request: Request, env: Env): Promise { const token = await mintSession(env.GATEWAY_SECRET, session); const secure = (env.COOKIE_SECURE ?? "true") !== "false"; - return jsonResponse({ ok: true, expiresAt: session.exp }, 200, { token, maxAge, secure }); + // The token is returned both as the cookie above and in the body: the body copy + // is what clients use when the third-party cookie cannot be stored. + return jsonResponse({ ok: true, expiresAt: session.exp, token }, 200, { token, maxAge, secure }); } async function sessionStatus(request: Request, env: Env): Promise { - const token = getCookie(request, COOKIE_NAME); + const token = readSessionToken(request); const session = token ? await verifySession(env.GATEWAY_SECRET, token) : null; if (!session) return jsonResponse({ ok: false }, 401); return jsonResponse({ ok: true, expiresAt: session.exp }, 200); @@ -276,7 +296,7 @@ async function proxy(request: Request, env: Env, url: URL, relativePath: string) const origin = request.headers.get("Origin"); if (origin && !originAllowed(origin, env)) return jsonResponse({ error: "origin_not_allowed" }, 403); - const token = getCookie(request, COOKIE_NAME); + const token = readSessionToken(request); const session = token ? await verifySession(env.GATEWAY_SECRET, token) : null; if (!session) return jsonResponse({ error: "session_required" }, 401);