diff --git a/.changeset/12808-console-boot.md b/.changeset/12808-console-boot.md new file mode 100644 index 0000000000..bf492a1b50 --- /dev/null +++ b/.changeset/12808-console-boot.md @@ -0,0 +1,5 @@ +--- +'@object-ui/console': patch +--- + +The console's startup sends one `get-session` and one `runtime/config` request instead of two of each: the session check and the early-branding request now hand their answers to the app instead of having them fetched again before the first render. diff --git a/.changeset/12808-entitlements-one-request.md b/.changeset/12808-entitlements-one-request.md new file mode 100644 index 0000000000..6246d0d3ea --- /dev/null +++ b/.changeset/12808-entitlements-one-request.md @@ -0,0 +1,7 @@ +--- +'@object-ui/app-shell': patch +--- + +Opening the Cloud pricing or Welcome page now asks for the organisation's environment entitlements once. The plan cards, the onboarding next step and the workspace-timezone notice share one request per organisation instead of each sending their own, so the page no longer waits for three of them in a row. The answer is re-read when the organisation changes, when the environment list refreshes, and on every later visit. + +The console's startup also reads `/api/v1/runtime/config` once: the answer the page fetched for its early branding is reused instead of being fetched again before the first render. diff --git a/.changeset/12808-session-handoff.md b/.changeset/12808-session-handoff.md new file mode 100644 index 0000000000..f040771197 --- /dev/null +++ b/.changeset/12808-session-handoff.md @@ -0,0 +1,5 @@ +--- +'@object-ui/auth': patch +--- + +`AuthProvider` no longer asks `get-session` a second time when the console has just validated the stored session before rendering. The validated answer becomes the provider's first state, once per page load; signing in or out, switching organisation and refreshing the session read it from the server as before. diff --git a/apps/console/index.html b/apps/console/index.html index 5fc051c127..e4cebb7d9b 100644 --- a/apps/console/index.html +++ b/apps/console/index.html @@ -27,20 +27,21 @@ `await`s that second call before `createRoot().render()`, so it sits on the critical path to first paint. - The fix is in-flight SHARING, not caching. We publish this request's parsed - -body promise into the registry `@object-ui/types` keeps on `globalThis` - (`Symbol.for('objectui.inflightGet')`), and delete the entry the moment it - settles. `sharedGetJson()` finds it and joins, so app-shell inherits a - request that started hundreds of ms earlier instead of starting its own; if - it ever arrives after settle it simply fetches, exactly as before. + The fix is a HANDOFF of this page load's answer (objectui#12808). This + script leaves its parsed-body promise on `globalThis` under + `Symbol.for('objectui.earlyRuntimeConfig')`, together with the URL it + asked, and keeps it for the page's lifetime. `initRuntimeConfig()` takes + it on its FIRST call when the URL matches: still in flight, it joins; + already settled, it reads the body. Hundreds of ms usually separate the + two, so an in-flight-only share (the earlier fix, objectui#5544) found + nothing to join and the bundle always asked again. A failed early fetch is + not reused: app-shell then fetches as it always did. Every later call + re-fetches, so the repeat-call contract is unchanged, and the slot holds + one answer of this page load, never one from an earlier page. - Why the key is spelled out by hand here: a classic inline script cannot - import, and it must stay classic (see the crypto shim's comment below for - the measurement that rules out `type="module"` ordering). The format is - owned by `inflightGetKey()` in `packages/types/src/http-inflight.ts`, and `src/__tests__/runtimeConfigBootDedup.test.ts` executes THIS script's text - against that function — so a drift between the two spellings fails a test - rather than silently restoring the duplicate. + against the real `initRuntimeConfig()`, so a drift between the slot's two + spellings fails a test rather than silently restoring the duplicate. The ORIGIN is the other half of that contract (objectui#5660). Sharing keys on the full URL, so the two callers must agree on the server origin @@ -71,22 +72,14 @@ const base = (injected.charAt(0) === '%' ? '' : injected).replace(/\/+$/, ''); const url = base + '/api/v1/runtime/config'; const init = { credentials: 'include', headers: { Accept: 'application/json' } }; - // Must equal inflightGetKey(url, init) — see the comment above. - const key = 'GET\ninclude\n' + url + '\naccept=application/json'; - - const registryKey = Symbol.for('objectui.inflightGet'); - const registry = globalThis[registryKey] || (globalThis[registryKey] = new Map()); const pending = (async function () { const res = await fetch(url, init); if (!res.ok) throw new Error('runtime/config ' + res.status); return res.json(); })(); - registry.set(key, pending); - const drop = function () { - if (registry.get(key) === pending) registry.delete(key); - }; - pending.then(drop, drop); + // Kept for the page's lifetime, never dropped on settle — see above. + globalThis[Symbol.for('objectui.earlyRuntimeConfig')] = { url: url, body: pending }; try { const body = await pending; diff --git a/apps/console/src/__tests__/runtimeConfigBootDedup.test.ts b/apps/console/src/__tests__/runtimeConfigBootDedup.test.ts index 6e8633abc1..5883d3b7ff 100644 --- a/apps/console/src/__tests__/runtimeConfigBootDedup.test.ts +++ b/apps/console/src/__tests__/runtimeConfigBootDedup.test.ts @@ -7,13 +7,18 @@ * before `createRoot().render()`, so it is on the critical path to first paint). * Neither can see the other, which is why no per-component guard closes it. * + * objectui#12808 measured it twice again: the first fix shared the request only + * while it was IN FLIGHT, and the pre-boot request settles hundreds of ms before + * the bundle asks, so there was never anything to join. The script now keeps its + * answer for the page's lifetime and `initRuntimeConfig()` takes it once. + * * These tests grade THE SHIPPED ARTIFACT, the same way * `insecure-origin-crypto.test.ts` does: the inline script's text is extracted - * from `index.html` and executed. That is what keeps the request key the script - * spells out by hand — a classic script cannot import `inflightGetKey()` — from - * drifting away from the function that owns the format. A drift would not break - * anything visibly; it would just quietly restore the duplicate, which is - * exactly the failure a hand-copied contract produces. + * from `index.html` and executed. That is what keeps the slot the script spells + * out by hand — a classic script cannot import — from drifting away from the + * reader in `runtime-config.ts`. A drift would not break anything visibly; it + * would just quietly restore the duplicate, which is exactly the failure a + * hand-copied contract produces. */ import indexHtml from '../../index.html?raw'; @@ -81,7 +86,14 @@ function jsonResponse(body: unknown, status = 200): Response { } as unknown as Response; } +/** The slot the pre-boot script leaves its answer in (see `index.html`). */ +const EARLY_SLOT = Symbol.for('objectui.earlyRuntimeConfig'); + +/** Let the pre-boot request settle, as it does long before the bundle asks. */ +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)); + beforeEach(() => { + delete (globalThis as unknown as Record)[EARLY_SLOT]; resetInflightGetsForTesting(); resetRuntimeConfigForTesting(); document.title = ''; @@ -96,7 +108,7 @@ describe('the shipped pre-boot script is findable', () => { it('is exactly one inline script in index.html', () => { const source = extractEarlyBrandingSource(); expect(source).toContain('/api/v1/runtime/config'); - expect(source).toContain("Symbol.for('objectui.inflightGet')"); + expect(source).toContain("Symbol.for('objectui.earlyRuntimeConfig')"); }); it('fails loudly if the script is gone rather than passing vacuously', () => { @@ -126,7 +138,23 @@ describe('one cold load, one runtime/config request', () => { expect(document.title).toBe('Acme Ops'); }); - it('still fetches on its own when nothing is in flight — sharing, not caching', async () => { + it('reads the pre-boot answer even after it settled — the real gap (objectui#12808)', async () => { + const fetchStub = vi.fn(async (_url: string, _init?: RequestInit) => jsonResponse(CONFIG_BODY)); + vi.stubGlobal('fetch', fetchStub); + + runEarlyBranding(); + // The bundle arrives well after the parse-time request has settled. + await settle(); + expect(document.title).toBe('Acme Ops'); + await initRuntimeConfig(''); + + // Before the fix this was 2: the in-flight share had nothing left to join. + expect(fetchStub).toHaveBeenCalledTimes(1); + expect(getRuntimeConfig().cloudUrl).toBe('https://cloud.example.test'); + expect(getRuntimeConfig().branding.productName).toBe('Acme Ops'); + }); + + it('still fetches on its own when the page has no pre-boot script', async () => { const fetchStub = vi.fn(async () => jsonResponse(CONFIG_BODY)); vi.stubGlobal('fetch', fetchStub); @@ -146,13 +174,13 @@ describe('one cold load, one runtime/config request', () => { expect(fetchStub).toHaveBeenCalledTimes(1); // `initRuntimeConfig` is documented as safe to call again, and a second call - // must re-fetch and re-merge. The registry entry is gone at settle, so it - // does — there is no window in which a stale body could be replayed. + // must re-fetch and re-merge. The pre-boot answer is taken once, so it + // does — a later call never replays the boot body. await initRuntimeConfig(''); expect(fetchStub).toHaveBeenCalledTimes(2); }); - it('keeps defaults when the shared request fails, and never leaves the failure cached', async () => { + it('CONTROL: a failed pre-boot request still lets app-shell fetch once', async () => { const fetchStub = vi .fn() .mockResolvedValueOnce(jsonResponse(null, 503)) @@ -160,22 +188,29 @@ describe('one cold load, one runtime/config request', () => { vi.stubGlobal('fetch', fetchStub); runEarlyBranding(); - // The rejection reaches app-shell, which absorbs it exactly as it absorbed a - // non-2xx before — defaults intact, boot uninterrupted. + await settle(); + // The failure is not reused: app-shell asks the server itself, as it did + // before there was any handoff. await initRuntimeConfig(''); - expect(fetchStub).toHaveBeenCalledTimes(1); - expect(getRuntimeConfig().branding.productName).toBe('ObjectOS'); + expect(fetchStub).toHaveBeenCalledTimes(2); + expect(getRuntimeConfig().branding.productName).toBe('Acme Ops'); + }); + + it('CONTROL: a failure on both asks keeps the defaults, boot uninterrupted', async () => { + const fetchStub = vi.fn(async () => jsonResponse(null, 503)); + vi.stubGlobal('fetch', fetchStub); + runEarlyBranding(); await initRuntimeConfig(''); expect(fetchStub).toHaveBeenCalledTimes(2); - expect(getRuntimeConfig().branding.productName).toBe('Acme Ops'); + expect(getRuntimeConfig().branding.productName).toBe('ObjectOS'); }); }); /** * objectui#5660 — one page, one server origin. * - * Sharing keys on the FULL URL (`inflightGetKey`), so the dedup above only ever + * Sharing keys on the FULL URL (the slot carries the URL it asked), so the dedup above only ever * fires while both callers agree on the origin. They did not: this script read * `window.__CONSOLE_SERVER_URL`, which nothing in the repository sets, while * `main.tsx` reads `import.meta.env.VITE_SERVER_URL`. Same-origin production diff --git a/apps/console/src/__tests__/sessionBootDedup.test.tsx b/apps/console/src/__tests__/sessionBootDedup.test.tsx new file mode 100644 index 0000000000..a1c4b90e9f --- /dev/null +++ b/apps/console/src/__tests__/sessionBootDedup.test.tsx @@ -0,0 +1,143 @@ +/** + * objectui#12808 — one console open, one `GET /api/v1/auth/get-session`. + * + * The card measured two before the first app render: the pre-React preflight + * (`lib/auth-preflight.ts`, awaited in `main.tsx`) validating the stored Bearer, + * then `AuthProvider`'s own mount asking the same question with the same Bearer. + * The preflight now hands its answer on, and `AuthProvider` takes it, once, for + * its first state. + * + * These run the REAL preflight and the REAL `AuthProvider` with its own + * better-auth client — no injected client double — over one stubbed `fetch`, so + * the count is of requests that would reach the wire, and the slot the two + * sides spell by hand is exercised from both ends. + */ + +import React, { useLayoutEffect } from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor, act, cleanup } from '@testing-library/react'; +import { AuthProvider, useAuth } from '@object-ui/auth'; +import { preflightAuth } from '../lib/auth-preflight'; + +const AUTH_URL = '/api/v1/auth'; +const TOKEN = 'tok_valid'; + +const SESSION_BODY = { + user: { id: 'u_1', name: 'Ada', email: 'ada@example.test' }, + session: { id: 's_1', userId: 'u_1', token: TOKEN, expiresAt: '2099-01-01T00:00:00.000Z' }, +}; + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +let sessionAnswer: () => unknown = () => SESSION_BODY; +const fetchStub = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.includes('/get-session')) return json(sessionAnswer()); + if (url.includes('/sign-out')) return json({ success: true }); + if (url.includes('/sign-in/email')) return json({ user: SESSION_BODY.user, token: TOKEN }); + if (url.includes('/organization/')) return json([]); + return json(null); +}); + +function getSessionCalls(): number { + return fetchStub.mock.calls.filter(([input]) => String(input instanceof Request ? input.url : input).includes('/get-session')).length; +} + +const probe: { current: ReturnType | null } = { current: null }; +function Probe({ sink }: { sink: typeof probe }) { + const value = useAuth(); + useLayoutEffect(() => { + sink.current = value; + }); + return {value.isLoading ? 'loading' : value.user?.name ?? 'nobody'}; +} + +function mountConsole() { + return render( + + + + + , + ); +} + +beforeEach(() => { + vi.stubGlobal('fetch', fetchStub); + fetchStub.mockClear(); + sessionAnswer = () => SESSION_BODY; + localStorage.clear(); + delete (globalThis as unknown as Record)[Symbol.for('objectui.authPreflightSession')]; + probe.current = null; +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe('one console open, one get-session (objectui#12808)', () => { + it('the preflight plus AuthProvider\'s first render ask once', async () => { + localStorage.setItem('auth-session-token', TOKEN); + + // The real boot order: `main.tsx` awaits the preflight, then renders. + await preflightAuth(AUTH_URL); + expect(getSessionCalls()).toBe(1); + + mountConsole(); + expect(await screen.findByText('Ada')).toBeTruthy(); + // Let every mount effect (and StrictMode's replay of them) run out. + await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + + expect(getSessionCalls()).toBe(1); + expect(probe.current?.isAuthenticated).toBe(true); + // Parsed as `getSession()` parses the wire body: dates revived. + expect(probe.current?.session?.expiresAt).toBeInstanceOf(Date); + }); + + it('CONTROL: a sign-out then sign-in reads the session again', async () => { + localStorage.setItem('auth-session-token', TOKEN); + await preflightAuth(AUTH_URL); + mountConsole(); + await screen.findByText('Ada'); + expect(getSessionCalls()).toBe(1); + + await act(async () => { await probe.current!.signOut(); }); + expect(screen.getByTestId('who').textContent).toBe('nobody'); + await act(async () => { await probe.current!.signIn('ada@example.test', 'pw'); }); + await act(async () => { await probe.current!.refreshSession(); }); + + // The handoff was the first state only: the next read went to the server. + expect(getSessionCalls()).toBe(2); + expect(screen.getByTestId('who').textContent).toBe('Ada'); + }); + + it('CONTROL: the answer is consumed once — a second provider asks the server', async () => { + localStorage.setItem('auth-session-token', TOKEN); + await preflightAuth(AUTH_URL); + const first = mountConsole(); + await screen.findByText('Ada'); + first.unmount(); + + mountConsole(); + await screen.findByText('Ada'); + await waitFor(() => expect(getSessionCalls()).toBeGreaterThanOrEqual(2)); + }); + + it('CONTROL: a stale token hands nothing on, and AuthProvider asks itself', async () => { + localStorage.setItem('auth-session-token', 'tok_stale'); + sessionAnswer = () => null; + + await preflightAuth(AUTH_URL); + expect(localStorage.getItem('auth-session-token')).toBeNull(); + + mountConsole(); + await waitFor(() => expect(screen.getByTestId('who').textContent).toBe('nobody')); + expect(getSessionCalls()).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/apps/console/src/lib/auth-preflight.ts b/apps/console/src/lib/auth-preflight.ts index 4d66bc766f..6a0d72903e 100644 --- a/apps/console/src/lib/auth-preflight.ts +++ b/apps/console/src/lib/auth-preflight.ts @@ -30,6 +30,17 @@ * * Idempotent, runs once per page load, < 50 ms when the token is valid * (single round-trip), no UI flicker (happens before render). + * + * # The answer is handed on (objectui#12808) + * + * When the token is valid this request already IS the session `AuthProvider` + * would ask for next: the server prefers the Bearer over the cookie, so adding + * the cookie cannot change the answer. The body is left on `globalThis` + * under `Symbol.for('objectui.authPreflightSession')`, with the auth URL and the + * token it was asked with, and `AuthProvider` takes it, once, for its first + * state (`takePreflightSession` in `@object-ui/auth`'s `createAuthClient.ts`) + * instead of issuing a second `get-session`. A stale or unanswered token leaves + * nothing behind, and every later session read asks the server as before. */ const TOKEN_KEY = 'auth-session-token'; @@ -43,6 +54,8 @@ const TOKEN_KEY = 'auth-session-token'; * component closure into the eager entry chunk. */ const ACTIVE_ORG_KEY_BASE = 'auth-active-organization-id'; +/** Where the valid answer is handed to `AuthProvider` — see the header. */ +const PREFLIGHT_SESSION_KEY = Symbol.for('objectui.authPreflightSession'); /** * Remove the stale active-organization id under every spelling — the bare @@ -98,7 +111,16 @@ export async function preflightAuth(authBaseUrl: string): Promise { if (res.ok) { const body = await res.json().catch(() => null); const user = body?.user ?? body?.data?.user ?? null; - if (user) return; // token valid — leave localStorage alone + if (user) { + // Token valid — leave localStorage alone, and hand the answer on, as + // JSON text so the taker parses it exactly as it parses the wire body. + (globalThis as unknown as Record)[PREFLIGHT_SESSION_KEY] = { + authUrl: authBaseUrl, + token, + body: JSON.stringify(body), + }; + return; + } } // 401, 4xx, or 200-with-null — token is stale. Purge. diff --git a/packages/app-shell/src/console/home/CloudOnboardingNext.tsx b/packages/app-shell/src/console/home/CloudOnboardingNext.tsx index 71733d2b7f..d3cf3d73f6 100644 --- a/packages/app-shell/src/console/home/CloudOnboardingNext.tsx +++ b/packages/app-shell/src/console/home/CloudOnboardingNext.tsx @@ -26,19 +26,23 @@ * * Routes and the "Open workspace" endpoint come from the page metadata * (`properties`) so the Cloud app owns its URLs; this component owns only the - * state logic. It mirrors `useEnvironmentEntitlements`' authoritative summary - * fetch (org-scoped GET) without its list-only gating. + * state logic. The signal is read through `useEnvironmentEntitlements`, the + * resource the pricing page's plan cards and the workspace-timezone notice share, + * so this block adds no request of its own (objectui#12808). Only the + * authoritative summary resolves it: the hook's row-derived fallback reads as + * `unknown` here, exactly as a failed summary always has. */ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Button, Skeleton } from '@object-ui/components'; import { Rocket, Plus, Settings2 } from 'lucide-react'; -import { useAuth } from '@object-ui/auth'; import { createAuthenticatedFetch } from '@object-ui/auth'; import { ComponentRegistry } from '@object-ui/core'; import { useObjectTranslation } from '@object-ui/i18n'; import { navRunActionHref } from '../../hooks/useNavRunAction.js'; +import { useAdapter } from '../../providers/AdapterProvider.js'; +import { useEnvironmentEntitlements } from '../../environment/useEnvironmentEntitlements.js'; interface CloudOnboardingNextProps { properties?: { @@ -81,63 +85,39 @@ const DEFAULT_ENVIRONMENTS_ROUTE = '/apps/cloud_control/sys_environment'; */ const DEFAULT_CREATE_ACTION = 'create_environment'; -/** Resolve the active locale's string (cheap; the page uses {en,zh} pairs). */ /** * Resolve `hasProductionEnv` from the org-scoped entitlements summary. Returns * `unknown` on any failure so the caller degrades gracefully rather than * blocking the user behind a wrong "create" CTA. */ function useProductionEnvState(warmUrl?: string): Resolved { - const { activeOrganization } = useAuth(); - const orgId = activeOrganization?.id; - const authFetch = useMemo(() => createAuthenticatedFetch(), []); - const [state, setState] = useState({ phase: 'loading' }); - + const dataSource = useAdapter(); + // `useState`, not `useMemo`: the warm effect below keys on this function, so + // its identity has to be one React guarantees to keep (AGENTS.md #10). + const [authFetch] = useState(() => createAuthenticatedFetch()); + const apiBase = ((import.meta.env.VITE_SERVER_URL as string | undefined) || '').replace(/\/+$/, ''); + const entitlements = useEnvironmentEntitlements({ enabled: true, dataSource, authFetch, apiBase }); + + // Strict: only the authoritative summary resolves the signal (cloud#1046's + // `{ success, data }` envelope, read by the hook). A row-derived or failed + // answer is `unknown`, which degrades to both actions and always works. + const state: Resolved = entitlements === null + ? { phase: 'loading' } + : entitlements.source === 'summary' + ? { phase: 'ready', hasProductionEnv: entitlements.hasProductionEnv } + : { phase: 'unknown' }; + + // Pre-warm the prod env the instant we know it exists — fire-and-forget, + // best-effort, once per resolve. The user is now reading the hint and will + // click "Open workspace" seconds later; by then the container is already + // waking. The sso-open click warms again, so a failed warm here costs + // nothing. Only when the page passes a warmUrl (else no-op). Keyed on the + // answer object itself, so each resolution warms once. useEffect(() => { - let cancelled = false; - const apiBase = ((import.meta as any).env?.VITE_SERVER_URL || '').replace(/\/+$/, ''); - const qs = orgId ? `?organizationId=${encodeURIComponent(orgId)}` : ''; - (async () => { - try { - const res = await authFetch(`${apiBase}/api/v1/cloud/environment-entitlements${qs}`, { - method: 'GET', - credentials: 'include', - }); - if (!res.ok) throw new Error(`entitlements ${res.status}`); - // Strict envelope read: the control plane wraps every success body as - // `{ success, data }` (cloud#1046). A bare body is a producer contract - // violation, not a second accepted dialect — reading it would resolve - // `hasProductionEnv` to `false` from an absent field and strand a user - // who HAS a production env behind a wrong "create" CTA. Unresolvable - // ⇒ `unknown`, which degrades to both actions and always works. - const json = (await res.json().catch(() => null)) as - | { data?: { hasProductionEnv?: boolean } } - | null; - const data = json?.data; - if (cancelled) return; - if (!data || typeof data !== 'object') { - setState({ phase: 'unknown' }); - return; - } - const hasProductionEnv = data.hasProductionEnv === true; - setState({ phase: 'ready', hasProductionEnv }); - // Pre-warm the prod env the instant we know it exists — fire-and-forget, - // best-effort, once per resolve. The user is now reading the hint and - // will click "Open workspace" seconds later; by then the container is - // already waking. The sso-open click warms again, so a failed warm here - // costs nothing. Only when the page passes a warmUrl (else no-op). - if (hasProductionEnv && warmUrl) { - void authFetch(`${apiBase}${warmUrl}`, { method: 'GET', credentials: 'include' }) - .catch(() => { /* pre-warm is best-effort */ }); - } - } catch { - if (!cancelled) setState({ phase: 'unknown' }); - } - })(); - return () => { - cancelled = true; - }; - }, [authFetch, orgId, warmUrl]); + if (entitlements?.source !== 'summary' || !entitlements.hasProductionEnv || !warmUrl) return; + void authFetch(`${apiBase}${warmUrl}`, { method: 'GET', credentials: 'include' }) + .catch(() => { /* pre-warm is best-effort */ }); + }, [entitlements, warmUrl, authFetch, apiBase]); return state; } diff --git a/packages/app-shell/src/console/home/CloudPlanStatus.tsx b/packages/app-shell/src/console/home/CloudPlanStatus.tsx index 08d1066213..4755b6066b 100644 --- a/packages/app-shell/src/console/home/CloudPlanStatus.tsx +++ b/packages/app-shell/src/console/home/CloudPlanStatus.tsx @@ -56,8 +56,9 @@ export interface CloudPlanStatusProps { export function CloudPlanStatus({ properties, className }: CloudPlanStatusProps) { const { t } = useObjectTranslation(); const dataSource = useAdapter(); - // `useState`, not `useMemo`: the hook keys its fetch effect on this function, - // so its identity has to be one React guarantees to keep (AGENTS.md #10). + // `useState`, not `useMemo`: one function per mount, with an identity React + // guarantees to keep (AGENTS.md #10). The hook uses it only when this reader + // is the one that starts the shared resolution (objectui#12808). const [authFetch] = useState(() => createAuthenticatedFetch()); const entitlements = useEnvironmentEntitlements({ enabled: true, diff --git a/packages/app-shell/src/console/home/CloudWorkspaceTimezoneNotice.tsx b/packages/app-shell/src/console/home/CloudWorkspaceTimezoneNotice.tsx index 182b3c7c51..86024606e9 100644 --- a/packages/app-shell/src/console/home/CloudWorkspaceTimezoneNotice.tsx +++ b/packages/app-shell/src/console/home/CloudWorkspaceTimezoneNotice.tsx @@ -48,8 +48,9 @@ export interface CloudWorkspaceTimezoneNoticeProps { export function CloudWorkspaceTimezoneNotice({ className }: CloudWorkspaceTimezoneNoticeProps) { const { t } = useObjectTranslation(); const dataSource = useAdapter(); - // `useState`, not `useMemo`: the hook keys its fetch effect on this function, - // so its identity has to be one React guarantees to keep (AGENTS.md #10). + // `useState`, not `useMemo`: one function per mount, with an identity React + // guarantees to keep (AGENTS.md #10). The hook uses it only when this reader + // is the one that starts the shared resolution (objectui#12808). const [authFetch] = useState(() => createAuthenticatedFetch()); const entitlements = useEnvironmentEntitlements({ enabled: true, diff --git a/packages/app-shell/src/environment/__tests__/useEnvironmentEntitlements.oneRequest-12808.test.tsx b/packages/app-shell/src/environment/__tests__/useEnvironmentEntitlements.oneRequest-12808.test.tsx new file mode 100644 index 0000000000..5f004fddd1 --- /dev/null +++ b/packages/app-shell/src/environment/__tests__/useEnvironmentEntitlements.oneRequest-12808.test.tsx @@ -0,0 +1,143 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12808 — one `GET /cloud/environment-entitlements` per organisation, + * however many readers are mounted. + * + * The Cloud pricing page mounts three `cloud:plan-status` nodes (one per plan + * card) and the Welcome page mounts `cloud:onboarding-next` beside + * `cloud:workspace-timezone-notice`. Each used to issue its own GET, and Chrome + * serialises identical in-flight GETs, so the requests cost one round trip each, + * in series. Every reader now shares one organisation-keyed resource. + * + * The count is taken at the one seam every reader's request goes through + * (`createAuthenticatedFetch`), with the five real widgets mounted together. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, waitFor, cleanup, act, renderHook } from '@testing-library/react'; +import React from 'react'; + +let orgId = 'org_1'; +const entitlementGets: string[] = []; + +vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() })); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ activeOrganization: { id: orgId } }), + createAuthenticatedFetch: () => async (url: string) => { + if (url.includes('/cloud/environment-entitlements')) entitlementGets.push(url); + const org = new URL(url, 'http://x.test').searchParams.get('organizationId'); + return { + ok: true, + status: 200, + json: async () => ({ + success: true, + data: { + plan: org === 'org_2' ? 'pro' : 'free', + hasProductionEnv: true, + workspaceTimezoneSeed: 'Asia/Shanghai', + }, + }), + }; + }, +})); + +import { I18nProvider } from '@object-ui/i18n'; +import { AdapterCtx } from '@object-ui/react'; +import { CloudPlanStatus } from '../../console/home/CloudPlanStatus'; +import { CloudOnboardingNext } from '../../console/home/CloudOnboardingNext'; +import { CloudWorkspaceTimezoneNotice } from '../../console/home/CloudWorkspaceTimezoneNotice'; +import { useEnvironmentEntitlements } from '../useEnvironmentEntitlements'; + +const adapter = { find: vi.fn().mockResolvedValue([]) }; + +function Page() { + return ( + + + + + + + + + + ); +} + +/** Let any request a mount would still start get started and settled. */ +const flush = () => act(async () => { await new Promise((r) => setTimeout(r, 0)); }); + +afterEach(() => { + cleanup(); + entitlementGets.length = 0; + orgId = 'org_1'; +}); + +describe('one entitlements request per organisation (objectui#12808)', () => { + it('five readers mounted together issue one GET, and every reader gets the answer', async () => { + render(); + + expect(await screen.findByText('Current plan')).toBeTruthy(); + expect(await screen.findByText(/Asia\/Shanghai/)).toBeTruthy(); + await waitFor(() => expect(document.querySelector('[data-onboarding="ready"]')).toBeTruthy()); + await flush(); + + expect(entitlementGets).toEqual(['/api/v1/cloud/environment-entitlements?organizationId=org_1']); + }); + + it('CONTROL: an organisation change issues exactly one more', async () => { + const view = render(); + await screen.findByText('Current plan'); + await flush(); + expect(entitlementGets).toHaveLength(1); + + orgId = 'org_2'; + view.rerender(); + // The `pro` card is the one marked now: every reader moved to org_2's answer. + await waitFor(() => + expect(document.querySelectorAll('[data-plan-status="current"]')).toHaveLength(1), + ); + await flush(); + + expect(entitlementGets).toEqual([ + '/api/v1/cloud/environment-entitlements?organizationId=org_1', + '/api/v1/cloud/environment-entitlements?organizationId=org_2', + ]); + }); + + it('CONTROL: a refreshKey change re-resolves once, for every reader', async () => { + const authFetch = (await import('@object-ui/auth')).createAuthenticatedFetch(); + const opts = { enabled: true, dataSource: adapter, authFetch, apiBase: '' }; + const other = renderHook(() => useEnvironmentEntitlements(opts)); + const list = renderHook(({ refreshKey }) => useEnvironmentEntitlements({ ...opts, refreshKey }), { + initialProps: { refreshKey: 0 }, + }); + await waitFor(() => expect(list.result.current?.source).toBe('summary')); + await flush(); + expect(entitlementGets).toHaveLength(1); + const before = other.result.current; + + list.rerender({ refreshKey: 1 }); + await waitFor(() => expect(other.result.current).not.toBe(before)); + await flush(); + + expect(entitlementGets).toHaveLength(2); + expect(list.result.current).toBe(other.result.current); + }); + + it('CONTROL: the answer does not outlive its readers — a later mount asks again', async () => { + const first = render(); + await screen.findByText('Current plan'); + await flush(); + first.unmount(); + await flush(); + + render(); + await screen.findByText('Current plan'); + await flush(); + + expect(entitlementGets).toHaveLength(2); + }); +}); diff --git a/packages/app-shell/src/environment/useEnvironmentEntitlements.ts b/packages/app-shell/src/environment/useEnvironmentEntitlements.ts index 96223d7f75..4f2ac04979 100644 --- a/packages/app-shell/src/environment/useEnvironmentEntitlements.ts +++ b/packages/app-shell/src/environment/useEnvironmentEntitlements.ts @@ -17,9 +17,26 @@ * * Returns `null` when disabled (not the environment list) so callers can cheaply * branch. Re-resolves when `refreshKey` changes (e.g. after a create). + * + * ## One request per organisation, however many readers (objectui#12808) + * + * Every reader of this hook shares ONE resource per (control-plane origin, + * organisation): the in-flight resolution and its settled answer. The Cloud + * pricing page mounts one `cloud:plan-status` per plan card and the Welcome + * page mounts `cloud:onboarding-next` beside `cloud:workspace-timezone-notice`; + * each used to issue its own GET, and Chrome serialises identical in-flight + * GETs, so three cards paid three round trips in series. + * + * Freshness is unchanged, because nothing here is time-based: + * - the answer is held only while at least one reader is mounted. When the + * last one unmounts the resource is dropped, so a later mount asks the + * server again, exactly as every mount did before; + * - a different organisation is a different resource; + * - a reader whose `refreshKey` changes (the environment list after a create) + * re-resolves the shared resource, and every reader receives the new answer. */ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useAuth } from '@object-ui/auth'; import type { EnvironmentEntitlementsState, EnvironmentEntitlementsSummary } from './entitlements.js'; @@ -45,6 +62,136 @@ export interface UseEnvironmentEntitlementsOptions { refreshKey?: unknown; } +type EntitlementsResolveOptions = Pick & { + orgId: string | undefined; +}; + +/** The authoritative summary first, the row derivation when it is unavailable. */ +async function resolveEntitlements(opts: EntitlementsResolveOptions): Promise { + const { dataSource, authFetch, apiBase, orgId } = opts; + + const fromSummary = async (): Promise => { + try { + const base = (apiBase || '').replace(/\/+$/, ''); + const qs = orgId ? `?organizationId=${encodeURIComponent(orgId)}` : ''; + const res = await authFetch(`${base}/api/v1/cloud/environment-entitlements${qs}`, { + method: 'GET', + credentials: 'include', + }); + if (!res.ok) return null; + // Strict envelope read: the control plane wraps every success body as + // `{ success, data }` (cloud#1046), and `EnvironmentEntitlementsSummary` + // is declared as that `data` payload. A bare body is therefore a + // producer contract violation, not a second accepted dialect — reading + // it would yield a summary whose fields are all `undefined`, i.e. a + // silent `hasProductionEnv: false`. Returning null instead degrades to + // the `fromRows()` derivation, which resolves that signal for real. + const json = (await res.json().catch(() => null)) as + | { data?: EnvironmentEntitlementsSummary } + | null; + const data = json?.data; + if (!data || typeof data !== 'object') return null; + return { + ready: true, + hasProductionEnv: data.hasProductionEnv === true, + canCreateDevelopmentEnv: data.development?.canCreate, + plan: data.plan, + // Verbatim, like `contactSalesUrl`: absent stays absent, and the + // toolbar's locked plan-tier mark is then drawn unlinked + // (objectui#10437, objectui#12190). + upgradeUrl: data.upgradeUrl, + contactSalesUrl: data.contactSalesUrl, + // Verbatim, like `upgradeUrl`: absent stays absent, and the + // workspace-timezone notice then renders nothing (objectui#11930). + workspaceTimezoneSeed: data.workspaceTimezoneSeed, + source: 'summary', + }; + } catch { + return null; + } + }; + + const fromRows = async (): Promise => { + try { + const params: any = { $top: 200 }; + if (orgId) params.$filter = { organization_id: orgId }; + const res = await dataSource.find('sys_environment', params); + const rows: any[] = Array.isArray(res) ? res : res?.data ?? []; + const active = rows.filter((r) => !TERMINAL_STATUSES.has(String(r?.status ?? ''))); + const hasProductionEnv = active.some((r) => classifyEnvironmentType(r) === 'production'); + return { + ready: true, + hasProductionEnv, + canCreateDevelopmentEnv: undefined, + source: 'derived', + }; + } catch { + return { + ready: false, + hasProductionEnv: false, + canCreateDevelopmentEnv: undefined, + source: 'unknown', + }; + } + }; + + return (await fromSummary()) ?? (await fromRows()); +} + +/** + * The shared resource for one (origin, organisation). `value` is the settled + * answer (`undefined` until the first resolution lands); `generation` lets a + * superseded or dropped resolution discard its answer instead of writing it. + */ +interface EntitlementsResource { + value: EnvironmentEntitlementsState | undefined; + inFlight: boolean; + generation: number; + readers: number; + listeners: Set<() => void>; +} + +const resources = new Map(); + +function resolveInto(resource: EntitlementsResource, opts: EntitlementsResolveOptions): void { + const generation = ++resource.generation; + resource.inFlight = true; + void resolveEntitlements(opts).then((next) => { + if (resource.generation !== generation) return; + resource.inFlight = false; + resource.value = next; + for (const listener of Array.from(resource.listeners)) listener(); + }); +} + +/** Join the resource for `key`, starting its resolution only if nobody has. */ +function acquire(key: string, opts: EntitlementsResolveOptions): EntitlementsResource { + let resource = resources.get(key); + if (!resource) { + resource = { value: undefined, inFlight: false, generation: 0, readers: 0, listeners: new Set() }; + resources.set(key, resource); + } + resource.readers += 1; + if (resource.value === undefined && !resource.inFlight) resolveInto(resource, opts); + return resource; +} + +/** + * Leave the resource. The drop waits for the end of the current task, so an + * unmount and a mount committed together (a route change, React StrictMode's + * effect replay) hand the answer over instead of asking the server twice; it is + * not a timer, and nothing outlives the readers. + */ +function release(key: string, resource: EntitlementsResource): void { + resource.readers -= 1; + if (resource.readers > 0) return; + queueMicrotask(() => { + if (resource.readers > 0 || resources.get(key) !== resource) return; + resources.delete(key); + resource.generation += 1; + }); +} + export function useEnvironmentEntitlements( opts: UseEnvironmentEntitlementsOptions, ): EnvironmentEntitlementsState | null { @@ -53,86 +200,46 @@ export function useEnvironmentEntitlements( const orgId = activeOrganization?.id; const [state, setState] = useState(null); + // The resolution runs with whichever reader starts it, so these are read at + // that moment rather than keyed on: two readers' fetch functions are + // interchangeable, and keying an effect on a function identity is what + // AGENTS.md #10 rules out. + const resolveDeps = useRef({ dataSource, authFetch }); + useEffect(() => { + resolveDeps.current = { dataSource, authFetch }; + }); + const lastRefresh = useRef<{ key: string; refreshKey: unknown } | null>(null); + + const key = `${(apiBase || '').replace(/\/+$/, '')}\n${orgId ?? ''}`; + useEffect(() => { if (!enabled) { + lastRefresh.current = null; setState(null); return; } - let cancelled = false; - - const fromSummary = async (): Promise => { - try { - const base = (apiBase || '').replace(/\/+$/, ''); - const qs = orgId ? `?organizationId=${encodeURIComponent(orgId)}` : ''; - const res = await authFetch(`${base}/api/v1/cloud/environment-entitlements${qs}`, { - method: 'GET', - credentials: 'include', - }); - if (!res.ok) return null; - // Strict envelope read: the control plane wraps every success body as - // `{ success, data }` (cloud#1046), and `EnvironmentEntitlementsSummary` - // is declared as that `data` payload. A bare body is therefore a - // producer contract violation, not a second accepted dialect — reading - // it would yield a summary whose fields are all `undefined`, i.e. a - // silent `hasProductionEnv: false`. Returning null instead degrades to - // the `fromRows()` derivation, which resolves that signal for real. - const json = (await res.json().catch(() => null)) as - | { data?: EnvironmentEntitlementsSummary } - | null; - const data = json?.data; - if (!data || typeof data !== 'object') return null; - return { - ready: true, - hasProductionEnv: data.hasProductionEnv === true, - canCreateDevelopmentEnv: data.development?.canCreate, - plan: data.plan, - // Verbatim, like `contactSalesUrl`: absent stays absent, and the - // toolbar's locked plan-tier mark is then drawn unlinked - // (objectui#10437, objectui#12190). - upgradeUrl: data.upgradeUrl, - contactSalesUrl: data.contactSalesUrl, - // Verbatim, like `upgradeUrl`: absent stays absent, and the - // workspace-timezone notice then renders nothing (objectui#11930). - workspaceTimezoneSeed: data.workspaceTimezoneSeed, - source: 'summary', - }; - } catch { - return null; - } - }; - - const fromRows = async (): Promise => { - try { - const params: any = { $top: 200 }; - if (orgId) params.$filter = { organization_id: orgId }; - const res = await dataSource.find('sys_environment', params); - const rows: any[] = Array.isArray(res) ? res : res?.data ?? []; - const active = rows.filter((r) => !TERMINAL_STATUSES.has(String(r?.status ?? ''))); - const hasProductionEnv = active.some((r) => classifyEnvironmentType(r) === 'production'); - return { - ready: true, - hasProductionEnv, - canCreateDevelopmentEnv: undefined, - source: 'derived', - }; - } catch { - return { - ready: false, - hasProductionEnv: false, - canCreateDevelopmentEnv: undefined, - source: 'unknown', - }; - } - }; + const resolveOpts = (): EntitlementsResolveOptions => ({ ...resolveDeps.current, apiBase, orgId }); + const resource = acquire(key, resolveOpts()); - (async () => { - const summary = await fromSummary(); - const next = summary ?? (await fromRows()); - if (!cancelled) setState(next); - })(); + // The explicit invalidation: this reader's `refreshKey` moved while it kept + // reading the same organisation, so the shared answer is re-resolved for + // every reader, not only for this one. + const previous = lastRefresh.current; + lastRefresh.current = { key, refreshKey }; + if (previous && previous.key === key && !Object.is(previous.refreshKey, refreshKey)) { + resolveInto(resource, resolveOpts()); + } - return () => { cancelled = true; }; - }, [enabled, orgId, apiBase, authFetch, dataSource, refreshKey]); + const sync = () => { + if (resource.value !== undefined) setState(resource.value); + }; + resource.listeners.add(sync); + sync(); + return () => { + resource.listeners.delete(sync); + release(key, resource); + }; + }, [enabled, key, apiBase, orgId, refreshKey]); return state; } diff --git a/packages/app-shell/src/runtime-config.ts b/packages/app-shell/src/runtime-config.ts index 439a89c9ee..a28954ee18 100644 --- a/packages/app-shell/src/runtime-config.ts +++ b/packages/app-shell/src/runtime-config.ts @@ -346,6 +346,40 @@ function applyUpdate(patch: Partial): void { }; } +/** + * The slot `apps/console/index.html`'s pre-boot branding script leaves its + * `GET /api/v1/runtime/config` in: the URL it asked and the parsed-body promise, + * kept for the page's lifetime. A classic inline script cannot import, so the + * slot is spelled by hand on both sides and `runtimeConfigBootDedup.test.ts` + * runs the shipped script against this reader. + */ +const EARLY_RUNTIME_CONFIG_KEY = Symbol.for('objectui.earlyRuntimeConfig'); + +interface EarlyRuntimeConfigSlot { + url: string; + body: Promise | null>; + taken?: boolean; +} + +/** + * Take the pre-boot script's answer, once per page load, when it asked the same + * URL. Resolves `undefined` when there is nothing to take — no script, another + * URL, already taken, or an early fetch that failed — and the caller fetches. + */ +async function takeEarlyRuntimeConfig( + url: string, +): Promise | null | undefined> { + const holder = globalThis as unknown as Record; + const slot = holder[EARLY_RUNTIME_CONFIG_KEY]; + if (!slot || slot.taken || slot.url !== url) return undefined; + slot.taken = true; + try { + return await slot.body; + } catch { + return undefined; + } +} + /** * Fetch the server-pushed runtime config and merge it into the singleton. * Must be awaited before first render so consumers see definitive values @@ -356,24 +390,26 @@ function applyUpdate(patch: Partial): void { * In production both Console SPA and tenant runtime share an origin so * the default (relative `/api/v1/...`) works. * - * Goes through {@link sharedGetJson} because this is NOT the page's first ask - * for this URL (objectui#5544): the inline branding script in - * `apps/console/index.html` starts the identical request during HTML parse, well - * before this module chunk is even fetched. Both were measured on prod and - * staging, and this one is on the critical path — the console awaits it before - * `createRoot().render()`. Joining the earlier request removes a whole - * control-plane round trip AND lets this await settle sooner, since it inherits - * a request that started first. In-flight only: with nothing in flight this - * fetches normally, and the repeat-call contract above is unchanged, because the - * registry entry is gone by the time any later call arrives. + * This is NOT the page's first ask for this URL (objectui#5544): the inline + * branding script in `apps/console/index.html` starts the identical request + * during HTML parse, well before this module chunk is even fetched, and this one + * is on the critical path — the console awaits it before `createRoot().render()`. + * So the first call takes that script's answer instead of asking again + * (objectui#12808, see {@link takeEarlyRuntimeConfig}); a failed early fetch, or + * a page without the script, fetches here through {@link sharedGetJson}, and + * every later call re-fetches, so the repeat-call contract above is unchanged. */ export async function initRuntimeConfig(baseUrl: string = ''): Promise { const base = (baseUrl || '').replace(/\/+$/, ''); + const url = `${base}/api/v1/runtime/config`; try { - const body = await sharedGetJson | null>( - `${base}/api/v1/runtime/config`, - { credentials: 'include', headers: { Accept: 'application/json' } }, - ); + const early = await takeEarlyRuntimeConfig(url); + const body = early !== undefined + ? early + : await sharedGetJson | null>( + url, + { credentials: 'include', headers: { Accept: 'application/json' } }, + ); if (!body || typeof body !== 'object') return; applyUpdate({ cloudUrl: typeof body.cloudUrl === 'string' ? body.cloudUrl.replace(/\/+$/, '') : current.cloudUrl, diff --git a/packages/auth/src/AuthProvider.tsx b/packages/auth/src/AuthProvider.tsx index 8f04201076..407c90f3b9 100644 --- a/packages/auth/src/AuthProvider.tsx +++ b/packages/auth/src/AuthProvider.tsx @@ -10,7 +10,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { authGateEvents } from './auth-gate-events.js'; import type { AuthUser, AuthClient, AuthProviderOptions, PreviewModeOptions, AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthPublicConfig, SignInWithProviderOptions } from './types.js'; import { AuthCtx, type AuthContextValue } from './AuthContext.js'; -import { createAuthClient, TokenStorage } from './createAuthClient.js'; +import { createAuthClient, takePreflightSession, TokenStorage } from './createAuthClient.js'; import { ActiveOrganizationStorage, SessionUserScope } from './ActiveOrganizationStorage.js'; /** @@ -172,9 +172,22 @@ export function AuthProvider({ [externalClient, authUrl], ); - const [user, setUser] = useState(null); - const [session, setSession] = useState(null); - const [isLoading, setIsLoading] = useState(true); + // objectui#12808 — the console's pre-React preflight already asked + // `get-session` with the stored Bearer. When that answer is still the one we + // would get, it IS the first state, and the mount below does not ask again. + // Taken once per page load, in an initializer so it runs once per instance + // (StrictMode keeps the first call's result). Only for a provider that builds + // its own client from `authUrl`: an injected client may talk to another server. + const [bootSession] = useState(() => + (enabled && previewMode == null && !externalClient ? takePreflightSession(authUrl) : null), + ); + const [user, setUser] = useState(() => bootSession?.user ?? null); + const [session, setSession] = useState(() => bootSession?.session ?? null); + const [isLoading, setIsLoading] = useState(() => bootSession === null); + // What the boot answer was taken for. The mount effect skips its load while + // it still holds (a StrictMode effect replay included); the first run that + // finds anything else drops it for good, so every later run loads as before. + const bootSessionFor = useRef(bootSession ? authUrl : null); const [error, setError] = useState(null); const [remediationRequired, setRemediationRequired] = useState<{ code: string; message: string } | null>(null); @@ -292,6 +305,9 @@ export function AuthProvider({ // Load session on mount (only if auth is enabled and not in preview mode) useEffect(() => { + if (isPreviewMode || !enabled || externalClient || bootSessionFor.current !== authUrl) { + bootSessionFor.current = null; + } if (isPreviewMode) { // Preview mode: simulate a user based on previewMode config const role = previewMode.simulatedRole ?? 'admin'; @@ -337,10 +353,13 @@ export function AuthProvider({ return; } + // The first state already holds the preflight's answer (see `bootSession`). + if (bootSessionFor.current !== null) return; + let cancelled = false; loadSession(() => cancelled); return () => { cancelled = true; }; - }, [client, enabled, isPreviewMode, previewMode, loadSession]); + }, [client, enabled, isPreviewMode, previewMode, loadSession, authUrl, externalClient]); // Notify on auth state changes useEffect(() => { diff --git a/packages/auth/src/createAuthClient.ts b/packages/auth/src/createAuthClient.ts index 631e7ae6c6..188ff42c9f 100644 --- a/packages/auth/src/createAuthClient.ts +++ b/packages/auth/src/createAuthClient.ts @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { createAuthClient as createBetterAuthClient } from 'better-auth/client'; +import { createAuthClient as createBetterAuthClient, parseJSON } from 'better-auth/client'; import { organizationClient, twoFactorClient } from 'better-auth/client/plugins'; import type { AuthClient, AuthClientConfig, AuthUser, AuthClientSession, SignInCredentials, SignUpData, @@ -229,6 +229,53 @@ function resolveResetPasswordRedirect(): string { return '/reset-password'; } +/** + * The slot the console's pre-React auth preflight (`apps/console/src/lib/ + * auth-preflight.ts`) leaves its `get-session` answer in when the stored Bearer + * was valid: the auth URL it asked, the token it sent and the body as JSON text. The + * preflight runs before any bundle chunk that could import this package, so the + * slot is spelled by hand on both sides; the console's + * `sessionBootDedup.test.tsx` runs the preflight against this reader. + */ +const PREFLIGHT_SESSION_KEY = Symbol.for('objectui.authPreflightSession'); + +interface PreflightSessionSlot { + authUrl: string; + token: string; + body: string; + taken?: boolean; +} + +/** + * Take the preflight's `get-session` answer, once per page load (objectui#12808). + * + * Returns it only when it answers what the caller would ask: the same auth URL, + * and the Bearer still stored is the one the preflight validated (the server + * prefers the Bearer, so the cookie the preflight omitted cannot change the + * answer). Parsed exactly as `getSession()` parses the wire body, with the same + * token sync. Anything else, or a second call, returns `null` and the caller asks + * the server as before. + */ +export function takePreflightSession( + authUrl: string, +): { user: AuthUser; session: AuthClientSession } | null { + const holder = globalThis as unknown as Record; + const slot = holder[PREFLIGHT_SESSION_KEY]; + if (!slot || slot.taken) return null; + slot.taken = true; + if (slot.authUrl !== authUrl || slot.token !== TokenStorage.get()) return null; + type Payload = { user?: AuthUser; session?: AuthClientSession } | null; + let payload: Payload; + try { + payload = parseJSON(slot.body, { strict: false }) as Payload; + } catch { + return null; + } + if (!payload?.user || !payload.session) return null; + if (payload.session.token) TokenStorage.set(payload.session.token); + return { user: payload.user, session: payload.session }; +} + /** * Create a fetch wrapper that injects Bearer token from localStorage * and captures updated tokens from the `set-auth-token` response header