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.
export const { client, handler } = createKizlo({
adapters: {
auth: myAuth(),
captcha: turnstile({ secret: process.env.TURNSTILE_SECRET }),
logger: consoleLog(),
},
})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.
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: number
email?: string
phone?: string
firstName: string
lastName: string
}
interface AuthAdapter {
getUser(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 the user, or null when no one's signed in. This is the seam between Kizlo and whatever owns
your sessions. Wrap your provider in createAuthAdapter to fill in the contract:
import { createAuthAdapter } from "kizlo"
export function myAuth() {
return createAuthAdapter({
async getUser(request) {
const session = await mySessionStore.read(request)
if (!session) return null
return {
id: session.userId,
firstName: session.firstName,
lastName: session.lastName,
email: session.email,
}
},
})
}WordPress keeps its own auth — and 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, the providers a customer-facing app expects. So your users come through an auth adapter instead — 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>
}Three operations over 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 — stdout, Datadog, 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.
The framework integration wires sensible defaults, like your framework's cookie store,
so the everyday setup needs little or no adapters block. Reach for the option to override a default or
plug in something Kizlo doesn't ship (auth is always yours to write).
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:
createKizlo({
adapters: {
auth: authMock({ mockUserId: 1 }), // every request is user 1
geo: geoMock({ country: "IN" }), // pinned location
},
})Adapters cover the services around a request — identity, cookies, geo, captcha, logs. Bridging WordPress itself, or a third-party plugin, is a different job done by extensions and companion plugins, not adapters.