fix(files): refresh URL auth token proactively for asset previews
The oc_url_token has a ~50s effective lifetime and was only fetched once at preview mount, so HTML/image/PDF previews cycled to 'authentication required' when it expired and nothing forced a re-render with a fresh token. Add a consumer-gated proactive refresh in runtime-auth: while at least one url-token consumer is active, a single scheduler mints a fresh token just before the skew window and swaps it in atomically (the previous token stays valid until the new one lands — no empty-token window for other consumers). acquire/release manage the consumer count; subscribe fires only on a real token replacement. FilesView consumes this via a shared useAssetAuthRefresh hook (replacing three near-duplicate effects) and remounts the iframe/img only when the token actually changes, not on a blind interval.
This commit is contained in:
@@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- Files: HTML, image, and PDF previews no longer cycle to "authentication required" every ~50 seconds. The short-lived URL auth token is now refreshed proactively before it expires (centrally, only while a preview is open), and previews remount only when the token actually changes.
|
||||
- Chat: adjacent paragraphs in assistant messages now render with a visible gap instead of collapsing into a single visual line. Reasoning and tool-card markdown stay compact, and messages don't gain trailing space at the bottom.
|
||||
|
||||
## [1.13.1] - 2026-06-17
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,6 +51,8 @@ const resetRuntimeAuthGeneration = (): void => {
|
||||
runtimeAuthGeneration += 1;
|
||||
runtimeUrlAuthRefreshPromise = null;
|
||||
clearRuntimeUrlAuthToken();
|
||||
// Credentials changed: if a consumer is active, re-mint promptly.
|
||||
scheduleUrlAuthRefresh();
|
||||
};
|
||||
|
||||
export const setRuntimeAuthCredentialProvider = (provider: RuntimeAuthCredentialProvider): void => {
|
||||
@@ -80,8 +82,15 @@ export const setRuntimeUrlAuthToken = (token: string | null | undefined, expires
|
||||
clearRuntimeUrlAuthToken();
|
||||
return;
|
||||
}
|
||||
const previous = runtimeUrlAuthToken;
|
||||
runtimeUrlAuthToken = normalized;
|
||||
runtimeUrlAuthTokenExpiresAt = expiresAt;
|
||||
// Notify only on a real replacement (existing token swapped for a fresh one),
|
||||
// not on the initial mint, so consumers remount token-bearing assets only
|
||||
// when the URL token actually changed underneath them.
|
||||
if (previous && previous !== normalized) {
|
||||
notifyRuntimeUrlAuthListeners();
|
||||
}
|
||||
};
|
||||
|
||||
const readValidRuntimeUrlAuthTokenSync = (): string => {
|
||||
@@ -108,9 +117,10 @@ export const getRuntimeAuthCredential = async (): Promise<RuntimeAuthCredential>
|
||||
return token ? { type: 'bearer', token } : null;
|
||||
};
|
||||
|
||||
export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Promise<string> => {
|
||||
const existing = readValidRuntimeUrlAuthTokenSync();
|
||||
if (existing) return existing;
|
||||
// Performs the actual network mint and swaps the new token in atomically (the
|
||||
// previous token stays valid until `setRuntimeUrlAuthToken` replaces it — no
|
||||
// empty-token window). Concurrent callers share one in-flight request.
|
||||
const mintRuntimeUrlAuthToken = (apiBaseUrl?: string | null): Promise<string> => {
|
||||
if (runtimeUrlAuthRefreshPromise) return runtimeUrlAuthRefreshPromise;
|
||||
const generation = runtimeAuthGeneration;
|
||||
|
||||
@@ -153,6 +163,98 @@ export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Pr
|
||||
return runtimeUrlAuthRefreshPromise;
|
||||
};
|
||||
|
||||
// Returns a valid token without a network call, minting only when the current
|
||||
// token is missing or already inside the skew window.
|
||||
export const refreshRuntimeUrlAuthToken = async (apiBaseUrl?: string | null): Promise<string> => {
|
||||
const existing = readValidRuntimeUrlAuthTokenSync();
|
||||
if (existing) return existing;
|
||||
return mintRuntimeUrlAuthToken(apiBaseUrl);
|
||||
};
|
||||
|
||||
// ── Proactive URL auth token refresh ──────────────────────────────────────
|
||||
// The url token has a short server TTL. Instead of each consumer minting on its
|
||||
// own timer (and clearing the shared token, which 401s other consumers during
|
||||
// the refetch), a single scheduler refreshes it just before the skew window —
|
||||
// but only while at least one consumer is active, so we never poll
|
||||
// /auth/url-token in the background when nothing needs the token.
|
||||
let urlAuthConsumerCount = 0;
|
||||
let urlAuthRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let urlAuthApiBaseUrl: string | null = null;
|
||||
const urlAuthListeners = new Set<() => void>();
|
||||
const URL_AUTH_PROACTIVE_BUFFER_MS = 5_000;
|
||||
|
||||
const notifyRuntimeUrlAuthListeners = (): void => {
|
||||
for (const listener of urlAuthListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch {
|
||||
// A listener throwing must not break the refresh loop.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearUrlAuthRefreshTimer = (): void => {
|
||||
if (urlAuthRefreshTimer !== null) {
|
||||
clearTimeout(urlAuthRefreshTimer);
|
||||
urlAuthRefreshTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleUrlAuthRefresh = (): void => {
|
||||
clearUrlAuthRefreshTimer();
|
||||
if (urlAuthConsumerCount <= 0 || typeof window === 'undefined') return;
|
||||
|
||||
// Refresh before the skew window so the old token is still valid when the new
|
||||
// one swaps in. With no token yet (expiry 0), refresh immediately.
|
||||
const refreshAt = runtimeUrlAuthTokenExpiresAt - URL_AUTH_REFRESH_SKEW_MS - URL_AUTH_PROACTIVE_BUFFER_MS;
|
||||
const delay = runtimeUrlAuthTokenExpiresAt > 0 ? Math.max(0, refreshAt - Date.now()) : 0;
|
||||
|
||||
urlAuthRefreshTimer = setTimeout(() => {
|
||||
urlAuthRefreshTimer = null;
|
||||
if (urlAuthConsumerCount <= 0) return;
|
||||
void mintRuntimeUrlAuthToken(urlAuthApiBaseUrl)
|
||||
.catch(() => {
|
||||
// Transient — the reschedule below retries (token is cleared on
|
||||
// failure → expiry 0 → delay 0 → prompt retry).
|
||||
})
|
||||
.finally(() => {
|
||||
scheduleUrlAuthRefresh();
|
||||
});
|
||||
}, delay);
|
||||
};
|
||||
|
||||
// Register an active url-token consumer. While any consumer is held, the token
|
||||
// is proactively refreshed before it expires. Returns a release function;
|
||||
// the proactive loop stops once the last consumer releases.
|
||||
export const acquireRuntimeUrlAuthToken = (apiBaseUrl?: string | null): (() => void) => {
|
||||
if (typeof apiBaseUrl === 'string' && apiBaseUrl.trim()) {
|
||||
urlAuthApiBaseUrl = apiBaseUrl.trim();
|
||||
}
|
||||
urlAuthConsumerCount += 1;
|
||||
scheduleUrlAuthRefresh();
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
urlAuthConsumerCount = Math.max(0, urlAuthConsumerCount - 1);
|
||||
if (urlAuthConsumerCount === 0) {
|
||||
clearUrlAuthRefreshTimer();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Subscribe to url-token *replacements* (an existing token swapped for a fresh
|
||||
// one). Fires only on a real change — not the initial mint — so consumers can
|
||||
// remount token-bearing assets without churning on first load. Returns an
|
||||
// unsubscribe function.
|
||||
export const subscribeRuntimeUrlAuthToken = (listener: () => void): (() => void) => {
|
||||
urlAuthListeners.add(listener);
|
||||
return () => {
|
||||
urlAuthListeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const buildRuntimeAuthHeaders = async (headers?: HeadersInit): Promise<Headers> => {
|
||||
const next = new Headers(headers);
|
||||
if (next.has('Authorization')) {
|
||||
|
||||
Reference in New Issue
Block a user