// Kratos public-API client (todo §4): typed `fetch` wrappers over Ory Kratos' public // endpoints — self-service flow init/get/submit, browser logout, session `whoami`, and the // session→JWT tokenizer (`whoami?tokenize_as`). Built-in `fetch` only, no SDK dep (AGENTS.md). // Flow `ui.nodes` types stay loose — rendering + field-error mapping is flow-view.ts's job. export type FlowType = "login" | "recovery" | "registration" | "settings" | "verification"; export interface UiText { context?: Record; id: number; text: string; type: string; } export interface UiNode { attributes: Record; group: string; messages: UiText[]; meta: { label?: UiText }; type: string; } export interface FlowUi { action: string; // absolute Kratos URL the browser POSTs the form to (Kratos owns its CSRF) messages?: UiText[]; method: string; nodes: UiNode[]; } export interface Flow { id: string; type?: string; ui: FlowUi; } export interface Session { active?: boolean; expires_at?: string; identity?: { id: string; metadata_public?: unknown; traits?: Record }; // whoami strips metadata_admin tokenized?: string; // the signed JWT — present only when `tokenize_as` was requested } export interface FlowInit { flow: Flow; setCookie: string[]; // Kratos' CSRF cookie(s) to relay to the browser } export interface LogoutFlow { logoutToken: string; // CSRF token Kratos embeds in logoutUrl logoutUrl: string; // send the browser here to revoke the session + clear Kratos' cookie } export interface FlowSubmission { body: unknown; // parsed JSON: the re-rendered flow on 400, the success payload on 200 location: string | null; // redirect target (Location header, or a 422 redirect_browser_to) ok: boolean; // status === 200 setCookie: string[]; status: number; } // Carries the HTTP status so a caller can branch — e.g. re-init on an expired flow (404/410). export class KratosError extends Error { body: string; status: number; constructor(message: string, status: number, body: string) { super(message); this.body = body; this.name = "KratosError"; this.status = status; } } export interface KratosPublic { createLogoutFlow(opts?: { cookie?: string }): Promise; // null ⇒ no active session (401) getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise; initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise; submitFlow(action: string, opts: { body: string; contentType?: string; cookie?: string }): Promise; whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function parseBody(text: string): unknown { if (!text) return null; try { return JSON.parse(text); } catch { return text; } } export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosPublic { const base = config.baseUrl.replace(/\/+$/, ""); const http = config.fetchImpl ?? fetch; // Forward the browser cookie + ask for JSON, so Kratos returns the flow/session instead // of redirecting an API caller. function headers(cookie?: string): Record { const h: Record = { accept: "application/json" }; if (cookie) h["cookie"] = cookie; return h; } return { async createLogoutFlow(opts = {}) { // Browser logout: get the logout URL (carrying a CSRF token) to send the browser to. const res = await http(new URL(`${base}/self-service/logout/browser`), { headers: headers(opts.cookie), redirect: "manual" }); if (res.status === 401) return null; // no active session to revoke if (res.status !== 200) throw new KratosError(`Kratos logout flow failed (${res.status})`, res.status, await res.text()); const body = (await res.json()) as { logout_token: string; logout_url: string }; return { logoutToken: body.logout_token, logoutUrl: body.logout_url }; }, async initBrowserFlow(type, opts = {}) { const url = new URL(`${base}/self-service/${type}/browser`); if (opts.returnTo) url.searchParams.set("return_to", opts.returnTo); const res = await http(url, { headers: headers(opts.cookie), redirect: "manual" }); if (res.status !== 200) throw new KratosError(`Kratos init ${type} flow failed (${res.status})`, res.status, await res.text()); return { flow: (await res.json()) as Flow, setCookie: res.headers.getSetCookie() }; }, async getFlow(type, id, opts = {}) { const url = new URL(`${base}/self-service/${type}/flows`); url.searchParams.set("id", id); const res = await http(url, { headers: headers(opts.cookie) }); if (res.status !== 200) throw new KratosError(`Kratos get ${type} flow failed (${res.status})`, res.status, await res.text()); return (await res.json()) as Flow; }, async submitFlow(action, opts) { const h = headers(opts.cookie); h["content-type"] = opts.contentType ?? "application/x-www-form-urlencoded"; // Manual redirect so we can read a 303 Location instead of following it server-side. const res = await http(action, { body: opts.body, headers: h, method: "POST", redirect: "manual" }); const body = parseBody(await res.text()); const location = res.headers.get("location") ?? (isRecord(body) && typeof body["redirect_browser_to"] === "string" ? body["redirect_browser_to"] : null); return { body, location, ok: res.status === 200, setCookie: res.headers.getSetCookie(), status: res.status }; }, async whoami(opts = {}) { const url = new URL(`${base}/sessions/whoami`); if (opts.tokenizeAs) url.searchParams.set("tokenize_as", opts.tokenizeAs); const res = await http(url, { headers: headers(opts.cookie) }); if (res.status === 401) return null; // no/expired session if (res.status !== 200) throw new KratosError(`Kratos whoami failed (${res.status})`, res.status, await res.text()); return (await res.json()) as Session; }, }; }