Docs

Adapter

Learn how adapters let you plug in your own auth, logging, cookies, geo, and captcha, then swap them later.

Some of what Kizlo needs has no single right answer: which provider signs in your users, where logs go, how cookies are read, what separates a real visitor from a bot. Kizlo doesn't pick for you.

For each capability it defines a small contract, a handful of methods it promises to call, and lets you supply the implementation. That implementation is an adapter.

src/lib/kizlo/server/index.ts
const application = createIntegration({
  id: "application",
  adapters: {
    auth: myAuth(),
    captcha: turnstile({ secret: process.env.TURNSTILE_SECRET }),
  },
})

export const { client, handler } = createKizlo({
  logging: "info",
  integrations: [application],
})

You name the implementation; Kizlo calls it when it needs that capability and stays out of the rest. It never knows whether auth is Clerk, better-auth, or your own session store, only that it can ask for the current user.

Why it's not locked in

Because each adapter is just a contract, the service behind it is yours to swap. Move from Clerk to better-auth, Vercel to Cloudflare, console to Datadog, and you change one adapter. Kizlo and the rest of your app never notice, because they only ever spoke to the contract. Your dependency is on a capability, never on a specific service.

Keep provider setup inside an integration

A provider can bundle its adapter with the procedures that depend on it. The factory keeps the SDK private, and the consuming app registers one integration:

src/lib/kizlo/server/provider/index.ts
import { createIntegration } from "kizlo"
import { profile } from "./procedures"
import { providerAuth } from "./auth"
import type { ProviderOptions } from "./types"

export function provider(options: ProviderOptions) {
  return createIntegration({
    id: "provider",
    adapters: { auth: providerAuth(options) },
    procedures: { profile },
  })
}
src/lib/kizlo/server/index.ts
export const { procedures, client, handler } = createKizlo({
  integrations: [provider({ secret: process.env.PROVIDER_SECRET })],
})

The app does not import providerAuth or repeat its configuration.

Which adapter wins?

Kizlo starts with the console logger selected by createKizlo({ logging }), then applies integrations from left to right.

A later concrete value replaces the earlier value for that capability. An undefined contribution is ignored, so an optional provider setting cannot erase an adapter that is already configured. Composition is synchronous and replace-only; an integration receives no previous adapter map to inspect or wrap.

The resolved map is the one exposed at context.config.adapters and used by every request.

The interfaces

Kizlo asks for a fixed set of capabilities, each with its own contract, and each optional: provide an adapter when your app needs that capability, leave it out when it doesn't. Each section below is one contract in full. (Promisify<T> in the signatures is T | Promise<T>: return sync or async, whichever suits.)

auth: who's making the request

interface AuthUser {
  id: string
  email: string
  firstName?: string
  lastName?: string
  meta?: Record<string, JsonValue>
}

interface AuthAdapter {
  getSession(request: Request | null): Promisify<AuthUser | null>
}

Kizlo hands you the incoming Request; you read your session however you issue it (cookie, header, bearer token) and return it, or null when no one's signed in. This is the seam between Kizlo and whatever owns your sessions. The id is your provider's own id, not a WordPress id; email is the one key Kizlo uses to map the session to a WordPress user, so any provider can supply it. Wrap your provider in createAuthAdapter to fill in the contract:

import { createAuthAdapter } from "kizlo"

export function myAuth() {
  return createAuthAdapter({
    async getSession(request) {
      const session = await mySessionStore.read(request)
      if (!session) return null
      return {
        id: session.userId,
        email: session.email,
        firstName: session.firstName,
        lastName: session.lastName,
      }
    },
  })
}

WordPress keeps its own auth, as it should. Admins, editors, and other privileged roles sign in through it, exactly what it's built for. What it isn't built for is modern end-user sign-in: social logins, passkeys, and the providers a customer-facing app expects. Your users come through an auth adapter instead. This is one of the reasons Kizlo keeps WordPress auth off the frontend.

cookies: read & write cookies

interface CookiesAdapter {
  getAll(): Promisify<{ name: string; value: string }[] | null>
  setAll(cookies: CookieWithOptions[]): Promisify<void>
  deleteAll(cookies: { name: string; options?: CookieOptions }[]): Promisify<void>
}

These three operations wrap whatever cookie store your framework gives you. Kizlo uses them to set, read, and clear sessions. On Next.js this maps straight onto next/headers; on another framework you wrap its equivalent. The framework integration usually wires this one for you, so you rarely write it by hand.

geo: describe the connection

interface ConnInfo {
  ip: string | null
  country: string | null
  city: string | null
  state: string | null
  postcode: string | null
  timezone: string | null
  userAgent: string | null
}

interface GeoAdapter {
  getConnInfo(request: Request | null): Promisify<ConnInfo>
}

Given the request, return what you can about where it came from. Fields you can't resolve are null; return the shape, not an error. The prebuilt geoVercelNext reads Vercel's edge headers (x-vercel-ip-country and friends); behind another CDN, you read its headers instead.

captcha: verify a challenge token

type CaptchaAdapter = (input: { token: string; ip: string }) => Promise<boolean>

The simplest of the five: just a function. You get the token your frontend collected and the caller's ip, verify it with your provider, and return true for human. turnstile, recaptcha, hcaptcha, altcha and the rest are all this one function pointed at different verification APIs:

import type { CaptchaAdapter } from "kizlo"

export function myCaptcha(opts: { secret: string }): CaptchaAdapter {
  return async ({ token, ip }) => {
    const data = await verifyWithMyProvider(opts.secret, token, ip)
    return data.success === true
  }
}

logger: receive log records

type LogLevel = "debug" | "info" | "warn" | "error"

interface LogPayload {
  level: LogLevel
  message: string
  timestamp: Date
  context?: Record<string, unknown>
  error?: Error
}

type LoggerAdapter = (payload: LogPayload) => void | Promise<void>

Kizlo calls this with every log record it produces. You decide where it goes, such as stdout, Datadog, or Sentry. The built-in consoleLog prints; a one-liner can forward each payload to your platform's SDK instead.

Built-ins, defaults, and mocks

You rarely write an adapter from scratch. Kizlo ships implementations for the common cases: turnstile, recaptcha, and hcaptcha for captcha; consoleLog for logging; geoVercelNext for reading geo on Vercel. For auth, the separate @kizlo/clerk package maps a verified Clerk session onto the contract above. The framework integration can wire defaults such as its cookie store. Add a later integration to override a capability or provide one Kizlo does not ship.

Because an adapter is just a contract, it's also the natural place to substitute behaviour in tests. Each capability has a mock, such as authMock and geoMock, that returns fixed values, so a test can stand in a known user or location without touching a real provider:

kizlo.test.ts
const testAdapters = createIntegration({
  id: "test-adapters",
  adapters: {
    auth: authMock({ email: "user@example.com" }), // every request is this signed-in user
    geo: geoMock({ country: "IN" }),   // pinned location
  },
})

createKizlo({ integrations: [testAdapters] })

Adapters cover the services around a request, including identity, cookies, geo, captcha, and logs. Bridging WordPress itself, or a third-party plugin, is a different job done by integrations and companion plugins, not adapters.

On this page