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.
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:
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 },
})
}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:
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.