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 in mount
order: ascending order, with declaration order kept among integrations that share one. Framework integrations
such as nextjs() declare a negative order, so they mount first however the integrations array is written.
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.
Direct server calls
For direct server calls, Kizlo supplies an in-memory Request with your configured baseUrl and the
cookies from your cookies adapter. If either is unavailable or the cookie store is empty, the auth adapter
receives null. Cookie-read failures propagate; this fallback supplies no incoming bearer token, other headers, or browser URL. Auth that
needs those values must use the HTTP handler or resolve them from its framework's request context.
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. Registering nextjs() wires next/headers automatically and reads cookies only when needed.
On another framework, wrap its equivalent cookie store.
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 platform integration supplies this adapter. Kizlo also forwards its valid ip
to WordPress, so the adapter must return a trusted address. See geo adapter configuration
for the built-in providers and trust requirements.
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 and geoCloudflareNext for platform geo.
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", ip: null }), // pinned location without forwarding a mock IP
},
})
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.