// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints // (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login // completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md); // `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`). import { KratosError } from "./kratos-public.ts"; export interface Identity { id: string; metadata_admin?: unknown; metadata_public?: unknown; schema_id?: string; state?: string; traits?: Record; } export interface IdentityList { identities: Identity[]; nextPageToken: string | null; // keyset cursor for the next page; null on the last page } export interface ListOptions { credentialsIdentifier?: string; // exact-match filter on a login identifier (e.g. email) ids?: string[]; pageSize?: number; pageToken?: string; } // A one-time recovery code + the self-service link wrapping it (admin "trigger recovery"). export interface RecoveryCode { code: string; link: string; } export interface KratosAdmin { createIdentity(payload: unknown): Promise; createRecoveryCode(identityId: string, opts?: { expiresIn?: string }): Promise; deleteIdentity(id: string): Promise; getIdentity(id: string): Promise; listIdentities(opts?: ListOptions): Promise; updateIdentity(id: string, payload: unknown): Promise; updateMetadataPublic(id: string, metadata: unknown): Promise; } // Kratos paginates with a Link header; pull the page_token of rel="next" (the href is a // relative path, so resolve it against a throwaway base just to read the query param). function nextPageToken(link: string | null): string | null { const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1]; return href ? new URL(href, "http://kratos").searchParams.get("page_token") : null; } export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosAdmin { const base = config.baseUrl.replace(/\/+$/, ""); const http = config.fetchImpl ?? fetch; const json = { "content-type": "application/json" }; const identity = (id: string) => `${base}/admin/identities/${encodeURIComponent(id)}`; async function fail(action: string, res: Response): Promise { throw new KratosError(`Kratos admin ${action} failed (${res.status})`, res.status, await res.text()); } return { async createIdentity(payload) { const res = await http(`${base}/admin/identities`, { body: JSON.stringify(payload), headers: json, method: "POST" }); if (res.status !== 201) return fail("create identity", res); return (await res.json()) as Identity; }, // Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the // user by Kratos; the code/link are also returned so an operator can hand them over directly. async createRecoveryCode(identityId, opts = {}) { const body: Record = { identity_id: identityId }; if (opts.expiresIn) body.expires_in = opts.expiresIn; const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" }); if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res); const data = (await res.json()) as { recovery_code: string; recovery_link: string }; return { code: data.recovery_code, link: data.recovery_link }; }, async deleteIdentity(id) { const res = await http(identity(id), { method: "DELETE" }); if (res.status !== 204) await fail("delete identity", res); }, async getIdentity(id) { const res = await http(identity(id)); if (res.status === 404) return null; if (res.status !== 200) return fail("get identity", res); return (await res.json()) as Identity; }, async listIdentities(opts = {}) { const url = new URL(`${base}/admin/identities`); if (opts.credentialsIdentifier) url.searchParams.set("credentials_identifier", opts.credentialsIdentifier); for (const id of opts.ids ?? []) url.searchParams.append("ids", id); if (opts.pageSize !== undefined) url.searchParams.set("page_size", String(opts.pageSize)); if (opts.pageToken) url.searchParams.set("page_token", opts.pageToken); const res = await http(url); if (res.status !== 200) return fail("list identities", res); return { identities: (await res.json()) as Identity[], nextPageToken: nextPageToken(res.headers.get("link")) }; }, async updateIdentity(id, payload) { const res = await http(identity(id), { body: JSON.stringify(payload), headers: json, method: "PUT" }); if (res.status !== 200) return fail("update identity", res); return (await res.json()) as Identity; }, // JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and // touches nothing else — so the login role projection never clobbers traits/state. // (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.) async updateMetadataPublic(id, metadata) { const patch = [{ op: "add", path: "/metadata_public", value: metadata }]; const res = await http(identity(id), { body: JSON.stringify(patch), headers: json, method: "PATCH" }); if (res.status !== 200) return fail("update metadata_public", res); return (await res.json()) as Identity; }, }; }