kirak
v0.2.0
Published
Isomorphic TypeScript client for a deployed Kirak app - session-aware auth plus a generic api() call reaching every endpoint.
Maintainers
Readme
kirak
Part of the kirak-sdk monorepo. Using React or React
Native? See kirak-react
for hooks built on top of this package.
Isomorphic TypeScript client for a deployed Kirak
app - session-aware auth plus a generic api() call that reaches every endpoint.
Framework-agnostic: any JS framework (Vue, Svelte, Angular, Next, plain JS), React
Native, and Node backends all use the same package, one import, no framework-specific
build.
import { createClient } from "kirak";
const kirak = createClient("https://my-app.kirak.io");
await kirak.api("auth.login", { email: "[email protected]", password: "s3cr3t!" });
const { data, error } = await kirak.api("posts.fetch", { status: "published" });Current surface (v0.1): session-aware authentication (.session, auth operations via api("auth.*")) and a generic
.api(operation, params) call that reaches every endpoint a Kirak app exposes. There is no
typed query builder yet - see What's not built yet.
Contents
- What you can do with it
- Supported platforms
- Install
- Authentication
createClient(baseUrl, options?)- Making requests:
api() - Handling results
- TypeScript
- What's not built yet
- Compatibility
- License
What you can do with it
- Sign users up and in, and keep them signed in across reloads/restarts, with no manual token handling.
- Read and write any model in your app's schema - fetch, search, count, exists, create, update, delete/destroy, restore - through one call.
- Run GraphQL queries against the same backend.
- Reach anything else the backend exposes, including something added after this SDK version shipped, with no update required.
- Get back typed
{ data, error }results instead of a raw response to parse yourself. - Point at any Kirak backend - self-hosted or Kirak Studio-deployed - by changing the URL.
Supported platforms
Plain TypeScript over the standard fetch API - runs anywhere fetch does:
| Platform | Works | Notes |
|---|---|---|
| Any browser frontend | Yes | React, Vue, Angular, Svelte, plain JS - framework-agnostic |
| Meta-frameworks (SSR) | Yes | Next.js, Nuxt, SvelteKit, Remix, Astro - server and client code paths |
| React Native / Expo | Yes | Pass a custom auth.storage (e.g. AsyncStorage) for session persistence |
| Node.js 18+ | Yes | Scripts, backend services, serverless/edge functions |
| Deno, Bun | Expected to work | Standard fetch + ES modules, not yet in the test matrix |
18+ is the Node floor because that's when fetch shipped in Node itself - older runtimes
need a fetch polyfill passed via the fetch option below.
Using this package directly works everywhere in the table above, React and React Native
included. If you're in React or React Native specifically, kirak-react adds hooks
(useSession, useUser, useApi, useMutation) on top of it - see
kirak-react.
Install
npm install kirakAuthentication
Requests are authenticated with a JWT access token, not an API key. Once
kirak.api("auth.login", ...) succeeds, every kirak.api(...) call automatically sends
Authorization: Bearer <accessToken> - nothing to attach yourself, and the token refreshes
itself as it nears expiry (see Session lifecycle below). This is the
credential a normal signed-in end user, in a browser or mobile app, uses for every call.
serverKey (X-API-Key, a kk_... key - see the option below) is a separate, optional
credential for trusted server-side code that isn't acting as any particular signed-in user -
a cron job, a backend service calling its own Kirak app. Most apps using this SDK never set
it. Never ship it in a browser bundle.
| Credential | Header | Who | Set by |
|---|---|---|---|
| JWT access token | Authorization: Bearer <token> | any signed-in user - the default | automatically, after login |
| API key | X-API-Key: kk_... | trusted server-side code only | createClient(url, { serverKey }) |
Sign up
const { data, error } = await kirak.api<{ user: { email: string } }>("auth.register", {
email: "[email protected]",
password: "s3cr3t!",
first_name: "Alice", // optional
});Does not log the user in - kirak-core issues no tokens on register. Follow with auth.login.
Sign in
const { data, error } = await kirak.api<{ accessToken: string; refreshToken: string; expiresIn: number; user: { email: string } }>(
"auth.login",
{ email: "[email protected]", password: "s3cr3t!" },
);
// The session is adopted automatically. Read it back with kirak.session.get():
// { accessToken, refreshToken, expiresIn, expiresAt, user } - user is the wire (snake_case) object, or nullHandle MFA
If the account has MFA enabled, a first call without mfa_code returns an MFA_REQUIRED
error - retry with the code:
let { data, error } = await kirak.api("auth.login", { email, password });
if (error?.code === "MFA_REQUIRED") {
({ data, error } = await kirak.api("auth.login", { email, password, mfa_code: "123456" }));
}Sign out
await kirak.api("auth.logout"); // clears the local session even if the network call failsNote: if the network call fails, the refresh token stays valid server-side until it expires.
Get the current session
const session = await kirak.session.get();
// async - resolves once a persisted session (if any) has finished loading; null if signed outGet the current user
const { data: user, error } = await kirak.api<{ email: string }>("auth.me", undefined, { method: "GET" }); // re-fetches from the serverapi("auth.me") returns the server object verbatim, including a token field that echoes the
caller's access token. The SDK strips token from the session user and from useUser(), so
avoid logging or persisting the raw auth.me result.
Listen for auth changes
const unsubscribe = kirak.session.onChange((event, session) => {
// event: "SIGNED_IN" | "SIGNED_OUT" | "TOKEN_REFRESHED"
if (event === "SIGNED_OUT") redirectToLogin();
});Signing in with OTP, social and other providers
Any successful /auth/* response that carries accessToken + refreshToken is adopted into
the session automatically - not just password login.
// One-time code (SMS to the phone number): request it, then verify it. The session is adopted on verify.
await kirak.api("auth.request-otp", { phone_number: "+14155552671" });
await kirak.api("auth.verify-otp", { phone_number: "+14155552671", otp: "123456" });
// Native social sign-in (mobile): pass the provider's user ID and email from the SDK
await kirak.api("auth.google-oauth2.mobile", { provider_user_id, email });
// Apple on the web: exchange the token from the redirect
await kirak.api("auth.verify-web-auth", { token });
// Anything else (your own server, a deep-link callback): adopt tokens yourself
await kirak.session.adoptSession({ accessToken, refreshToken, expiresIn, user });expiresIn and user are optional in adoptSession; when omitted the SDK reads the expiry
from the token and looks the user up. Deep links that return tokens to your app should use
verified app links (Android App Links, iOS Universal Links), not custom URL schemes any app can claim.
Servers
Clients created with serverKey do not adopt sessions by default (auth.autoAdopt defaults
to false there). On a server, use one client per request so one user's tokens never leak
into another's; set auth.autoAdopt: true only if you want the shared-session behavior.
With autoAdopt: false (or any serverKey client), api("auth.logout") does not fill the
refresh token or clear the local session, so pass { token: refreshToken } explicitly and call
session.clear() yourself.
Session lifecycle
Once signed in, every subsequent kirak.api(...) call automatically carries the session's
access token - you never attach it yourself. The SDK also handles the token going stale:
- Refresh-on-401: any request that comes back 401 (other than a call to
/auth/*itself) triggers one refresh and one retry, transparently. - Single-flight: if several requests 401 around the same time, they share exactly one refresh call instead of each racing their own.
- Rotation: kirak-core issues a new refresh token on every refresh and invalidates the old one - the SDK always stores the newest one.
- Proactive refresh (
auth.autoRefresh: true): refreshes ~60 seconds before the access token's known expiry, so a long-lived tab never has to rely on hitting a 401 first.
If a refresh ever fails (the refresh token itself is expired or revoked), the session is
cleared and a "SIGNED_OUT" event fires - handle that in
session.onChange to redirect to a sign-in screen.
Session persistence
By default, the session is written to localStorage if it's available (browsers), or kept
in memory otherwise (Node, SSR, a worker - it simply won't survive a process restart there).
Pass your own store for anything else (React Native's AsyncStorage, a server-side session
table):
createClient(baseUrl, {
auth: {
storage: {
getItem: (key) => AsyncStorage.getItem(key),
setItem: (key, value) => AsyncStorage.setItem(key, value),
removeItem: (key) => AsyncStorage.removeItem(key),
},
},
});getItem/setItem/removeItem may return a value directly or a Promise - both work. Set
auth.persistSession: false to keep the session in memory only (it disappears on reload).
createClient(baseUrl, options?)
const kirak = createClient(baseUrl, {
prefix: "/api", // if your app's CRUD/graphql routes are mounted under a path prefix
headers: { "X-Client": "web" }, // extra headers sent with every request
fetch: customFetch, // override the fetch implementation (defaults to global fetch)
serverKey: process.env.KIRAK_SERVER_KEY, // kk_... - server-to-server only, NEVER ship in a browser bundle
auth: {
storage: myAuthStore, // where the session is persisted - see Session persistence above
persistSession: true, // default true
autoRefresh: false, // default false - see Session lifecycle
autoAdopt: true, // default true; false when serverKey is set - see Servers above
},
});| Option | Default | Notes |
|---|---|---|
| prefix | "" | Only affects model CRUD (/{model}/{action}) and graphql - auth/storage/other modules are never affected, matching how kirak-core mounts them |
| headers | {} | Merged into every request |
| fetch | global fetch | Inject a polyfill or a wrapped fetch (logging, retries, a proxy) |
| serverKey | - | Sends X-API-Key. Server-side use only |
| publishableKey | - | Reserved for a future anonymous/browser-safe credential mode - accepted, not yet used |
| auth.storage | localStorage if present, else in-memory | See Session persistence |
| auth.persistSession | true | Set false to keep the session in memory only |
| auth.autoRefresh | false | Proactively refresh ~60s before the access token expires |
| auth.flow | "header" | "cookie" is reserved and not implemented yet; the SDK always uses the Authorization header |
baseUrl and prefix are read back off the returned client (kirak.baseUrl, kirak.prefix),
both with any trailing slash trimmed.
Making requests: api()
api<T = unknown>(operation: string, params?: Record<string, unknown>, opts?: { method?: HttpMethod }): Promise<{ data: T | null; error: KirakError | null; pagination?: KirakPagination }>operation is the URL path with / written as .:
| Operation | Request |
|---|---|
| "posts.fetch" | GET /posts/fetch |
| "posts.create" | POST /posts/create |
| "auth.login" | POST /auth/login |
| "graphql" | POST /graphql |
| "storage.upload.image" | POST /storage/upload/image |
For the standard model CRUD verbs, the HTTP method is inferred automatically from kirak-core's
own fixed table - you never pass method for these:
| Action | Method |
|---|---|
| fetch, search, count, exists | GET |
| create, upsert, restore | POST |
| update | PUT |
| delete, destroy | DELETE |
Anything else (auth internals, storage, other modules) defaults to POST unless you pass
opts.method - most of those endpoints are POST anyway, but check
http-api-reference.md
for the exceptions (e.g. GET /auth/me, GET /storage/url).
params is sent as a query string for GET, a JSON body otherwise. api() does not
reshape or validate params - what you pass is the wire body/query verbatim, so it must
already match the shape kirak-core expects.
Reading data
// GET /posts/fetch?status=published&limit=20
await kirak.api("posts.fetch", { status: "published", limit: 20 });
// filter operators are query keys, same as the raw HTTP API
await kirak.api("posts.fetch", { created_at__gte: "2026-01-01", "author_id__in": "1,2,3" });
// GET /posts/search?q=hello
await kirak.api("posts.search", { q: "hello" });
// GET /posts/count / GET /posts/exists
const { data: total } = await kirak.api<{ count: number }>("posts.count", { status: "draft" });
const { data: exists } = await kirak.api<{ exists: boolean }>("posts.exists", { id: 42 });Writing data
// POST /posts/create - body shape is create's own contract: { data: {...} }
await kirak.api("posts.create", { data: { title: "Hello", body: "..." } });
// PUT /posts/update - { data: {...}, where: {...} }
await kirak.api("posts.update", { data: { status: "published" }, where: { id: 42 } });
// POST /posts/upsert
await kirak.api("posts.upsert", { data: { email: "[email protected]", name: "Ada" } });
// DELETE /posts/delete (soft) and /posts/destroy (hard) - { query: {...} } or { ids: [...] }
await kirak.api("posts.delete", { query: { id: 42 } });
await kirak.api("posts.destroy", { ids: [42, 43] });
// POST /posts/restore
await kirak.api("posts.restore", { data: {}, where: { id: 42 } });GraphQL
const { data, error } = await kirak.api("graphql", {
query: "query { posts { id title } }",
});Anything else
Any endpoint the SDK doesn't have a typed method for yet - social login, MFA setup, API-key management, storage - is still reachable the same way:
// GET /storage/url is a GET, not the POST default, so pass opts.method
await kirak.api("storage.url", { path: "avatars/1.png" }, { method: "GET" });
await kirak.api("auth.setup-mfa", {});
await kirak.api("auth.social-login.google", { code: "..." });Handling results
Every call resolves to { data, error, pagination? } - never throws for an HTTP or
network failure, so you destructure rather than try/catch:
const { data, error } = await kirak.api("posts.fetch");
if (error) {
// error is a KirakError: { code, message, httpStatus, operation, details?, retryAfter? }
if (error.code === "PERMISSION_DENIED") { /* ... */ }
return;
}
// data is populatedcode is kirak-core's own machine error code (PERMISSION_DENIED, NOT_FOUND,
VALIDATION_ERROR, TOKEN_EXPIRED, ...) verbatim, plus two SDK-only network codes:
NETWORK_ERROR (the response body wasn't valid JSON) and CORS_OR_UNREACHABLE (the request
never reached the server at all - offline, CORS, DNS). There's no request-timeout handling
yet, so no TIMEOUT code - a hung request currently waits on the runtime's own default.
The one thing that does reject the promise rather than returning { error } is a usage
bug in the call itself - a malformed operation string ("", a leading/trailing ., ..).
That's a programmer error, not a runtime condition, so it surfaces immediately:
try {
await kirak.api(".posts.fetch"); // throws/rejects with a VALIDATION_ERROR KirakError
} catch (err) {
// only reachable for a malformed call, never for a normal API failure
}TypeScript
api() is generic over its response type - data comes back typed, error stays
KirakError | null:
interface Post { id: number; title: string; status: string }
const { data, error } = await kirak.api<Post[]>("posts.fetch");
// data: Post[] | nullThere's no generated Database type yet (that lands with the query builder in v0.2, powered
by kirak gen types), so today you supply the shape per call as shown above.
What's not built yet (and how to reach it anyway)
Nothing is ever unreachable - it just isn't typed yet. Everything below already works through
api(), using the operation-string convention described earlier:
- A typed, chainable query builder (
kirak.from('posts').eq(...).fetch()) - usekirak.api("posts.fetch", {...})until it ships. .storage(file/image upload, list, delete, public URLs) - upload specifically needs multipart handling that isn't wired intoapi()yet either.- Password reset, OTP, MFA setup/verify/disable, social login, API-key management - reachable
via
kirak.api("auth.<action>", {...}), seehttp-api-reference.mdfor each shape. auth.flow: "cookie"(HttpOnly-cookie mode) - the option is accepted but not implemented; only header-based (Bearer token) auth works today.- Realtime / subscriptions - not supported by kirak-core at all yet.
Compatibility
Targets kirak-core with the unified response contract - one canonical envelope
({ statusCode, status, error, message, data }) on every route including /auth/* and
/storage/*. See
kirak-core/docs/http-api-reference.md
for the full wire contract this package is built against.
License
MIT
