+
+ Session Ending Soon
+
+
+ Your session is about to end. The app will refresh when it expires.
+
+
+
+
+
+ )}
diff --git a/src/components/frontend/session/SessionWatchdog.tsx b/src/components/frontend/session/SessionWatchdog.tsx
new file mode 100644
index 0000000..b0a4902
--- /dev/null
+++ b/src/components/frontend/session/SessionWatchdog.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { useRouter } from "next/navigation";
+import { SESSION_EXPIRED_EVENT } from "@/hooks/useGameTick";
+
+/**
+ * How early (ms) before the JWT `exp` we lock the client (dispatch the
+ * `SESSION_EXPIRED_EVENT`). The server is authoritative; this buffer only
+ * makes the client-side UX hint fire a little ahead of the hard cutoff so the
+ * user isn't mid-action when the token lapses.
+ */
+const NEAR_EXPIRY_BUFFER_MS = 30_000;
+
+/**
+ * How often (ms) the safety check runs. It compares the wall clock against the
+ * server-provided `sessionExp` — it makes no server requests and reads no
+ * cookies. It exists to catch a throttled timer (e.g. a backgrounded tab) that
+ * delayed the one-shot timeout past the real expiry.
+ */
+const SAFETY_CHECK_INTERVAL_MS = 5 * 60_000;
+
+/**
+ * Mounted only in the authenticated branch of `(frontend)/layout.tsx`.
+ *
+ * The server reads the HttpOnly `payload-token` cookie and passes only the JWT
+ * `exp` (ms) as `sessionExp`. This component schedules a one-shot lock timer
+ * just before expiry (dispatches `SESSION_EXPIRED_EVENT` so minigames and other
+ * consumers can lock themselves) and a refresh timer at the actual expiry
+ * (`router.refresh()` — the server then re-renders the layout, sees the expired
+ * token, and swaps the shell for the guest landing page). A lightweight safety
+ * interval catches throttled timers. Guards prevent duplicate lock/refresh.
+ */
+export function SessionWatchdog({ sessionExp }: { sessionExp: number | null }) {
+ const router = useRouter();
+ const lockFiredRef = useRef(false);
+ const refreshFiredRef = useRef(false);
+
+ useEffect(() => {
+ if (sessionExp === null) return;
+
+ const lock = () => {
+ if (lockFiredRef.current) return;
+ lockFiredRef.current = true;
+ window.dispatchEvent(new CustomEvent(SESSION_EXPIRED_EVENT));
+ };
+
+ const refresh = () => {
+ if (refreshFiredRef.current) return;
+ refreshFiredRef.current = true;
+ router.refresh();
+ };
+
+ const now = Date.now();
+ const lockDelay = sessionExp - NEAR_EXPIRY_BUFFER_MS - now;
+ const refreshDelay = sessionExp - now;
+
+ let lockTimer: number | null = null;
+ let refreshTimer: number | null = null;
+
+ if (refreshDelay <= 0) {
+ // Already past expiry — lock and refresh immediately.
+ lock();
+ refresh();
+ } else {
+ if (lockDelay <= 0) {
+ lock();
+ } else {
+ lockTimer = window.setTimeout(lock, lockDelay);
+ }
+ refreshTimer = window.setTimeout(refresh, refreshDelay);
+ }
+
+ // Safety net for throttled timers in background tabs.
+ const safety = window.setInterval(() => {
+ if (Date.now() >= sessionExp) {
+ lock();
+ refresh();
+ window.clearInterval(safety);
+ }
+ }, SAFETY_CHECK_INTERVAL_MS);
+
+ return () => {
+ if (lockTimer !== null) window.clearTimeout(lockTimer);
+ if (refreshTimer !== null) window.clearTimeout(refreshTimer);
+ window.clearInterval(safety);
+ };
+ }, [router, sessionExp]);
+
+ return null;
+}
diff --git a/src/hooks/useGameTick.ts b/src/hooks/useGameTick.ts
index e238832..10a50fe 100644
--- a/src/hooks/useGameTick.ts
+++ b/src/hooks/useGameTick.ts
@@ -6,6 +6,7 @@ export const GAME_TICK_EVENT = "ptf:game-tick";
export const PROFILE_UPDATE_EVENT = "ptf:profile-update";
export const GAME_EVENT_EVENT = "ptf:game-event";
export const NOTIFICATION_EVENT = "ptf:notification";
+export const SESSION_EXPIRED_EVENT = "ptf:session-expired";
export interface GameTickDetail {
processedAt?: string;
diff --git a/src/hooks/useSessionExpired.ts b/src/hooks/useSessionExpired.ts
new file mode 100644
index 0000000..cce9e37
--- /dev/null
+++ b/src/hooks/useSessionExpired.ts
@@ -0,0 +1,33 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import { SESSION_EXPIRED_EVENT } from "@/hooks/useGameTick";
+
+/**
+ * Tracks whether the session has expired (per the client-side watchdog).
+ *
+ * Returns both a reactive `expired` boolean (for rendering) and an
+ * `expiredRef` (for use inside callbacks / the game loop without stale
+ * closures). Consumers should gate start/retry/input and skip server saves
+ * on `expiredRef.current`.
+ */
+export function useSessionExpired(): {
+ expired: boolean;
+ expiredRef: React.RefObject;
+} {
+ const [expired, setExpired] = useState(false);
+ const expiredRef = useRef(false);
+
+ useEffect(() => {
+ const handler = () => {
+ expiredRef.current = true;
+ setExpired(true);
+ };
+ window.addEventListener(SESSION_EXPIRED_EVENT, handler);
+ return () => {
+ window.removeEventListener(SESSION_EXPIRED_EVENT, handler);
+ };
+ }, []);
+
+ return { expired, expiredRef };
+}
diff --git a/src/lib/session/token.ts b/src/lib/session/token.ts
new file mode 100644
index 0000000..01e284d
--- /dev/null
+++ b/src/lib/session/token.ts
@@ -0,0 +1,44 @@
+/**
+ * Session-token helpers.
+ *
+ * The `payload-token` cookie is HttpOnly (set by Payload), so it is invisible
+ * to client-side JavaScript. The server reads it from the request headers and
+ * extracts only the JWT `exp` claim, which is passed to the client watchdog as
+ * a plain number. The server remains the authority on whether a session is
+ * actually valid — the `exp` is used purely as a client-side UX hint.
+ */
+
+/**
+ * Decode the `exp` claim (seconds since epoch) from a JWT string, or null when
+ * the token is malformed or carries no numeric `exp`. Pure and side-effect
+ * free so it is trivially unit-testable on both server and client.
+ */
+export function decodeTokenExp(token: string): number | null {
+ const payload = token.split(".")[1];
+ if (!payload) return null;
+ let json: unknown;
+ try {
+ const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
+ json = JSON.parse(atob(base64));
+ } catch {
+ return null;
+ }
+ if (typeof json !== "object" || json === null) return null;
+ const exp = (json as { exp?: unknown }).exp;
+ return typeof exp === "number" && Number.isFinite(exp) ? exp : null;
+}
+
+/**
+ * Read the `payload-token` cookie from the request headers and return the JWT
+ * `exp` in milliseconds since epoch, or null when the cookie is absent or the
+ * token is malformed. Server-only in practice (needs the request headers), but
+ * takes the headers as a parameter so it stays pure and testable.
+ */
+export function getSessionExpMs(headers: { get(name: string): string | null }): number | null {
+ const cookieHeader = headers.get("cookie");
+ if (!cookieHeader) return null;
+ const match = cookieHeader.match(/(?:^|;\s*)payload-token=([^;]+)/);
+ if (!match) return null;
+ const exp = decodeTokenExp(match[1]);
+ return exp === null ? null : exp * 1000;
+}