diff --git a/apps/web/components/listings/listing-detail-client.tsx b/apps/web/components/listings/listing-detail-client.tsx
index 1dd5658..ac94e8f 100644
--- a/apps/web/components/listings/listing-detail-client.tsx
+++ b/apps/web/components/listings/listing-detail-client.tsx
@@ -81,9 +81,9 @@ export function ListingDetailClient({ listing }: ListingDetailClientProps) {
{/* Breadcrumb */}
diff --git a/apps/web/lib/api-client.ts b/apps/web/lib/api-client.ts
index 8f73270..f148980 100644
--- a/apps/web/lib/api-client.ts
+++ b/apps/web/lib/api-client.ts
@@ -22,7 +22,52 @@ function getCsrfToken(): string | undefined {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
-async function request(endpoint: string, options: RequestOptions = {}): Promise {
+// Endpoints for which a transparent refresh-and-retry on 401 must NOT run
+// (refresh itself, login, logout, exchange-token, etc.).
+const AUTH_ENDPOINTS = new Set([
+ '/auth/refresh',
+ '/auth/login',
+ '/auth/logout',
+ '/auth/register',
+ '/auth/exchange-token',
+ '/auth/forgot-password',
+ '/auth/reset-password',
+]);
+
+/**
+ * Coalesce concurrent refresh attempts: if ten requests race a 401 at the same
+ * moment, we should fire `/auth/refresh` once, not ten times.
+ */
+let refreshInflight: Promise | null = null;
+
+async function tryRefresh(): Promise {
+ if (refreshInflight) return refreshInflight;
+ refreshInflight = (async () => {
+ try {
+ const csrfToken = getCsrfToken();
+ const res = await fetch(`${API_BASE_URL}/auth/refresh`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...(csrfToken ? { 'X-CSRF-Token': csrfToken } : {}),
+ },
+ body: JSON.stringify({}),
+ });
+ return res.ok;
+ } catch {
+ return false;
+ } finally {
+ // Allow a new refresh attempt after this one settles.
+ setTimeout(() => {
+ refreshInflight = null;
+ }, 0);
+ }
+ })();
+ return refreshInflight;
+}
+
+async function doFetch(endpoint: string, options: RequestOptions): Promise {
const { body, headers, ...rest } = options;
const method = options.method?.toUpperCase() ?? 'GET';
@@ -34,7 +79,7 @@ async function request(endpoint: string, options: RequestOptions = {}): Promi
}
}
- const res = await fetch(`${API_BASE_URL}${endpoint}`, {
+ return fetch(`${API_BASE_URL}${endpoint}`, {
...rest,
credentials: 'include',
headers: {
@@ -44,6 +89,19 @@ async function request(endpoint: string, options: RequestOptions = {}): Promi
},
body: body ? JSON.stringify(body) : undefined,
});
+}
+
+async function request(endpoint: string, options: RequestOptions = {}): Promise {
+ let res = await doFetch(endpoint, options);
+
+ // If the access_token expired silently, try one refresh+retry before giving
+ // up. Skip for auth endpoints themselves to avoid loops.
+ if (res.status === 401 && !AUTH_ENDPOINTS.has(endpoint)) {
+ const refreshed = await tryRefresh();
+ if (refreshed) {
+ res = await doFetch(endpoint, options);
+ }
+ }
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));