diff --git a/api/handler/session.go b/api/handler/session.go
index 3baafe96..e8a81aa9 100644
--- a/api/handler/session.go
+++ b/api/handler/session.go
@@ -14,6 +14,10 @@ import (
"github.com/aws/aws-lambda-go/events"
)
+// StatusOnlyQueryParam requests site status (notice/maintenance) without Turnstile or
+// session cookie minting so the frontend can show banners before session bootstrap.
+const StatusOnlyQueryParam = "statusOnly"
+
// TurnstileTokenQueryParam carries a one-time Cloudflare Turnstile response on GET /session.
// Query string avoids a CORS preflight for a custom header; API Gateway OPTIONS does not
// forward preflights to Lambda with our current CORS wiring.
@@ -36,6 +40,10 @@ func Session(ctx context.Context, request events.APIGatewayProxyRequest) (events
return res, nil
}
+ if isStatusOnlyRequest(request) {
+ return statusOnlyResponse(apiRes, origin)
+ }
+
if config.APISessionSecret() == "" {
return errorResponse(apiRes, origin, "session not configured", http.StatusServiceUnavailable)
}
@@ -105,6 +113,28 @@ func enforceTurnstile(
return apiRes, true
}
+func isStatusOnlyRequest(request events.APIGatewayProxyRequest) bool {
+ if request.HTTPMethod != http.MethodGet || request.QueryStringParameters == nil {
+ return false
+ }
+ value := strings.TrimSpace(request.QueryStringParameters[StatusOnlyQueryParam])
+ return value == "1" || strings.EqualFold(value, "true")
+}
+
+func statusOnlyResponse(
+ apiRes events.APIGatewayProxyResponse,
+ origin string,
+) (events.APIGatewayProxyResponse, error) {
+ apiRes, err := jsonResponse(apiRes, origin, http.StatusOK, buildSiteStatusResponse())
+ if err != nil {
+ return errorResponse(apiRes, origin, "err marshalling response", http.StatusInternalServerError)
+ }
+ applyMaintenanceHeaders(&apiRes)
+ headers := ensureResponseHeaders(&apiRes)
+ headers["Cache-Control"] = "public, max-age=60"
+ return apiRes, nil
+}
+
var (
errSessionVerificationRequired = errors.New("verification required")
errSessionMethodNotAllowed = errors.New("method not allowed")
diff --git a/api/handler/session_test.go b/api/handler/session_test.go
index 28329f02..51dddffc 100644
--- a/api/handler/session_test.go
+++ b/api/handler/session_test.go
@@ -124,6 +124,30 @@ func TestSession_RejectsFailedTurnstileVerification(t *testing.T) {
require.Equal(t, "verification failed", payload.Error)
}
+func TestSession_StatusOnly_SkipsTurnstileAndCookieMint(t *testing.T) {
+ t.Setenv(config.APISessionSecretEnv, "test-session-secret")
+ t.Setenv(config.TurnstileSecretKeyEnv, "test-turnstile-secret")
+ t.Setenv(config.APINoticeMessageEnv, "Card Kingdom prices may be delayed today.")
+
+ req := events.APIGatewayProxyRequest{
+ HTTPMethod: http.MethodGet,
+ Headers: map[string]string{
+ "origin": "http://localhost:5173",
+ },
+ QueryStringParameters: map[string]string{
+ StatusOnlyQueryParam: "1",
+ },
+ }
+
+ res, err := Session(context.Background(), req)
+ require.NoError(t, err)
+ require.Equal(t, http.StatusOK, res.StatusCode)
+ require.Empty(t, res.Headers["Set-Cookie"])
+ require.Equal(t, "Card Kingdom prices may be delayed today.", res.Headers[noticeMessageHeader])
+ require.Contains(t, res.Body, `"noticeMessage":"Card Kingdom prices may be delayed today."`)
+ require.Equal(t, "public, max-age=60", res.Headers["Cache-Control"])
+}
+
func TestSession_RejectsPostWhenTurnstileConfigured(t *testing.T) {
t.Setenv(config.APISessionSecretEnv, "test-session-secret")
t.Setenv(config.TurnstileSecretKeyEnv, "test-turnstile-secret")
diff --git a/docs/api-abuse-mitigation.md b/docs/api-abuse-mitigation.md
index 29709149..f0ca3b38 100644
--- a/docs/api-abuse-mitigation.md
+++ b/docs/api-abuse-mitigation.md
@@ -209,6 +209,19 @@ cannot search when this layer is on.
Token format: `expiryUnix.nonce.hmac` (HMAC-SHA256 over `expiry.nonce` with
`API_SESSION_SECRET`).
+### Site status probe (`GET /session?statusOnly=1`)
+
+The SPA fetches notice and maintenance banners with **`GET /session?statusOnly=1`**
+before Turnstile and session minting complete. This path:
+
+1. Passes origin verification (layer 1) when configured.
+2. Skips Turnstile and does **not** mint a session cookie (even when
+ `TURNSTILE_SECRET_KEY` / `API_SESSION_SECRET` are set).
+3. Returns the same JSON site status and `X-Maintenance-*` / `X-Notice-Message`
+ headers as a successful mint, with `Cache-Control: public, max-age=60`.
+
+No API Gateway route change is required — it uses the existing `/session` route.
+
### Enforcement (`GET /search`)
When `API_SESSION_SECRET` is set, `/search` requires a valid cookie:
diff --git a/frontend/index.html b/frontend/index.html
index dcc73e81..5ef641ac 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -43,6 +43,7 @@
+
diff --git a/frontend/src/constants.js b/frontend/src/constants.js
index c46bac48..9eaf1c71 100644
--- a/frontend/src/constants.js
+++ b/frontend/src/constants.js
@@ -223,6 +223,9 @@ export const TURNSTILE_SITE_KEY =
/** Query param sent with GET /session when Turnstile is enabled. */
export const TURNSTILE_TOKEN_QUERY_PARAM = "turnstileToken";
+/** Query param for GET /session that returns notice/maintenance without Turnstile. */
+export const STATUS_ONLY_QUERY_PARAM = "statusOnly";
+
export const BASE_URL = "https://gishathfetch.com/";
/** Deep link that opens the main privacy policy modal on the site. */
diff --git a/frontend/src/hooks/useSearch.js b/frontend/src/hooks/useSearch.js
index 16033b2c..7f79ed42 100644
--- a/frontend/src/hooks/useSearch.js
+++ b/frontend/src/hooks/useSearch.js
@@ -10,7 +10,10 @@ import {
import {
API_SESSION_REFRESH_INTERVAL_MS,
ensureApiSession,
+ fetchSiteStatus,
formatSessionBootstrapError,
+ getCachedSessionBootstrap,
+ getCachedSiteStatus,
isApiSessionAccessDenied,
resetApiSessionCache,
} from "../utils/apiSession";
@@ -41,7 +44,23 @@ const AUTOCOMPLETE_DEBOUNCE_MS = 300;
const SEARCH_PROGRESS_INTERVAL_MS = 1000;
const MAX_PROGRESS_DOTS = 15;
+function readLandingSearchQuery() {
+ const urlParams = new URLSearchParams(window.location.search);
+ if (!urlParams.has("s") || urlParams.get("s") === "") {
+ return null;
+ }
+
+ const query = decodeURIComponent(urlParams.get("s"));
+ if (query.length < MIN_SEARCH_LENGTH || query.length > MAX_SEARCH_LENGTH) {
+ return null;
+ }
+
+ return query;
+}
+
export default function useSearch() {
+ const initialSiteStatus = getCachedSiteStatus();
+ const initialBootstrap = getCachedSessionBootstrap();
const [searchQuery, setSearchQuery] = useState(() => {
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has("s") && urlParams.get("s") !== "") {
@@ -67,10 +86,18 @@ export default function useSearch() {
const [cardKingdomPrice, setCardKingdomPrice] = useState(null);
const [dismissedStoreErrorsKey, setDismissedStoreErrorsKey] = useState(null);
const [storesWarning, setStoresWarning] = useState(null);
- const [maintenanceMode, setMaintenanceMode] = useState(false);
- const [maintenanceMessage, setMaintenanceMessage] = useState("");
- const [noticeMessage, setNoticeMessage] = useState("");
- const [sessionBootstrapped, setSessionBootstrapped] = useState(false);
+ const [maintenanceMode, setMaintenanceMode] = useState(() =>
+ Boolean(initialSiteStatus?.maintenanceMode),
+ );
+ const [maintenanceMessage, setMaintenanceMessage] = useState(
+ () => initialSiteStatus?.maintenanceMessage ?? "",
+ );
+ const [noticeMessage, setNoticeMessage] = useState(
+ () => initialSiteStatus?.noticeMessage ?? "",
+ );
+ const [sessionBootstrapped, setSessionBootstrapped] = useState(
+ () => initialBootstrap !== null,
+ );
const [selectedStores, setSelectedStores] = useState(() =>
getInitialSelectedStores(),
);
@@ -90,23 +117,59 @@ export default function useSearch() {
const skipHistorySyncRef = useRef(false);
const restoringHistoryRef = useRef(false);
const performSearchRef = useRef(() => {});
+ const landingSearchHandledRef = useRef(false);
useEffect(() => {
searchResultsRef.current = searchResults;
}, [searchResults]);
+ const runLandingSearchIfNeeded = useCallback((timing) => {
+ if (landingSearchHandledRef.current || timing?.maintenanceMode) {
+ return;
+ }
+
+ const query = readLandingSearchQuery();
+ if (!query) {
+ landingSearchHandledRef.current = true;
+ return;
+ }
+
+ landingSearchHandledRef.current = true;
+ skipSuggestionsRef.current = true;
+
+ const urlParams = new URLSearchParams(window.location.search);
+ const urlStores = getStoresFromUrl(urlParams);
+ const stores = urlStores ?? getInitialSelectedStores(urlParams);
+ performSearchRef.current(query, stores);
+ }, []);
+
useEffect(() => {
let cancelled = false;
+ const applySiteStatus = (status) => {
+ setMaintenanceMode(Boolean(status.maintenanceMode));
+ setMaintenanceMessage(status.maintenanceMessage ?? "");
+ setNoticeMessage(status.noticeMessage ?? "");
+ };
+
+ fetchSiteStatus()
+ .then((status) => {
+ if (!cancelled) {
+ applySiteStatus(status);
+ }
+ })
+ .catch(() => {
+ // Notice is optional; session bootstrap may still provide status later.
+ });
+
ensureApiSession()
.then((timing) => {
if (cancelled) {
return;
}
- setMaintenanceMode(Boolean(timing.maintenanceMode));
- setMaintenanceMessage(timing.maintenanceMessage ?? "");
- setNoticeMessage(timing.noticeMessage ?? "");
+ applySiteStatus(timing);
setSessionBootstrapped(true);
+ runLandingSearchIfNeeded(timing);
})
.catch((err) => {
if (!cancelled) {
@@ -116,15 +179,17 @@ export default function useSearch() {
});
const refreshTimer = setInterval(() => {
- ensureApiSession({ forceRefresh: true })
- .then((timing) => {
- setMaintenanceMode(Boolean(timing.maintenanceMode));
- setMaintenanceMessage(timing.maintenanceMessage ?? "");
- setNoticeMessage(timing.noticeMessage ?? "");
+ fetchSiteStatus({ forceRefresh: true })
+ .then((siteStatus) => {
+ applySiteStatus(siteStatus);
})
.catch(() => {
- // Next search or interval will try again.
+ // Next interval will try again.
});
+
+ ensureApiSession({ forceRefresh: true }).catch(() => {
+ // Next search or interval will try again.
+ });
}, API_SESSION_REFRESH_INTERVAL_MS);
return () => {
@@ -745,40 +810,12 @@ export default function useSearch() {
persistSelectedStores(stores);
}, []);
- // --- Initialization ---
- // Run a deep-linked ?s= search once session bootstrap (incl. Turnstile) completes.
- const landingSearchHandledRef = useRef(false);
-
- const runLandingSearchIfNeeded = useCallback(() => {
- if (landingSearchHandledRef.current || maintenanceMode) {
- return;
- }
-
- const urlParams = new URLSearchParams(window.location.search);
- if (!urlParams.has("s") || urlParams.get("s") === "") {
- landingSearchHandledRef.current = true;
- return;
- }
-
- const q = decodeURIComponent(urlParams.get("s"));
- if (q.length < MIN_SEARCH_LENGTH || q.length > MAX_SEARCH_LENGTH) {
- landingSearchHandledRef.current = true;
- return;
- }
-
- landingSearchHandledRef.current = true;
- skipSuggestionsRef.current = true;
-
- const urlStores = getStoresFromUrl(urlParams);
- const stores = urlStores ?? getInitialSelectedStores(urlParams);
- performSearch(q, stores);
- }, [maintenanceMode, performSearch]);
-
+ // Fallback when bootstrap completed before this hook subscribed (e.g. fast remount).
useEffect(() => {
if (!sessionBootstrapped) {
return;
}
- runLandingSearchIfNeeded();
+ runLandingSearchIfNeeded(getCachedSessionBootstrap());
}, [sessionBootstrapped, runLandingSearchIfNeeded]);
return {
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index fdce2565..5818aac7 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -2,6 +2,16 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.jsx";
+import { TURNSTILE_SITE_KEY } from "./constants";
+import { ensureApiSession, fetchSiteStatus } from "./utils/apiSession";
+import { isTurnstileEnabled, preloadTurnstile } from "./utils/turnstile";
+
+// Fetch notice/maintenance immediately (no Turnstile). Overlap session mint with hydration.
+void fetchSiteStatus();
+if (isTurnstileEnabled(TURNSTILE_SITE_KEY)) {
+ void preloadTurnstile(TURNSTILE_SITE_KEY);
+}
+void ensureApiSession();
createRoot(document.getElementById("root")).render(
diff --git a/frontend/src/utils/apiSession.js b/frontend/src/utils/apiSession.js
index f52fa57e..69182af0 100644
--- a/frontend/src/utils/apiSession.js
+++ b/frontend/src/utils/apiSession.js
@@ -1,5 +1,6 @@
import {
API_SESSION_URL,
+ STATUS_ONLY_QUERY_PARAM,
TURNSTILE_SITE_KEY,
TURNSTILE_TOKEN_QUERY_PARAM,
} from "../constants";
@@ -14,10 +15,15 @@ const NOTICE_MESSAGE_HEADER = "X-Notice-Message";
/**
* @typedef {{
+ * maintenanceMode: boolean,
+ * maintenanceMessage: string,
+ * noticeMessage: string,
+ * }} SiteStatus
+ */
+
+/**
+ * @typedef {SiteStatus & {
* sessionMintDurationMs: number,
- * maintenanceMode?: boolean,
- * maintenanceMessage?: string,
- * noticeMessage?: string,
* }} SessionBootstrapTiming
*/
@@ -28,6 +34,46 @@ export const API_SESSION_REFRESH_INTERVAL_MS = 10 * 60 * 1000;
const SESSION_MINT_MAX_ATTEMPTS = 3;
let sessionBootstrapPromise = null;
+/** @type {SessionBootstrapTiming | null} */
+let cachedSessionBootstrap = null;
+let siteStatusPromise = null;
+/** @type {SiteStatus | null} */
+let cachedSiteStatus = null;
+
+/** Resolved bootstrap timing when session mint has completed (for fast UI hydration). */
+export function getCachedSessionBootstrap() {
+ return cachedSessionBootstrap;
+}
+
+/** Resolved site status when the status-only probe has completed. */
+export function getCachedSiteStatus() {
+ return cachedSiteStatus;
+}
+
+/**
+ * Fetches notice/maintenance without Turnstile or session cookies so banners can
+ * render before session bootstrap completes.
+ *
+ * @returns {Promise}
+ */
+export async function fetchSiteStatus(options = {}) {
+ const { forceRefresh = false } = options;
+
+ if (forceRefresh) {
+ siteStatusPromise = null;
+ }
+
+ if (!siteStatusPromise) {
+ siteStatusPromise = loadSiteStatus().catch((err) => {
+ siteStatusPromise = null;
+ throw err;
+ });
+ }
+
+ const status = await siteStatusPromise;
+ cachedSiteStatus = status;
+ return status;
+}
export function parseMaintenanceFromSessionResponse(res) {
if (res.headers.get(MAINTENANCE_MODE_HEADER) !== "1") {
@@ -138,12 +184,14 @@ export async function ensureApiSession(options = {}) {
try {
const bootstrapTiming = await sessionBootstrapPromise;
+ cachedSessionBootstrap = bootstrapTiming;
if (!initiatedBootstrap) {
return joinedBootstrapTiming(bootstrapTiming);
}
return bootstrapTiming;
} catch (err) {
sessionBootstrapPromise = null;
+ cachedSessionBootstrap = null;
throw err;
}
}
@@ -170,6 +218,22 @@ async function bootstrapSessionWithRetry() {
throw lastError ?? new Error("API session failed");
}
+async function loadSiteStatus() {
+ const params = new URLSearchParams({
+ [STATUS_ONLY_QUERY_PARAM]: "1",
+ });
+ const res = await fetch(`${API_SESSION_URL}?${params.toString()}`, {
+ method: "GET",
+ credentials: "omit",
+ });
+
+ if (!res.ok) {
+ throw new Error(`Site status failed (${res.status})`);
+ }
+
+ return parseSiteStatusFromSession(res);
+}
+
async function mintApiSession() {
// Network failures surface as TypeError; rethrow as-is so the UI keeps the
// accurate "unable to connect" copy instead of blaming session verification.
@@ -226,6 +290,13 @@ export function isApiSessionAccessDenied(message, statusCode) {
/** Clears the cached bootstrap promise (for tests or after auth errors). */
export function resetApiSessionCache() {
sessionBootstrapPromise = null;
+ cachedSessionBootstrap = null;
+}
+
+/** Clears cached site status (for tests). */
+export function resetSiteStatusCache() {
+ siteStatusPromise = null;
+ cachedSiteStatus = null;
}
/** User-facing copy when the initial session bootstrap fails. */
diff --git a/frontend/src/utils/turnstile.js b/frontend/src/utils/turnstile.js
index 04ff462d..bfbf978b 100644
--- a/frontend/src/utils/turnstile.js
+++ b/frontend/src/utils/turnstile.js
@@ -1,4 +1,4 @@
-const TURNSTILE_SCRIPT_URL =
+export const TURNSTILE_SCRIPT_URL =
"https://challenges.cloudflare.com/turnstile/v0/api.js";
const TURNSTILE_TIMEOUT_MS = 30_000;
@@ -60,6 +60,19 @@ function ensureWidget(siteKey) {
return widgetId;
}
+/**
+ * Loads the Turnstile script and pre-renders the invisible widget so the first
+ * session mint does not wait on script download during React hydration.
+ */
+export async function preloadTurnstile(siteKey) {
+ if (!isTurnstileEnabled(siteKey)) {
+ return;
+ }
+
+ await loadTurnstileScript();
+ ensureWidget(siteKey.trim());
+}
+
/**
* Runs invisible Turnstile and returns a one-time token for GET /session
* (sent as the turnstileToken query param). Safe to call on every session mint
diff --git a/frontend/src/utils/turnstile.test.js b/frontend/src/utils/turnstile.test.js
index 96fb29c1..5fa03cc3 100644
--- a/frontend/src/utils/turnstile.test.js
+++ b/frontend/src/utils/turnstile.test.js
@@ -1,6 +1,17 @@
import assert from "node:assert/strict";
-import { isTurnstileEnabled } from "./turnstile.js";
+import {
+ isTurnstileEnabled,
+ preloadTurnstile,
+ TURNSTILE_SCRIPT_URL,
+} from "./turnstile.js";
assert.equal(isTurnstileEnabled(""), false);
assert.equal(isTurnstileEnabled(" "), false);
assert.equal(isTurnstileEnabled("1x00000000000000000000AA"), true);
+assert.equal(
+ TURNSTILE_SCRIPT_URL,
+ "https://challenges.cloudflare.com/turnstile/v0/api.js",
+);
+
+await preloadTurnstile("");
+await preloadTurnstile(" ");