feat: consolidate shared UI and harden browser authority for release
This commit is contained in:
+85
-41
@@ -1,4 +1,4 @@
|
||||
import type { ApiSettings } from "../types";
|
||||
import type { ApiSettings, AuthUpdate } from "../types";
|
||||
import { temporalRequestHeaders } from "../platform/temporal";
|
||||
|
||||
const STORAGE_KEY = "govoplan.apiSettings";
|
||||
@@ -25,12 +25,14 @@ type RecentSafeRequest = {
|
||||
type ConditionalSafeRequest = {
|
||||
value: unknown;
|
||||
etag: string;
|
||||
cacheControl: string;
|
||||
};
|
||||
|
||||
const inFlightSafeRequests = new Map<string, Promise<unknown>>();
|
||||
const recentSafeRequests = new Map<string, RecentSafeRequest>();
|
||||
const conditionalSafeRequests = new Map<string, ConditionalSafeRequest>();
|
||||
let safeRequestGeneration = 0;
|
||||
let lastSessionCookie: string | undefined;
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
@@ -166,7 +168,16 @@ export function loadApiSettings(): ApiSettings {
|
||||
};
|
||||
}
|
||||
|
||||
export function apiSettingsForAuthUpdate(settings: ApiSettings, next: AuthUpdate | null, accessToken?: string): ApiSettings {
|
||||
if (next === null || accessToken !== undefined || next.principal?.auth_method === "session") {
|
||||
const token = next === null ? "" : accessToken ?? settings.accessToken;
|
||||
return settings.apiKey || token !== settings.accessToken ? { ...settings, apiKey: "", accessToken: token } : settings;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
|
||||
export function saveApiSettings(settings: ApiSettings): void {
|
||||
clearApiReadCache();
|
||||
localStorage.setItem(`${STORAGE_KEY}.baseUrl`, normalizeApiBaseUrl(settings.apiBaseUrl));
|
||||
if (settings.apiKey) {
|
||||
sessionStorage.setItem(`${SESSION_STORAGE_KEY}.apiKey`, settings.apiKey);
|
||||
@@ -181,6 +192,7 @@ export function saveApiSettings(settings: ApiSettings): void {
|
||||
}
|
||||
|
||||
export function clearAccessToken(): void {
|
||||
clearApiReadCache();
|
||||
sessionStorage.removeItem(`${SESSION_STORAGE_KEY}.accessToken`);
|
||||
localStorage.removeItem(`${STORAGE_KEY}.accessToken`);
|
||||
}
|
||||
@@ -220,30 +232,27 @@ function isUnsafeMethod(method?: string): boolean {
|
||||
return !["GET", "HEAD", "OPTIONS", "TRACE"].includes(normalized);
|
||||
}
|
||||
|
||||
function canReuseSafeRequest(method: string, init?: RequestInit): boolean {
|
||||
function canReuseSafeRequest(method: string, headers: Headers, init?: RequestInit): boolean {
|
||||
return (method === "GET" || method === "HEAD") &&
|
||||
!init?.body &&
|
||||
!init?.signal &&
|
||||
init?.cache !== "no-store" &&
|
||||
init?.cache !== "reload";
|
||||
!requiresFreshRead(headers, init);
|
||||
}
|
||||
|
||||
function requestHeadersKey(headers: Headers): string {
|
||||
return [...headers.entries()].
|
||||
sort(([left], [right]) => left.localeCompare(right)).
|
||||
map(([key, value]) => `${key}:${value}`).
|
||||
join("\n");
|
||||
function requiresFreshRead(headers: Headers, init?: RequestInit): boolean {
|
||||
return init?.cache === "no-store" || init?.cache === "reload" || init?.cache === "no-cache" ||
|
||||
/\b(?:no-store|no-cache|max-age\s*=\s*"?0\b)/i.test(headers.get("Cache-Control") ?? "");
|
||||
}
|
||||
|
||||
function safeRequestKey(url: string, method: string, headers: Headers, init?: RequestInit): string {
|
||||
return [
|
||||
// Headers already iterates normalized names in lexicographical order.
|
||||
return JSON.stringify([
|
||||
method,
|
||||
url,
|
||||
init?.credentials ?? "include",
|
||||
init?.mode ?? "",
|
||||
init?.redirect ?? "",
|
||||
requestHeadersKey(headers)].
|
||||
join("\n\n");
|
||||
[...headers].filter(([key]) => key !== "cache-control")]);
|
||||
}
|
||||
|
||||
function pruneRecentSafeRequests(now = Date.now()): void {
|
||||
@@ -285,9 +294,9 @@ function conditionalSafeResponse(key: string): ConditionalSafeRequest | undefine
|
||||
return cached;
|
||||
}
|
||||
|
||||
function rememberConditionalSafeResponse(key: string, etag: string, value: unknown): void {
|
||||
function rememberConditionalSafeResponse(key: string, etag: string, value: unknown, cacheControl: string): void {
|
||||
conditionalSafeRequests.delete(key);
|
||||
conditionalSafeRequests.set(key, { etag, value });
|
||||
conditionalSafeRequests.set(key, { etag, value, cacheControl });
|
||||
while (conditionalSafeRequests.size > MAX_CONDITIONAL_SAFE_REQUESTS) {
|
||||
const oldestKey = conditionalSafeRequests.keys().next().value;
|
||||
if (!oldestKey) break;
|
||||
@@ -295,7 +304,8 @@ function rememberConditionalSafeResponse(key: string, etag: string, value: unkno
|
||||
}
|
||||
}
|
||||
|
||||
function clearSafeRequestCaches(): void {
|
||||
/** Invalidate before changing the shell's account, tenant, permissions, or session. */
|
||||
export function clearApiReadCache(): void {
|
||||
safeRequestGeneration += 1;
|
||||
inFlightSafeRequests.clear();
|
||||
recentSafeRequests.clear();
|
||||
@@ -318,6 +328,7 @@ function shouldNotifyAuthRequired(path: string): boolean {
|
||||
}
|
||||
|
||||
function notifyAuthRequired(path: string): void {
|
||||
clearApiReadCache();
|
||||
if (typeof window === "undefined" || !shouldNotifyAuthRequired(path)) return;
|
||||
const detail: AuthRequiredEventDetail = {
|
||||
path,
|
||||
@@ -347,43 +358,85 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
}
|
||||
|
||||
const csrf = csrfToken();
|
||||
// The HttpOnly session cookie cannot enter a cache key. Its paired CSRF
|
||||
// cookie rotates with the session, including sign-in from another tab.
|
||||
if (lastSessionCookie !== csrf) {
|
||||
clearApiReadCache();
|
||||
lastSessionCookie = csrf;
|
||||
}
|
||||
if (csrf && isUnsafeMethod(method) && !headers.has("X-CSRF-Token")) {
|
||||
headers.set("X-CSRF-Token", csrf);
|
||||
}
|
||||
|
||||
if (isUnsafeMethod(method)) {
|
||||
clearSafeRequestCaches();
|
||||
const unsafe = isUnsafeMethod(method);
|
||||
if (unsafe) {
|
||||
clearApiReadCache();
|
||||
}
|
||||
|
||||
const url = apiUrl(settings, path);
|
||||
const reusableSafeRequest = canReuseSafeRequest(method, init);
|
||||
const cacheKey = reusableSafeRequest ? safeRequestKey(url, method, headers, init) : null;
|
||||
const reusableSafeRequest = canReuseSafeRequest(method, headers, init);
|
||||
const cacheKey = safeRequestKey(url, method, headers, init);
|
||||
const requestGeneration = safeRequestGeneration;
|
||||
let request: Promise<T> | undefined;
|
||||
|
||||
if ((method === "GET" || method === "HEAD") && requiresFreshRead(headers, init)) {
|
||||
// Explicit fresh reads supersede both stored and still-running reads for
|
||||
// this resource, without invalidating unrelated workspace requests.
|
||||
inFlightSafeRequests.delete(cacheKey);
|
||||
recentSafeRequests.delete(cacheKey);
|
||||
conditionalSafeRequests.delete(cacheKey);
|
||||
}
|
||||
|
||||
function rememberResponse(response: Response, value: T, previous?: ConditionalSafeRequest): void {
|
||||
if (!reusableSafeRequest || requestGeneration !== safeRequestGeneration ||
|
||||
inFlightSafeRequests.get(cacheKey) !== request) return;
|
||||
|
||||
const cacheControl = response.headers.get("Cache-Control") ?? previous?.cacheControl ?? "";
|
||||
const noStore = /(?:^|,)\s*no-store\b/i.test(cacheControl) || response.headers.get("Vary")?.trim() === "*";
|
||||
const mustValidate = /(?:^|,)\s*(?:no-cache\b|max-age\s*=\s*"?0\b)/i.test(cacheControl);
|
||||
const etag = response.headers.get("etag") ?? previous?.etag;
|
||||
if (!noStore && etag) {
|
||||
rememberConditionalSafeResponse(cacheKey, etag, value, cacheControl);
|
||||
} else {
|
||||
conditionalSafeRequests.delete(cacheKey);
|
||||
}
|
||||
if (!noStore && !mustValidate) {
|
||||
rememberSafeResponse(cacheKey, value);
|
||||
} else {
|
||||
recentSafeRequests.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
async function runFetch(): Promise<T> {
|
||||
const fetchHeaders = new Headers(headers);
|
||||
const conditional = cacheKey ? conditionalSafeResponse(cacheKey) : undefined;
|
||||
const conditional = reusableSafeRequest ? conditionalSafeResponse(cacheKey) : undefined;
|
||||
if (conditional && !fetchHeaders.has("If-None-Match")) {
|
||||
fetchHeaders.set("If-None-Match", conditional.etag);
|
||||
}
|
||||
const fetchInit = { ...init, headers: fetchHeaders, credentials: init?.credentials ?? "include" };
|
||||
const response = await fetch(url, fetchInit);
|
||||
|
||||
if (response.status === 304 && cacheKey && conditional) {
|
||||
rememberSafeResponse(cacheKey, conditional.value);
|
||||
if (response.status === 304 && conditional && fetchHeaders.get("If-None-Match") === conditional.etag) {
|
||||
rememberResponse(response, conditional.value as T, conditional);
|
||||
return conditional.value as T;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
|
||||
notifyAuthRequired(path);
|
||||
// An obsolete request must not expire a newly established session.
|
||||
if (requestGeneration === safeRequestGeneration && csrfToken() === csrf) notifyAuthRequired(path);
|
||||
throw authExpiredError(response.statusText);
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText, text);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
if (cacheKey) conditionalSafeRequests.delete(cacheKey);
|
||||
if (reusableSafeRequest && requestGeneration === safeRequestGeneration &&
|
||||
inFlightSafeRequests.get(cacheKey) === request) {
|
||||
recentSafeRequests.delete(cacheKey);
|
||||
conditionalSafeRequests.delete(cacheKey);
|
||||
}
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
@@ -395,20 +448,15 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
value = (await response.json()) as T;
|
||||
}
|
||||
|
||||
const etag = response.headers.get("etag");
|
||||
if (cacheKey && etag) {
|
||||
rememberConditionalSafeResponse(cacheKey, etag, value);
|
||||
} else if (cacheKey) {
|
||||
conditionalSafeRequests.delete(cacheKey);
|
||||
}
|
||||
rememberResponse(response, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
if (!reusableSafeRequest || !cacheKey) {
|
||||
return runFetch();
|
||||
if (!reusableSafeRequest) {
|
||||
// Reads made while a write was pending may still describe its old state.
|
||||
return unsafe ? runFetch().finally(clearApiReadCache) : runFetch();
|
||||
}
|
||||
|
||||
const requestGeneration = safeRequestGeneration;
|
||||
const recent = recentSafeResponse(cacheKey);
|
||||
if (recent !== undefined) {
|
||||
return recent as T;
|
||||
@@ -419,13 +467,7 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
return existing as Promise<T>;
|
||||
}
|
||||
|
||||
const request = runFetch().
|
||||
then((value) => {
|
||||
if (requestGeneration === safeRequestGeneration) {
|
||||
rememberSafeResponse(cacheKey, value);
|
||||
}
|
||||
return value;
|
||||
}).
|
||||
request = runFetch().
|
||||
finally(() => {
|
||||
if (inFlightSafeRequests.get(cacheKey) === request) {
|
||||
inFlightSafeRequests.delete(cacheKey);
|
||||
@@ -437,6 +479,8 @@ export async function apiFetch<T>(settings: ApiSettings, path: string, init?: Re
|
||||
|
||||
|
||||
export async function apiDownload(settings: ApiSettings, path: string, filename: string): Promise<void> {
|
||||
const requestGeneration = safeRequestGeneration;
|
||||
const sessionCookie = csrfToken();
|
||||
const headers = authHeaders(settings);
|
||||
for (const [key, value] of Object.entries(temporalRequestHeaders())) {
|
||||
headers.set(key, value);
|
||||
@@ -445,7 +489,7 @@ export async function apiDownload(settings: ApiSettings, path: string, filename:
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
if (response.status === 401 && shouldNotifyAuthRequired(path)) {
|
||||
notifyAuthRequired(path);
|
||||
if (requestGeneration === safeRequestGeneration && csrfToken() === sessionCookie) notifyAuthRequired(path);
|
||||
throw authExpiredError(response.statusText);
|
||||
}
|
||||
throw new ApiError(response.status, response.statusText, text);
|
||||
|
||||
@@ -79,6 +79,7 @@ export const mailProfilePolicyLimitKeys = [
|
||||
"allowed_profile_ids",
|
||||
"allow_user_profiles",
|
||||
"allow_group_profiles",
|
||||
"allow_campaign_profiles",
|
||||
"smtp_credentials.inherit",
|
||||
"imap_credentials.inherit",
|
||||
"whitelist.smtp_hosts",
|
||||
|
||||
Reference in New Issue
Block a user