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);