diff --git a/.changeset/session-state-for-app-chrome.md b/.changeset/session-state-for-app-chrome.md new file mode 100644 index 000000000..91d0cf5ef --- /dev/null +++ b/.changeset/session-state-for-app-chrome.md @@ -0,0 +1,12 @@ +--- +"@zitadel/sdk-next": minor +"@zitadel/sdk-core": minor +"@zitadel/sdk-nuxt": patch +"@zitadel/cli": minor +--- + +Give the embedding app a supported way to read session state for its own chrome (header navigation, account menus) — previously the widgets read `GET /sessions/me` internally but the host page had no documented path to the same answer and kept rendering signed-out CTAs beside a live session. + +- `@zitadel/sdk-next` ships a new `@zitadel/sdk-next/session` entry with `getSession()`: a client-side read of the same-origin `{proxyPath}/sessions/me` (the exact read `` performs). Works on any page — unlike server-side `auth()` it does not require the route to be covered by the middleware `matcher` — and returns the client-safe `ClientAuthResult` (`userId`/`email`/`name`, no token). 401, the backend's JSON 404, and anonymous sessions map to signed-out; other failures — including a framework's HTML 404 from a misrouted proxy — throw instead of silently rendering signed-out. +- The client-safe auth shapes (`ClientSession`, `ClientAuthState`, `ClientAuthResult`) move to `@zitadel/sdk-core` as the single source; `@zitadel/sdk-nuxt` re-exports them unchanged, so its `useAuth()` and sdk-next's `getSession()` now return the identical shape. +- CLI scaffold guidance (`AGENTS.md` managed section) and the generated profile pages now name each framework's session read: `getSession()` on Next, the auto-imported `useAuth()` composable on Nuxt, and the raw `/__nextgen/sessions/me` read for the SPA frameworks. diff --git a/apps/cli/src/lib/orca/patchers/rule/guidance.ts b/apps/cli/src/lib/orca/patchers/rule/guidance.ts index 8e102362c..bc52a9885 100644 --- a/apps/cli/src/lib/orca/patchers/rule/guidance.ts +++ b/apps/cli/src/lib/orca/patchers/rule/guidance.ts @@ -67,6 +67,16 @@ export function agentsGuidanceSection(ctx: PatchContext): string { ctx.framework.id === "next" ? " React JSX types for the `` elements ship with the SDK — `custom-elements.d.ts` references `@zitadel/sdk-next/jsx`." : ""; + // The app's own chrome (header nav, account menus) needs a session read of + // its own — the widgets render their surfaces but never tell the host page + // whether someone is signed in. Name the framework's supported read path; + // the contract details live with each helper's own docs. + const sessionStateParagraph = + ctx.framework.id === "next" + ? "Your app's own chrome (header navigation, account menus) reads session state with `getSession()` from `@zitadel/sdk-next/session` — a client-side read of the same-origin `/__nextgen/sessions/me` that the session card itself uses, so the answer is the server's (200 → identity, 401 → signed out). In Server Components on routes covered by the request-boundary `matcher`, `auth()` from `@zitadel/sdk-next/server` works too. Sign-in and sign-out navigate (`post-sign-in-url` / `post-sign-out-url`), so chrome re-reads on the next page load without extra wiring." + : ctx.framework.id === "nuxt" + ? "Your app's own chrome (header navigation, account menus) reads session state with the auto-imported `useAuth()` composable — seeded from the server on every render by the scaffolded auth plugin. Sign-in and sign-out navigate (`post-sign-in-url` / `post-sign-out-url`), so the state is fresh on every page load." + : "Your app's own chrome (header navigation, account menus) can read session state from the same-origin `/__nextgen/sessions/me` — the same server-answered read the session card performs: fetch with `credentials: \"include\"`; 200 returns the signed-in identity (`user_id`, `name`, `email`), 401 means signed out. Sign-in and sign-out navigate (`post-sign-in-url` / `post-sign-out-url`), so chrome re-reads on the next page load."; return `## Authentication (Zitadel) This app's login is managed by Zitadel. Local config is the source of truth; never change auth behavior by editing generated route files. @@ -85,6 +95,8 @@ The golden path: Presentation, by contrast, is edited in the generated pages: they pin the sign-in widgets to \`variant="page"\` (full-page chrome); switch to \`variant="widget"\` to embed a card inside your own layout, and set \`theme\` (\`light\` | \`dark\` | \`auto\`) to pick the color scheme.${jsxTypesNote} +${sessionStateParagraph} + Machine-readable dialect (read these before authoring flow or schema edits): - Flow files carry \`"$schema": "../meta/flow-definition.json"\` — the flow dialect spec (steps, actions and their kinds, transitions, reserved outcomes like \`user_not_found\`). Editors validate against it. diff --git a/apps/cli/src/lib/orca/patchers/rule/next/renderers/react/index.ts b/apps/cli/src/lib/orca/patchers/rule/next/renderers/react/index.ts index 1df8fcd2f..fa681430f 100644 --- a/apps/cli/src/lib/orca/patchers/rule/next/renderers/react/index.ts +++ b/apps/cli/src/lib/orca/patchers/rule/next/renderers/react/index.ts @@ -122,7 +122,9 @@ const ZitadelSession = dynamic( const { configureZitadel } = await import("@zitadel/sdk-next/client"); // Build the SDK project handle and pass it to the session card via the // \`project\` prop. The card reads identity from "/__nextgen/sessions/me" - // and exposes a Sign out action. + // and exposes a Sign out action. Your own components (a header, an + // account menu) can make the same read with getSession() from + // "@zitadel/sdk-next/session" to swap sign-in CTAs for account chrome. const project = configureZitadel({ projectId: process.env.NEXT_PUBLIC_ZITADEL_PROJECT_ID ?? "", proxyPath: "/__nextgen", diff --git a/apps/cli/src/lib/orca/patchers/rule/nuxt/templates.ts b/apps/cli/src/lib/orca/patchers/rule/nuxt/templates.ts index d88273da5..ebc64c363 100644 --- a/apps/cli/src/lib/orca/patchers/rule/nuxt/templates.ts +++ b/apps/cli/src/lib/orca/patchers/rule/nuxt/templates.ts @@ -99,7 +99,9 @@ const project = useZitadelProject();
+ tokens; variant="widget" embeds the card inside a layout you own. + Your own components (a header, an account menu) read the same session + state with the auto-imported useAuth() composable. -->
diff --git a/apps/cli/tests/unit/lib/orca/patchers/rule/guidance.test.ts b/apps/cli/tests/unit/lib/orca/patchers/rule/guidance.test.ts index f756d149e..c8a290ff8 100644 --- a/apps/cli/tests/unit/lib/orca/patchers/rule/guidance.test.ts +++ b/apps/cli/tests/unit/lib/orca/patchers/rule/guidance.test.ts @@ -138,6 +138,34 @@ describe("guidance content", () => { expect(agentsGuidanceSection(passwordCtx)).not.toContain("passkey ceremonies"); }); + it("tells each framework how its own chrome reads session state", () => { + // Next: the client helper (works on any page), plus the server-side + // auth() with its matcher precondition. + const agents = agentsGuidanceSection(ctx); + expect(agents).toContain("@zitadel/sdk-next/session"); + expect(agents).toContain("`matcher`"); + + // Nuxt: the composable the scaffolded auth plugin seeds — no Next helper. + const nuxtCtx = { + ...ctx, + framework: { id: "nuxt", devPort: 3000, url: "http://localhost:3000" }, + } as PatchContext; + const nuxtAgents = agentsGuidanceSection(nuxtCtx); + expect(nuxtAgents).toContain("useAuth()"); + expect(nuxtAgents).not.toContain("@zitadel/sdk-next/session"); + + // SPA frameworks: no framework helper exists yet, so the guidance names + // the raw proxy read instead of a package that would not resolve. + const reactCtx = { + ...ctx, + framework: { id: "react", devPort: 5173, url: "http://localhost:5173" }, + } as PatchContext; + const reactAgents = agentsGuidanceSection(reactCtx); + expect(reactAgents).toContain("/__nextgen/sessions/me"); + expect(reactAgents).not.toContain("@zitadel/sdk-next/session"); + expect(reactAgents).not.toContain("useAuth()"); + }); + it("names the presentation knobs and, on Next, the shipped JSX types", () => { const agents = agentsGuidanceSection(ctx); expect(agents).toContain('variant="widget"'); diff --git a/packages/sdk-core/src/middleware.ts b/packages/sdk-core/src/middleware.ts index 02e8f95f0..1d144fdee 100644 --- a/packages/sdk-core/src/middleware.ts +++ b/packages/sdk-core/src/middleware.ts @@ -107,6 +107,32 @@ export type UnauthState = { isAuthenticated: false; session: null }; /** Union of all possible auth states. */ export type AuthResult = AuthState | UnauthState; +/** + * The client-safe session exposed to app UI (headers, account menus). + * Identical to {@link NextgenSession} but omits `token` — the raw session + * token must never reach client-side JavaScript, whether through an SSR + * payload or a client-side fetch result. + */ +export type ClientSession = { + /** The user's unique identifier (`sub` claim). */ + userId: string; + /** The user's email address, or `null` if not present. */ + email: string | null; + /** The user's display name, or `null` if not present. */ + name: string | null; +}; + +/** Client-safe auth state when the user is signed in. */ +export type ClientAuthState = { isAuthenticated: true; session: ClientSession }; + +/** + * Union of all possible client-safe auth states. Returned by the client + * session reads (`useAuth()` in sdk-nuxt, `getSession()` in sdk-next). + * Token is intentionally absent — use the server-side helpers when the raw + * token is needed. + */ +export type ClientAuthResult = ClientAuthState | UnauthState; + /** * Options passed to the SDK middleware factory (`nextgenMiddleware` in * sdk-next, `createNextgenMiddleware` in sdk-nuxt). diff --git a/packages/sdk-next/README.md b/packages/sdk-next/README.md index ff90f19bc..56b11a0e8 100644 --- a/packages/sdk-next/README.md +++ b/packages/sdk-next/README.md @@ -80,7 +80,51 @@ export function UserBadge() { } ``` -### 4. Login page +### 4. Session state for your own UI (any page) + +`auth()` only sees a session on routes the middleware `matcher` covers, and the +scaffolded matcher covers just the proxy path and the protected routes — so a +header on a public page would always look signed out. For your app's own chrome +(header navigation, account menus), read the session client-side with +`getSession()` from `@zitadel/sdk-next/session`. It fetches the same-origin +`{proxyPath}/sessions/me` — the same read the `` card performs — +so it works on every page and the answer is the server's: + +```tsx +'use client'; +import { useEffect, useState } from 'react'; +import { getSession, type ClientAuthResult } from '@zitadel/sdk-next/session'; + +export function HeaderNav() { + // undefined = not yet known — render neutral chrome, not "Sign in". + const [auth, setAuth] = useState(); + const [error, setError] = useState(); + useEffect(() => { + getSession().then(setAuth, setError); + }, []); + if (error) return Session unavailable; + if (!auth) return null; + return auth.isAuthenticated ? ( + {auth.session.name ?? auth.session.email ?? 'Account'} + ) : ( + Sign in + ); +} +``` + +A rejected `getSession()` means the state is *unknown* (broken proxy, network, +5xx) — render a neutral or error state, never the signed-out CTAs. + +A `200` with an authenticated user resolves to `{ isAuthenticated: true, session: { userId, email, name } }` +(client-safe — no token); `401`, the backend's JSON `404`, and anonymous +sessions resolve to signed out; any other response — including a framework's +HTML 404 page from a misrouted proxy — throws so a broken proxy doesn't +silently render as signed out. Sign-in and sign-out navigate (`post-sign-in-url` / +`post-sign-out-url`), so chrome re-reads on the next page load without extra +wiring; to react in place, listen for the widgets' `zitadel-signout` / +`zitadel-flow-complete` events. + +### 5. Login page The `` web component (from `@zitadel/components`) must be rendered client-side only. Split it into a server wrapper and a client widget: diff --git a/packages/sdk-next/package.json b/packages/sdk-next/package.json index 9b686aee9..3eee8ecae 100644 --- a/packages/sdk-next/package.json +++ b/packages/sdk-next/package.json @@ -35,13 +35,17 @@ }, "./jsx": { "types": "./dist/jsx.d.ts" + }, + "./session": { + "types": "./dist/session.d.ts", + "import": "./dist/session.js" } }, "publishConfig": { "access": "public" }, "scripts": { - "build": "tsup src/middleware.ts src/auth.ts src/context.tsx src/useAuth.ts src/types.ts src/index.ts src/server.ts src/client.ts --format esm --dts --clean --tsconfig tsconfig.build.json && node -e \"require('node:fs').copyFileSync('src/jsx.d.ts', 'dist/jsx.d.ts')\"", + "build": "tsup src/middleware.ts src/auth.ts src/context.tsx src/useAuth.ts src/types.ts src/session.ts src/index.ts src/server.ts src/client.ts --format esm --dts --clean --tsconfig tsconfig.build.json && node -e \"require('node:fs').copyFileSync('src/jsx.d.ts', 'dist/jsx.d.ts')\"", "typecheck": "tsc --build tsconfig.json", "test": "vitest run --passWithNoTests", "lint": "eslint ." diff --git a/packages/sdk-next/src/__tests__/session.test.ts b/packages/sdk-next/src/__tests__/session.test.ts new file mode 100644 index 000000000..8b278ee95 --- /dev/null +++ b/packages/sdk-next/src/__tests__/session.test.ts @@ -0,0 +1,129 @@ +import { _resetConfigForTesting, configureZitadel } from "@zitadel/api/config"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getSession } from "../session"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +const fetchMock = vi.fn(); + +beforeEach(() => { + _resetConfigForTesting(); + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + _resetConfigForTesting(); +}); + +describe("getSession()", () => { + it("reads the default proxy path with credentials and maps the identity", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ + session_id: "sess-1", + project_id: "proj-1", + state: "active", + user_id: "user-1", + email: "bob@example.com", + name: "Bob", + }), + ); + + const result = await getSession(); + + expect(fetchMock).toHaveBeenCalledWith( + "/__nextgen/sessions/me", + expect.objectContaining({ credentials: "include" }), + ); + expect(result).toEqual({ + isAuthenticated: true, + session: { userId: "user-1", email: "bob@example.com", name: "Bob" }, + }); + }); + + it("nulls absent identity attributes instead of dropping the session", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ session_id: "s", project_id: "p", state: "active", user_id: "user-2" }), + ); + + const result = await getSession(); + + expect(result).toEqual({ + isAuthenticated: true, + session: { userId: "user-2", email: null, name: null }, + }); + }); + + it("treats an anonymous session (no user_id) as signed out", async () => { + fetchMock.mockResolvedValue( + jsonResponse({ session_id: "s", project_id: "p", state: "building", user_id: null }), + ); + + await expect(getSession()).resolves.toEqual({ isAuthenticated: false, session: null }); + }); + + it.each([401, 404])("treats HTTP %i as the server's definitive signed-out", async (status) => { + fetchMock.mockResolvedValue(jsonResponse({ error: "no session" }, status)); + + await expect(getSession()).resolves.toEqual({ isAuthenticated: false, session: null }); + }); + + it("throws on other failures instead of silently rendering signed-out", async () => { + fetchMock.mockResolvedValue(jsonResponse({ error: "boom" }, 502)); + + await expect(getSession()).rejects.toThrow(/HTTP 502/); + }); + + it("throws on a framework's HTML 404 — only the backend's JSON 404 means signed out", async () => { + // A misrouted proxy (e.g. matcher miss) yields the router's 404 page, + // not the backend's error details; that must be loud, not "signed out". + fetchMock.mockResolvedValue( + new Response("

404

", { + status: 404, + headers: { "content-type": "text/html" }, + }), + ); + + await expect(getSession()).rejects.toThrow(/HTTP 404/); + }); + + it("strips trailing slashes from the proxy path before building the URL", async () => { + fetchMock.mockResolvedValue(jsonResponse({ user_id: "u" })); + + await getSession({ proxyPath: "/__nextgen/" }); + + expect(fetchMock).toHaveBeenCalledWith("/__nextgen/sessions/me", expect.anything()); + }); + + it("prefers an explicit proxyPath over the configured one", async () => { + configureZitadel({ projectId: "proj", proxyPath: "/configured" }); + fetchMock.mockResolvedValue(jsonResponse({ user_id: "u" })); + + await getSession({ proxyPath: "/explicit" }); + + expect(fetchMock).toHaveBeenCalledWith("/explicit/sessions/me", expect.anything()); + }); + + it("falls back to the configureZitadel() proxy path when set", async () => { + configureZitadel({ projectId: "proj", proxyPath: "/configured" }); + fetchMock.mockResolvedValue(jsonResponse({ user_id: "u" })); + + await getSession(); + + expect(fetchMock).toHaveBeenCalledWith("/configured/sessions/me", expect.anything()); + }); + + it("refuses to run server-side and points at auth()", async () => { + vi.stubGlobal("window", undefined); + + await expect(getSession()).rejects.toThrow(/auth\(\)/); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk-next/src/index.ts b/packages/sdk-next/src/index.ts index 9726f652c..6a9964b79 100644 --- a/packages/sdk-next/src/index.ts +++ b/packages/sdk-next/src/index.ts @@ -4,3 +4,5 @@ export type { ProxyOptions, ProxyHandler } from "./middleware"; export { auth } from "./auth"; export { NextgenProvider, useAuthContext } from "./context"; export { useAuth } from "./useAuth"; +export { getSession } from "./session"; +export type { GetSessionOptions } from "./session"; diff --git a/packages/sdk-next/src/session.ts b/packages/sdk-next/src/session.ts new file mode 100644 index 000000000..43a4d447f --- /dev/null +++ b/packages/sdk-next/src/session.ts @@ -0,0 +1,124 @@ +import type { GetMySession200 } from "@zitadel/api/generated/model"; + +import { getZitadelConfig } from "@zitadel/api/config"; + +import type { ClientAuthResult } from "./types"; + +/** Matches the `configureZitadel()` default so zero-config apps agree with the scaffold. */ +const DEFAULT_PROXY_PATH = "/__nextgen"; + +/** Options for {@link getSession}. */ +export type GetSessionOptions = { + /** + * Proxy path the scaffolded request boundary forwards to the Zitadel + * backend. Defaults to the path from `configureZitadel()` when it has run + * on this page, else `"/__nextgen"` (the scaffold default). + */ + proxyPath?: string; +}; + +/** + * Reads the current session state from the browser — the supported way for + * an app's own UI (header navigation, account menus) to know whether a user + * is signed in and who they are. + * + * Fetches the same-origin `{proxyPath}/sessions/me` with credentials — the + * same read the `` card performs — so the answer is the + * server's, not a client-side guess. Works on any page: unlike server-side + * `auth()`, it does not require the route to be covered by the middleware + * `matcher` (only the proxy path itself must be matched, which the + * scaffolded request boundary always does), and it does not require + * `configureZitadel()` to have run. + * + * - `200` with an authenticated user → `{ isAuthenticated: true, session }` + * with the client-safe identity (`userId`, `email`, `name` — no token). + * - `200` for an anonymous session, `401`, or a JSON `404` (the backend's + * "session gone") → signed out. + * - Any other response throws — including a framework's HTML 404 page from + * a misrouted proxy: a failing proxy is a misconfiguration and must not + * silently render as "signed out". Treat a rejection as "unknown" — + * distinct from signed-out — as the example does. + * + * ```tsx + * "use client"; + * import { getSession, type ClientAuthResult } from "@zitadel/sdk-next/session"; + * + * export function HeaderNav() { + * // undefined = not yet known — render neutral chrome, not "Sign in". + * const [auth, setAuth] = useState(); + * const [failed, setFailed] = useState(false); + * useEffect(() => { + * getSession().then(setAuth, () => setFailed(true)); + * }, []); + * if (failed) return Session unavailable; + * if (!auth) return null; + * return auth.isAuthenticated + * ? {auth.session.name ?? auth.session.email ?? "Account"} + * : Sign in; + * } + * ``` + * + * The transitions need no extra wiring in the scaffolded posture: sign-in + * (`post-sign-in-url`) and sign-out (`post-sign-out-url`) both navigate, so + * chrome re-reads on the next page load. To react in place instead, listen + * for the widgets' `zitadel-signout` / `zitadel-flow-complete` events. + * + * @returns The current {@link ClientAuthResult}. + */ +export async function getSession(options: GetSessionOptions = {}): Promise { + if (typeof window === "undefined") { + throw new Error( + "[nextgen] getSession() reads the session from the browser. " + + "In Server Components and Route Handlers use auth() from @zitadel/sdk-next/server " + + "(requires the route to be covered by the middleware matcher).", + ); + } + + // Strip trailing slashes so "/__nextgen/" doesn't produce a double-slash + // URL that misses the request-boundary matcher — same normalization the + // typed API client applies to its base URL. + let proxyPath = options.proxyPath ?? getZitadelConfig()?.proxyPath ?? DEFAULT_PROXY_PATH; + while (proxyPath.endsWith("/")) { + proxyPath = proxyPath.slice(0, -1); + } + + const response = await fetch(`${proxyPath}/sessions/me`, { + credentials: "include", + headers: { accept: "application/json" }, + }); + + // 401 = no/invalid session token; 404 = session gone (revoked/expired). + // Both are the server's definitive "not signed in" — but only when the + // answer came from the backend (JSON error details). A framework router's + // HTML 404 means the proxy never saw the request; that falls through to + // the throw below instead of silently rendering signed-out. + const isJson = (response.headers.get("content-type") ?? "").includes("application/json"); + if (response.status === 401 || (response.status === 404 && isJson)) { + return { isAuthenticated: false, session: null }; + } + + if (!response.ok) { + throw new Error( + `[nextgen] Session read failed: HTTP ${response.status} from ${proxyPath}/sessions/me`, + ); + } + + const session = (await response.json()) as GetMySession200; + + // An anonymous session (no verified user factor yet) has no user_id — + // for app chrome that is "not signed in". + if (!session.user_id) { + return { isAuthenticated: false, session: null }; + } + + return { + isAuthenticated: true, + session: { + userId: session.user_id, + email: session.email ?? null, + name: session.name ?? null, + }, + }; +} + +export type { ClientAuthResult, ClientAuthState, ClientSession } from "./types"; diff --git a/packages/sdk-next/src/types.ts b/packages/sdk-next/src/types.ts index 30d140b5f..7087ea74f 100644 --- a/packages/sdk-next/src/types.ts +++ b/packages/sdk-next/src/types.ts @@ -10,4 +10,9 @@ export type { UnauthState, AuthResult, NextgenMiddlewareOptions, + // Client-safe auth shapes returned by getSession() — same shape as + // sdk-nuxt's useAuth(), token intentionally absent. + ClientSession, + ClientAuthState, + ClientAuthResult, } from "@zitadel/sdk-core/middleware"; diff --git a/packages/sdk-nuxt/src/runtime/types.ts b/packages/sdk-nuxt/src/runtime/types.ts index 5873cc7d1..179445de4 100644 --- a/packages/sdk-nuxt/src/runtime/types.ts +++ b/packages/sdk-nuxt/src/runtime/types.ts @@ -10,33 +10,11 @@ export type { UnauthState, AuthResult, NextgenMiddlewareOptions, + // Client-safe auth shapes (token intentionally absent — it must not be + // serialised into the Nuxt SSR payload where third-party scripts can read + // it; use getAuth server-side when the raw JWT is needed). Defined in + // sdk-core so sdk-next's getSession() returns the identical shape. + ClientSession, + ClientAuthState, + ClientAuthResult, } from "@zitadel/sdk-core/middleware"; - -import type { UnauthState } from "@zitadel/sdk-core/middleware"; - -// ─── Nuxt-specific client-safe types ───────────────────────────────────────── - -/** - * The client-safe session exposed to Vue components via {@link useAuth}. - * Identical to {@link NextgenSession} but omits `token` — the raw JWT must - * not be serialised into the Nuxt SSR payload where third-party scripts can - * read it. - */ -export type ClientSession = { - /** The user's unique identifier (`sub` claim). */ - userId: string; - /** The user's email address, or `null` if not present in the token. */ - email: string | null; - /** The user's display name, or `null` if not present in the token. */ - name: string | null; -}; - -/** Client-safe auth state when the user is signed in. */ -export type ClientAuthState = { isAuthenticated: true; session: ClientSession }; - -/** - * Union of all possible auth states returned by {@link useAuth}. - * Token is intentionally absent — use {@link getAuth} server-side when the - * raw JWT is needed. - */ -export type ClientAuthResult = ClientAuthState | UnauthState;