feat: consolidate shared UI and harden browser authority for release

This commit is contained in:
2026-09-08 01:35:05 +02:00
parent ac40774785
commit b75ca34295
143 changed files with 8664 additions and 752 deletions
+85 -41
View File
@@ -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);
+1
View File
@@ -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",