# Primitives Docs > Full Markdown corpus for agents and language models. Index: https://kuratchi.dev/llms.txt Sitemap: https://kuratchi.dev/sitemap.xml ## Koze Framework and .koze compiler ### koze Package: Koze Canonical: https://kuratchi.dev/docs/koze Markdown: https://kuratchi.dev/docs/koze/index.md > Cloudflare Workers-native framework docs ## Start Here Koze is the framework and `.koze` compiler package. These docs live with the package so they can move with Koze if it later leaves this monorepo. ## Guides - [Getting Started](/docs/koze/getting-started) - [Project Structure](/docs/koze/project-structure) - [Routing](/docs/koze/routing) - [Actions](/docs/koze/actions) - [Client Interactivity](/docs/koze/client-interactivity) - [Middleware](/docs/koze/middleware) - [Request APIs](/docs/koze/request-apis) - [Styling](/docs/koze/styling) - [Development](/docs/koze/development) - [Configuration](/docs/koze/configuration) ## Cloudflare Primitives - [Durable Objects](/docs/koze/durable-objects) - [Workflows](/docs/koze/workflows) - [Queues](/docs/koze/queues) - [Pipelines](/docs/koze/pipelines) - [Containers](/docs/koze/containers) - [Sandbox](/docs/koze/sandbox) - [Cloudflare Access](/docs/koze/cloudflare-access) - [API Routes](/docs/koze/api-routes) - [API Shield](/docs/koze/api-shield) - [Content](/docs/koze/content) - [Async Values](/docs/koze/async-values) ### Actions Package: Koze Canonical: https://kuratchi.dev/docs/koze/actions Markdown: https://kuratchi.dev/docs/koze/actions.md > Form actions, ActionError, redirects, and RPC form submissions ## Form actions Export a server function from the route `
``` The action receives a single **context object** with the form data and per-request context. Destructure what you need: ```ts import { ActionError } from '@kuratchi/koze'; export async function addItem({ formData }: { formData: FormData }): Promise { const title = (formData.get('title') as string)?.trim(); if (!title) throw new ActionError('Title is required'); } ``` The context object carries: - `formData` — the submitted form fields (always present; empty for button-triggered actions that don't carry a form body). - `request` — the underlying `Request` object. - `url` — the parsed `URL` of the incoming request. - `params` — path params for the matched route. - `env` - Cloudflare bindings for the current Worker request. - `ctx` - the Cloudflare `ExecutionContext`. - `locals` - request-scoped state populated by middleware. The full action signature is `fn(...args, ctx)`. For `
` submissions `args` is empty and the context object is the handler's only argument; for [button-triggered actions](#button-triggered-actions) `args` carries the positional call arguments and `ctx` stays as the trailing parameter. If you want a reusable annotation, import `ActionContext`: ```ts import type { ActionContext } from '@kuratchi/koze'; export async function addItem({ formData, env, locals }: ActionContext) { // env.DB, locals.user, ... } ``` ## Augmented forms `` works without JavaScript as a normal POST-Redirect-GET form. For app-like UX, wrap the server action with `augment()` and bind the form to the returned action state: ```html if (createDb.error) {

{createDb.error}

}
``` `augment(action, hooks)` returns an action state object: ```ts interface AugmentedActionState { pending: boolean; success: boolean; error: string | undefined; } ``` The compiler maps the alias (`createDb`) back to the wrapped server action (`createDatabase`). The submitted form still carries `_action` and still works as a native form if JavaScript is unavailable. ### Stateful behavior Stateful augmented forms behave like this: - Submit uses `fetch()` with the compiler-injected `_action` field. - Before the request starts, `.pending` becomes `true`, `.success` becomes `false`, and `.error` is cleared. - Submit buttons inside the form are disabled while the request is in flight. Buttons that were already disabled are restored to disabled. - On success, `.pending` becomes `false` and `.success` becomes `true`. - On `ActionError`, `.pending` becomes `false`, `.success` becomes `false`, and `.error` contains the user-facing message. - The route re-renders the pieces of UI that read `.pending`, `.success`, or `.error`. Use this for dialogs, create/delete forms, auth forms, uploads, and any flow where the user should see the button disable, a spinner, inline errors, or a success message before the next navigation. ### Hooks The second argument can define lifecycle hooks: ```ts const save = augment(saveSettings, { pending(payload) {}, success(payload) {}, error(payload) {}, settled(payload) {}, }); ``` Each hook receives: ```ts interface AugmentedActionHookContext { action: string; form?: HTMLFormElement; response?: Response; result?: T; error?: string; redirectTo?: string | null; redirectStatus?: number | null; } ``` Use hooks for dialog state, toast dispatch, delayed navigation, or route refreshes: ```html ``` > **Note:** Stateful `augment()` does not automatically navigate on action redirects. Redirect metadata is exposed as `payload.redirectTo` and `payload.redirectStatus` so your success UI can render first. Call `navigateTo(...)`, `location.assign(...)`, or another navigation helper from the `success` hook when the flow should leave the page. ### HTML augmentation The older form attribute still exists: ```html
...
``` This mode fetches the form, disables submit buttons during the request, and swaps in the returned HTML or follows redirects. It does not create a route-local state object and does not run lifecycle hooks. Prefer `const state = augment(action, hooks)` for new product UI. Use the bare `augment` attribute only when you want progressive HTML replacement and do not need `.pending`, `.success`, `.error`, or hooks. ## Redirect after write Use `redirect()` for POST-redirect-get flows: ```ts import { redirect } from 'koze:navigation'; export async function createItem({ formData }: { formData: FormData }): Promise { const id = await db.items.insert({ title: formData.get('title') }); redirect(`/items/${id}`); } ``` ## Action errors Throw `ActionError` for user-facing validation failures: ```ts import { ActionError } from '@kuratchi/koze'; export async function signIn({ formData }: { formData: FormData }) { const email = formData.get('email') as string; const password = formData.get('password') as string; if (!email || !password) throw new ActionError('Email and password are required'); } ``` For native and HTML-augmented forms, action state is exposed in the template under the function name after the server re-renders the route: ```html
if (signIn.error) {

{signIn.error}

}
``` For stateful `augment()` forms, read the alias returned by `augment()`: ```html
if (signInAction.error) {

{signInAction.error}

}
``` ## Page errors Throw `PageError` when a route should render an HTTP error page: ```ts import { PageError } from '@kuratchi/koze'; if (!post) throw new PageError(404); if (!post.isPublished && !currentUser?.isAdmin) throw new PageError(403, 'Admin only'); ``` ## Validated RPC input For client-callable route RPC functions, declare a companion `schemas` object in the same server module. Each schema key must match the exported function name exactly. ```ts import { schema, type InferSchema } from '@kuratchi/koze'; export const schemas = { createPost: schema({ title: schema.string().min(1), content: schema.string().min(1), }), }; export async function createPost(data: InferSchema) { return { slug: data.title.toLowerCase().replace(/ /g, '-') }; } ``` Use the function normally from the route template: ```html

Created: {result.slug}

``` Rules: - Schema-backed RPCs accept one object argument. - The schema object must be exported as `schemas`. - Keys in `schemas` must match the RPC function names. - If validation fails, Koze returns `400` and does not execute the function. Current TypeScript pattern: ```ts import { schema, type InferSchema } from '@kuratchi/koze'; export const schemas = { updateProfile: schema({ displayName: schema.string().min(1), bio: schema.string().optional(''), }), }; export async function updateProfile(data: InferSchema) { // data is typed from the schema } ``` Koze currently uses the schema as the source of truth for validated RPC input. Automatic type generation for unannotated handler parameters is not documented as part of this API yet, so use `InferSchema` when you want the handler parameter typed. ## Button-triggered actions Reference any `$server/*` function directly from a button. The framework spreads the call arguments into the server handler positionally, with the [context object](#form-actions) as the trailing parameter: ```html ``` The handler signature mirrors the call site — positional arguments come first, then the context object: ```ts import { ActionError } from '@kuratchi/koze'; export async function deleteItem(id: number, { formData }: { formData: FormData }) { if (!Number.isInteger(id)) throw new ActionError('Invalid id'); await db.items.delete(id); } export async function toggleItem( id: number, done: boolean, { formData, request }: { formData: FormData; request: Request } ) { await db.items.update(id, { done }); } ``` Handlers that don't need the context object just drop the parameter: ```ts export async function deleteItem(id: number) { await db.items.delete(id); } ``` ### Transport Button-triggered actions share the same action endpoint as `
`: a `POST` to the current route with `_action` (the function name) and `_args` (a JSON-encoded array of the call arguments). The framework bridge serializes the click-site arguments, invokes the server action, follows redirects, and refreshes SSR data after success. For interactions that need optimistic UI or custom error handling, wrap the call in a client handler and use the RPC path instead. ### When to wrap in a client handler Reach for a top-script client handler when you need something beyond a fire-and-refresh: ```html ``` Use a client handler when you want to: - Prompt for confirmation before sending the request. - Show an inline spinner or optimistic UI update. - Handle errors inline without a full page re-render. > **Note:** When you invoke a server function directly from client JS (e.g. `await deleteItem(id)` inside `onDelete`), Koze dispatches it through the **RPC** path rather than the button-action path. RPC handlers receive their call arguments positionally without a trailing context object: ```ts // Called from client JS → RPC path export async function deleteItem(id: number) { await db.items.delete(id); } ``` The context object is only appended when the framework invokes the handler via the action dispatch path (i.e. for `` submissions and ` ``` The browser call goes through Koze's Capn Web channel endpoint. On success or error, the runtime dispatches `koze:invalidate-reads` so hydrated reactive effects can re-run against the latest server state. ## Success guard Use `else if (x.success)` in place of a naked `else` when the template body needs data from the resolved value. The pending AsyncValue is an empty object — accessing `.data` or iterating it during pending renders nothing, which is rarely what you want. ```html if (todos.pending) { } else if (todos.error) {

Failed: {todos.error}

} else if (todos.success) { for (const todo of todos) { } } ``` ## Type safety `AsyncValue` extends `T`, so type inference flows through: ```ts interface Todo { id: string; title: string; done: boolean; } // getTodos returns Promise; bound without await: AsyncValue const todos = getTodos(); if (todos.success) { for (const todo of todos) { console.log(todo.title); // string console.log(todo.done); // boolean } } ``` ## Live workflow status For Cloudflare Workflow instance status — which changes over time — use [`workflowStatus`](/docs/koze/workflows#live-status-with-workflowstatus) from `koze:workflow`. It returns the same `AsyncValue` shape and, when `{ poll }` is passed, the framework re-renders the whole route on each tick. ```html ``` ## Runtime helpers Import from `koze` for AsyncValue construction and type guarding outside of streamed boundaries: ```ts import { createPendingValue, createSuccessValue, createErrorValue, isAsyncValue, type AsyncValue, } from '@kuratchi/koze'; ``` | Helper | Purpose | | --- | --- | | `createPendingValue()` | Returns an empty AsyncValue with `pending=true`. | | `createSuccessValue(v)` | Wraps a resolved value with `success=true`. | | `createErrorValue(msg)` | Returns an AsyncValue with `error=msg`. | | `isAsyncValue(v)` | Type guard for inspecting unknown values. | ### Client interactivity Package: Koze Canonical: https://kuratchi.dev/docs/koze/client-interactivity Markdown: https://kuratchi.dev/docs/koze/client-interactivity.md > Single-script routes, SSR data hydration, and the `on={fn(args)}` directive Koze routes render on the server, but the authored top `

{board.name}

    for (const item of board.items) {
  • {item.title}
  • }
``` > **Note:** You author **one** top ` ``` Reactive reads update these template surfaces in the browser after the server-rendered first paint: - text expressions like `{selectOptions.length}` - normal attributes like `hidden={...}`, `class={...}`, and `value={...}` - `if (...) { ... } else { ... }` blocks - `for (const item of items) { ... }` blocks - `bind:value={form.field}` form controls `bind:value` is only the DOM write-back API. `$:` is still where derived state and effects live. ### Declaring reactive state Top-level `let` bindings become reactive state when they are read by `$:`, `bind:value`, or a live template expression. Use them for mutable source values: ```html ``` Derived values can be declared directly with `$:`. You do not need to predeclare a placeholder `let` first: ```html if (hasCells) { for (const cell of filteredCells) {

{cell.name}

} } ``` In that example `filteredCells` and `hasCells` are both defined by their `$:` assignments and exposed to live template blocks. Top-level `const` bindings, like `allCells`, stay readonly; they can be read by reactive state but are not converted into mutable state themselves. Inside template loops, use loop locals directly. Koze carries those locals into client bindings, so `bind:value={forms[item.id].selected}` works without DOM traversal. ## How hydration works At render time the framework: 1. Runs the top ` ``` The JSON blob is **data**, not code — browsers never parse it as JavaScript — so the payload can safely carry arbitrary strings, including values that contain `` fragments. ### What gets hydrated | Pattern | Hydrated? | Notes | | --- | --- | --- | | `const x = await fn()` | Yes | Direct top-level await. | | `const x = cond ? await fn() : other` | Yes | Ternary with an `await` in either branch. | | `let x = null;` + `if (...) { x = await fn(); }` | Yes | `let` bindings reassigned inside an `await`-bearing top-level block. | | `const x = computeSync()` | No | Pure synchronous code re-runs in the browser. | | `function helper() { ... }` | No | Declarations run on both sides. | | Anything inside a function body | No | Nested `await` only fires when the function is called. | ### Stripping SSR-only code from the browser bundle The framework strips every top-level statement whose body contains an `await` from the browser bundle — those blocks already ran server-side and their mutations are captured in the hydrate payload. Your event handlers, helper functions, non-async conditionals, and template expressions are preserved. ### Browser RPC after hydration When a `$server/*` import is called in the browser, the generated stub returns the same thenable async-value contract the server uses for non-awaited calls: ```html ``` That means you can either `await refreshBoard()` for blocking flow or inspect `.pending`, `.error`, and `.success` immediately for reactive UI. ### Reading additional SSR values Sometimes you want SSR to compute a value the template doesn't need but a client handler does. Use `__kozeReadData`, the runtime helper the framework injects when hydration is in play: ```html ``` ## Event handler directive Author DOM handlers declaratively with `on={expr}`: ```html ... ... ``` Supported events: `click`, `change`, `input`, `submit`, `keydown`, `keyup`, `focus`, `blur`. The `expr` between `{…}` must be a call expression: ```html ``` ### Dispatch rules The framework picks the dispatch path based on what the callee is: | Callee shape | Dispatch | What runs | | --- | --- | --- | | `$server/*` import (e.g. `deleteItem` imported from `$server/items`) | **Server action** — POSTs to the route URL | The route's `actions[fnName]` server function, invoked as `fn(...args, ctx)` where `ctx` is `{ formData, request, url, params }`. See [Actions](/docs/koze/actions) for the full signature. | | Function declared in the top ` ... ``` The bridge passes `(…args, event, element)` to every handler, so an author-declared function with fewer parameters just ignores the trailing ones. ### Client handler vs. direct server-action dispatch Both paths work. Pick based on what the interaction needs: ```html ``` The server side is the same function in both cases. See [Actions](/docs/koze/actions#button-triggered-actions) for the server function signature. ## Client bridge When a route uses `on={…}`, the framework injects a ~1 kB bridge into the route's client bundle. The bridge: - Registers a single document-level listener per event type. - Looks up the ancestor carrying `data-client-*` attributes. - Dispatches to the handler registered via `window.__kozeClient.register(routeId, {…})` for client handlers. - POSTs `_action` / `_args` to the route URL for server-action callees (`$server/*` imports). Handler expressions are stored in a per-route table keyed by a short id (`h0`, `h1`, …) and referenced from the HTML via `data-client-handler="h0"`. Handler bodies live in the client bundle — `data-client-args="[1]"` only carries the serialized args. The bridge is idempotent: multiple registrations against the same `routeId` merge their handler tables, so HMR and client-side navigations that recycle the document don't double-bind. ## Security - The JSON hydration payload escapes `<` to `\u003c` so an attacker can't close the `
...
``` Modules imported from `$lib/*` should be SSR-safe at import time. If a third-party browser library touches `window` during import, wrap it in a small `$lib/*` helper that loads it lazily inside the browser-only function. ### Cloudflare Access Package: Koze Canonical: https://kuratchi.dev/docs/koze/cloudflare-access Markdown: https://kuratchi.dev/docs/koze/cloudflare-access.md > First-class JWT verification for Cloudflare Access — not the same as your application auth ## What this is Cloudflare Access is the platform's edge identity layer. When a user hits a URL behind an Access App, Cloudflare authenticates them against your IdP (Google, Azure AD, Okta, GitHub, etc.) and signs a JWT into the request before forwarding it to your Worker. The header is `cf-access-jwt-assertion`. Koze verifies that JWT as a first-class framework primitive in `koze/access`. The verification is **strict by default** — signature, audience, issuer, and expiry are all checked against the team's signing keys. Failed verification short-circuits with a 403; a successful verification populates `locals.access` and the request continues. ## What this isn't Cloudflare Access answers "is this user from my org?" It is **not** the same thing as `kyzen`, which answers "is this app user signed in?" The two compose orthogonally — see [Composing with kyzen](#composing-with-kuratchi-auth) below. If you only need application auth (signup, signin, sessions, role checks), use `kyzen` and skip this page. If you only need edge identity for an internal admin tool, use this page and skip `kyzen`. Both, neither, either — all valid. ## Quick start ```ts // src/middleware.ts import { defineMiddleware } from 'koze:middleware'; import { requireCloudflareAccess } from '@kuratchi/koze/access'; import { env } from 'cloudflare:workers'; export default defineMiddleware({ access: requireCloudflareAccess({ audience: env.CF_ACCESS_AUD, teamDomain: 'mycompany.cloudflareaccess.com', }), }); ``` That's the whole integration. Every request is now verified against your Access App's policies; routes can read the verified identity through the `koze:access` virtual module. ## Configuration | Option | Required | What it is | |---|---|---| | `audience` | Yes | The Application Audience (AUD) tag from your Access App. Found under the App's **Overview → Application Audience (AUD)**. JWT verification fails if the JWT's `aud` claim doesn't match. | | `teamDomain` | Yes | Your team domain — `mycompany.cloudflareaccess.com` (no scheme). The framework derives the JWKS endpoint as `https:///cdn-cgi/access/certs` and validates the JWT's issuer claim against `https://`. | | `exclude` | No | Path patterns to skip — `/health`, `/api/public/*`, etc. Matches against `pathname` only (query strings don't influence). Trailing slash normalized. | `audience` is intentionally a single string. Each Access App has its own AUD. If a single Worker serves multiple Access Apps, compose multiple middleware steps with different audiences and `exclude` filters: ```ts export default defineMiddleware({ adminAccess: requireCloudflareAccess({ audience: env.ADMIN_AUD, teamDomain: 'mycompany.cloudflareaccess.com', exclude: ['/api/*', '/'], // only protect /admin/* }), apiAccess: requireCloudflareAccess({ audience: env.API_AUD, teamDomain: 'mycompany.cloudflareaccess.com', exclude: ['/admin/*', '/'], // only protect /api/* }), }); ``` ## Reading the verified identity Routes consume the verified identity via the `koze:access` virtual module: ```html

Welcome, {me.email}!

{#if me.groups?.includes('admins')} Admin actions {/if} ``` ### `user(): AccessIdentity` Returns the verified identity. **Throws** if called on an unauthenticated request — guard with `isAuthenticated()` first if your route can serve both states. ```ts interface AccessIdentity { /** Authenticated user email. Always present for user identities. */ email: string; /** Stable Access user ID (`sub` claim). Persists across email changes. */ sub: string; /** Identity provider (`azureAD`, `google`, `onetimepin`, etc.). */ idp?: string; /** Group memberships defined in your IdP, surfaced by Access. */ groups?: string[]; /** Country code Cloudflare derived from the request. */ country?: string; /** Custom claims set on the Access policy (e.g. `department`, `costCenter`). */ custom?: Record; } ``` ### `jwt(): Record` Returns the raw verified JWT payload. Escape hatch for unusual claims that aren't on the standard `AccessIdentity` projection. Same throw semantics as `user()`. ### `isAuthenticated(): boolean` Non-throwing check. `true` when the current request has a verified Access identity, `false` otherwise. Use this in routes that conditionally show authenticated content (e.g. an admin link in a header) without taking the throw of `user()`. ## Excluding paths Use `exclude` for endpoints that shouldn't require Access: ```ts requireCloudflareAccess({ audience: env.CF_ACCESS_AUD, teamDomain: 'mycompany.cloudflareaccess.com', exclude: [ '/health', // exact match '/api/public/*', // glob — matches /api/public/foo, /api/public/foo/bar '/_assets/*', '/webhooks/stripe', ], }); ``` | Pattern | Matches | |---|---| | `/health` | `/health`, `/health/` | | `/api/public/*` | `/api/public`, `/api/public/foo`, `/api/public/foo/bar/baz` | | `/foo` | `/foo`, `/foo/` (NOT `/foo/bar`) | Excluded paths skip JWT verification entirely. Routes under excluded paths cannot call `user()` or `jwt()` (those throw without a verified identity); they can call `isAuthenticated()` and it'll return `false`. ## Composing with kyzen `kyzen` and Cloudflare Access answer different questions and run at different layers — they compose cleanly without conflict. A common shape: ```ts // src/middleware.ts — admin panel + customer app on one Worker import { defineMiddleware } from 'koze:middleware'; import { requireCloudflareAccess } from '@kuratchi/koze/access'; import { kyzenAuthMiddleware } from '@kuratchi/kyzen/middleware'; import { authConfig } from '$server/auth-config'; import { env } from 'cloudflare:workers'; export default defineMiddleware({ // Edge-level identity: only employees with Access policies hit /admin/* access: requireCloudflareAccess({ audience: env.CF_ACCESS_AUD, teamDomain: 'mycompany.cloudflareaccess.com', exclude: ['/auth/*', '/api/public/*', '/'], }), // App-level identity: customers sign in to the public app via credentials auth: kyzenAuthMiddleware(authConfig), }); ``` Two independent identity systems, neither knowing about the other. Routes can read both: ```html ``` A request might have: - **Access only**: an employee hitting an admin URL via Access, no app session. - **App auth only**: a customer signed in to the public app, no Access (path was in `exclude`). - **Both**: an employee who's also signed in to the customer app for testing. - **Neither**: a public route excluded from Access, no app session. Each route decides what it requires. ## How verification works The framework uses [`jose`](https://github.com/panva/jose) under the hood — the standard, Workers-compatible JWT library. 1. `requireCloudflareAccess` is created at module load. The team's JWKS endpoint (`https:///cdn-cgi/access/certs`) is registered for lazy fetch via `jose.createRemoteJWKSet`. 2. On each request, the JWT is read from the `cf-access-jwt-assertion` header. 3. `jose.jwtVerify` validates: signature (against the JWKS), `iss === https://`, `aud === `, `exp` not in the past, `nbf` not in the future. 4. Verified payload is projected onto `AccessIdentity` and stored on `locals.access`. Standard claims fill named fields; everything else flows into `identity.custom`. 5. Failure → 403 with a brief reason (e.g. `Forbidden: Missing Cloudflare Access JWT`, `Forbidden: signature verification failed`). JWKS keys are cached per team domain at module scope — first request after a cold start pays the JWKS fetch; subsequent requests use the cached keys. The cache survives within a Worker isolate's lifetime. ## Security notes The middleware is **strict by default** and there is no `mode: 'trust-headers'` escape hatch. Three reasons: 1. **Header trust is a footgun.** Any path on your Worker that bypasses Access (a misconfigured policy, a forgotten `exclude` rule, a route Access doesn't cover) lets clients spoof identity by setting `cf-access-authenticated-user-email` themselves. JWT verification proves Cloudflare actually signed the assertion. 2. **The same code becoming secure or insecure based on env is how incidents ship.** A "loose in dev, strict in prod" toggle would be a permanent vector for accidental production exposure. 3. **`jose` makes this fast.** JWKS is fetched once, cached. Verification on each request is ~1ms of CPU. The cost of strict-by-default is ~no different than trust-headers in practice. If you need a non-Access path during local dev (where Access isn't running), compose middleware around the dev flag rather than loosening the verifier: ```ts import { dev } from '@kuratchi/koze/environment'; export default defineMiddleware({ // Skip Access entirely in dev — let local requests through unverified. // Production requests always go through `requireCloudflareAccess`. access: dev ? { request: (_, next) => next() } : requireCloudflareAccess({ audience, teamDomain }), }); ``` This is explicit. There's no env-conditional inside the verifier; the dev branch is a different middleware step entirely. If you forget to gate it on `dev`, you get a no-op middleware in production — a behavior change you'll catch immediately rather than a silent verification skip. ## Testing locally Cloudflare Access only injects the JWT for requests that come through your team's hostname after the user authenticates. Local Vite dev hits `localhost`, which doesn't go through Access — so `cf-access-jwt-assertion` is absent and `requireCloudflareAccess` returns 403 on every request. Two options for local development: 1. **Skip Access in dev** (shown above). Fastest, but you're running unauthenticated. 2. **Use `cloudflared` to tunnel** through your team domain. Authentic Access flow, but slower to set up. For most apps, option 1 is the right tradeoff — local dev is for iterating on UI / business logic, not for testing Access policies. Validate Access policies on a staging deployment. ## Configuration in `wrangler.jsonc` `audience` typically lives in `wrangler.jsonc` `vars` so it's available to `env.CF_ACCESS_AUD`: ```jsonc { "name": "my-app", "main": "src/worker.ts", "vars": { "CF_ACCESS_AUD": "your-application-audience-tag-here" } } ``` The `teamDomain` is typically a literal in your code (it doesn't change per environment). If you have multiple environments with different team domains (rare), put it in `vars` too. ## Errors and edge cases | Symptom | Cause | Fix | |---|---|---| | `Forbidden: Missing Cloudflare Access JWT` on every request | Either Access isn't enabled for this hostname, OR you're hitting localhost (Access doesn't sign requests there). | Verify the hostname is bound to an Access App; locally, follow [Testing locally](#testing-locally). | | `Forbidden: signature verification failed` | The JWKS was fetched but the JWT's `kid` doesn't match any cached key. Usually means the key rotated and the cached set is stale (rare — Cloudflare bakes in long overlap windows). | Restart the Worker isolate (deploy a no-op change, or wait for cold-start). | | `Forbidden: "iss" claim check failed` | Wrong `teamDomain`. The JWT's `iss` is `https://` and won't match the configured one. | Check the `Settings → General` page in Cloudflare Zero Trust for your actual team name. | | `Forbidden: "aud" claim check failed` | The audience tag in your config doesn't match the Access App's AUD. | Copy the AUD from `Access → Applications → → Overview → Application Audience (AUD)`. | | Request with valid JWT still gets 403 | Token expired between the user authenticating and the request hitting your Worker (rare, requires a long-lived browser tab). | The user re-authenticates on next nav — Cloudflare handles it transparently. | ## Related - [`kyzen`](/auth) — the application-auth quickstart library. Different layer, composes orthogonally. - [Middleware](/docs/koze/middleware) — composition, hooks, error phases, and lifecycle. - [Cloudflare Access docs](https://developers.cloudflare.com/cloudflare-one/policies/access/) — the platform side: policies, IdPs, and Application Audience. ### Configuration Package: Koze Canonical: https://kuratchi.dev/docs/koze/configuration Markdown: https://kuratchi.dev/docs/koze/configuration.md > How config is split between vite.config.ts, src/middleware.ts, and wrangler.jsonc Koze has **no project-level config file**. The framework is convention-driven for everything it can be (route discovery, DOs, workflows, queues, pipelines, containers, sandboxes, agents), and where configuration genuinely is needed, it splits cleanly across three honest surfaces: | File | Owns | Edited by you | | --- | --- | --- | | `vite.config.ts` | Build-time options for the Koze plugin (security headers, directory overrides) | Yes | | `wrangler.jsonc` | Cloudflare deployment (secrets, routes, custom bindings) | Yes, except auto-synced fields (DOs, workflows, queues, pipelines, containers, sandboxes, assets) | | `src/middleware.ts` | Request-time concerns (auth, ORM auto-migration, custom steps) | Yes | If you used `kuratchi.config.ts` in a previous version, see [Migrating from `kuratchi.config.ts`](#migrating-from-kuratchi-config-ts) below. ## `vite.config.ts` Where the Koze plugin lives. Pass options here for things that need to be baked into the build: ```ts import { defineConfig } from 'vite'; import { cloudflare } from '@cloudflare/vite-plugin'; import { koze } from '@kuratchi/koze/vite'; export default defineConfig({ plugins: [ koze({ // Optional. All defaults are sensible. // routesDir: 'src/routes', // serverDir: 'src/server', // libDir: 'src/lib', security: { contentSecurityPolicy: "default-src 'self'; script-src 'self' 'nonce-{NONCE}'", strictTransportSecurity: 'max-age=31536000; includeSubDomains', permissionsPolicy: 'camera=(), microphone=(), geolocation=()', }, }), cloudflare({ viteEnvironment: { name: 'ssr' } }), ], }); ``` Available options: | Option | Default | Purpose | | --- | --- | --- | | `routesDir` | `'src/routes'` | Where `.koze` route files live | | `serverDir` | `'src/server'` | Where DOs / workflows / containers / queues / pipelines / sandboxes / agents are auto-discovered | | `libDir` | `'src/lib'` | Resolution root for `$lib/*` imports | | `security.contentSecurityPolicy` | `null` | CSP header value. Use `{NONCE}` to opt into per-request nonce stamping on injected ` ``` Then import components freely: ```html Active

Card content

``` ## Migrating from `kuratchi.config.ts` Earlier versions of Koze had a project-level `kuratchi.config.ts`. It was deleted. Each block has a clean home in the new model: | Old config block | New home | | --- | --- | | `orm.databases` | `autoMigrate({ DB: schema })` step in `src/middleware.ts` | | `auth.*` | `kyzenAuthMiddleware({...})` step in `src/middleware.ts` | | `ui.*` | `import 'kuzan/styles/theme.css'` in `src/app.css` + `` in your layout | | `css.*` | Standard PostCSS / Tailwind / Vite CSS plugins in `src/app.css` | | `security.*` | `koze({ security: {...} })` Vite plugin option | | `durableObjects` | Auto-discovered from `*.do.ts` files. Filename derives the binding name (`auth.do.ts` → `AUTH_DO`) or override via `static binding = '...'` on the class. | | `containers` / `workflows` / `queues` / `pipelines` / `sandboxes` | Auto-discovered from singular `*.container.ts` / `*.workflow.ts` / `*.queue.ts` / `*.pipeline.ts` / `*.sandbox.ts` files | | `agents` | Place classes in singular `*.agent.ts` files; Koze re-exports them and you keep the matching Durable Object binding/migration in `wrangler.jsonc` | Migration steps for an existing project: 1. Delete `kuratchi.config.ts`. 2. Move the `auth:` block into `src/middleware.ts` via `kyzenAuthMiddleware(authConfig)`. 3. Add `migrate: autoMigrate({ DB: yourSchema })` to `src/middleware.ts`. 4. If you used `ui.theme`, add `@import '@kuratchi/kuzan/styles/theme.css'` to `src/app.css` and `` to your layout's ``. 5. If you had `security:` headers, move them to `koze({ security: {...} })` in `vite.config.ts`. 6. Remove the `kuratchi.config.ts` line from `tsconfig.json`'s `include` array. That's the whole migration. The framework is a smaller mental model now — three honest config surfaces instead of one config file pretending to own everything. ### Containers Package: Koze Canonical: https://kuratchi.dev/docs/koze/containers Markdown: https://kuratchi.dev/docs/koze/containers.md > Run stateful container-backed Durable Objects with auto-discovery ## Containers Koze auto-discovers `.container.ts` files in `src/server/`. Each file becomes a [Cloudflare Container](https://developers.cloudflare.com/containers/) — a Durable Object with a Docker image attached — with its wrangler config, binding, and SQLite migration wired up on every build. Containers are lower-level than [sandboxes](/docs/koze/sandbox): you supply the image and the class implementation. ```ts // src/server/wordpress.container.ts import { Container } from 'cloudflare:workers'; export default class WordPress extends Container { static image = './docker/wordpress.Dockerfile'; static instanceType = 'standard'; static maxInstances = 5; static sqlite = true; // Your container lifecycle hooks and request handlers go here. } ``` That single file produces every wrangler entry the container needs: ```jsonc // wrangler.jsonc — auto-synced, do not edit by hand { "containers": [ { "name": "wordpress-container", "class_name": "WordPress", "image": "./docker/wordpress.Dockerfile", "instance_type": "standard", "max_instances": 5 } ], "durable_objects": { "bindings": [{ "name": "WORDPRESS_CONTAINER", "class_name": "WordPress" }] }, "migrations": [ { "tag": "v1", "new_sqlite_classes": ["WordPress"] } ] } ``` ## Naming convention The filename maps to the env binding: | File | Binding | Class (as written) | |------|---------|--------------------| | `wordpress.container.ts` | `WORDPRESS_CONTAINER` | `WordPress` | | `redis.container.ts` | `REDIS_CONTAINER` | `Redis` | | `data-pipeline.container.ts` | `DATA_PIPELINE_CONTAINER` | `DataPipeline` | The framework reads the class name from your exported class (`export default class X` or `export class X`). It does not rename anything. ## Static tuning fields Koze parses `static` fields on the class at build time. All fields are optional except `image`. ```ts export default class WordPress extends Container { static image = './docker/wordpress.Dockerfile'; // REQUIRED — Dockerfile path or registry reference static instanceType = 'standard'; // 'lite' (default) or 'standard' static maxInstances = 5; // concurrent container cap static sqlite = true; // opt into new_sqlite_classes migration } ``` ### `image` — required Accepts either a **local Dockerfile path** (build context resolved by wrangler) or a **registry reference** (pre-built image pulled at deploy time). Cloudflare Containers supports both; Koze passes whatever string you declare straight through to the wrangler `containers[].image` field. ```ts static image = './docker/wordpress.Dockerfile'; // local build static image = 'docker.io/library/redis:7.2-alpine'; // registry pull ``` **Sibling-file fallback.** If you omit `static image` and a file named `.Dockerfile` sits next to the `.container.ts`, Koze uses it automatically: ``` src/server/wordpress.container.ts src/server/wordpress.Dockerfile ← picked up without declaring `static image` ``` If neither a static field nor a sibling file is present, the build fails with a clear error — containers cannot guess an image. ### `instanceType` — optional `'lite'` (default) is cheaper and smaller. `'standard'` has more CPU/RAM. Cloudflare may add more tiers; the string passes through to wrangler verbatim. ### `maxInstances` — optional Concurrent container cap. Tune based on expected load. ### `sqlite` — optional When `true`, the framework adds the class to `migrations[].new_sqlite_classes` so the DO uses SQLite-backed storage. Leave unset for classic DurableObject storage. ## Lifecycle and runtime Container runtime, request dispatch, start/stop hooks, and the `Container` base class API all come from Cloudflare's `cloudflare:workers` module — Koze does not wrap or re-export them. See [Cloudflare's Container documentation](https://developers.cloudflare.com/containers/) for the full runtime surface. ## Calling a container from routes Resolve the container the same way you resolve any Durable Object: ```html ``` For anything beyond a single binding name, split the routing key and share the stub between routes in a small helper in `src/server/`. ## See also - **[Sandbox](/docs/koze/sandbox)** — specialized `.sandbox.ts` convention built on top of Cloudflare's `@cloudflare/sandbox` SDK - **[Durable Objects](/docs/koze/durable-objects)** — lower-level primitive (no container attached) - **[Convention-based auto-discovery](/docs/koze/configuration)** — full table of file suffix → binding mappings ### Content Package: Koze Canonical: https://kuratchi.dev/docs/koze/content Markdown: https://kuratchi.dev/docs/koze/content.md > Render Markdown from src/content through the koze:content virtual module Koze discovers Markdown files under `src/content//` and exposes them through the `koze:content` virtual module. The folder name becomes the property on `content`. ```text src/ content/ docs/ getting-started.md settings/api-keys.md changelog/ first-release.md ``` ```html if (doc) {
{@html doc.html}
} ``` ## Content files Each Markdown file can include YAML frontmatter: ```md --- title: API Keys description: Create and rotate API keys. section: Settings order: 20 --- # API Keys Use short-lived keys for automation. ``` The content id is derived from the file path below the group: | File | ID | | --- | --- | | `src/content/docs/getting-started.md` | `getting-started` | | `src/content/docs/settings/api-keys.md` | `settings/api-keys` | | `src/content/docs/settings/index.md` | `settings` | | `src/content/docs/index.md` | `index` | Loose Markdown files directly inside `src/content/` are invalid. Put files inside a named group so the runtime API has a stable property to expose. ## API `content..list()` returns sorted metadata for every Markdown file in the group: ```ts const docs = await content.docs.list(); ``` Each item includes: | Field | Description | | --- | --- | | `id` | Path id used by `render(id)` | | `href` | Default URL-like path, such as `/docs/settings/api-keys` | | `file` | Source file path relative to the project root | | `title` | Frontmatter `title`, first heading, or a titleized filename | | `description` | Frontmatter `description`, when present | | `section` | Frontmatter `section`, when present | | `order` | Frontmatter `order`, defaulting to `999` | | `headings` | Extracted Markdown headings with `depth`, `slug`, and `text` | | `frontmatter` | Raw frontmatter object | `content..render(id)` returns the same metadata plus the raw Markdown body and rendered HTML: ```ts const doc = await content.docs.render('settings/api-keys'); if (doc) { console.log(doc.html); } ``` Missing ids return `null`. ## Rendering Markdown rendering uses a CommonMark/GFM pipeline internally. Tables, task lists, fenced code blocks, and heading extraction work out of the box. Raw HTML from Markdown is not passed through by default; render authored team docs as Markdown, and use Koze components/routes for richer interactive UI. ## Generated Types `src/app.d.ts` is regenerated during dev and build. If `src/content/docs` exists, `content.docs` is typed. If `src/content/changelog` exists, `content.changelog` is typed. Folder names with dashes are still available with bracket access: ```ts await content['release-notes'].list(); ``` ### Development Package: Koze Canonical: https://kuratchi.dev/docs/koze/development Markdown: https://kuratchi.dev/docs/koze/development.md > The Vite-powered dev loop, build output, and execution model ## Commands ```bash bun run dev # vite dev — local dev server on :5173 bun run build # vite build — emits dist/ for Wrangler bun run deploy # build + wrangler deploy ``` `vite dev` uses the Cloudflare Vite plugin under the hood, so local requests hit a Workers-accurate runtime with real DO / KV / D1 bindings. ## What the Vite plugin does The `koze()` plugin in `vite.config.ts` is the entire build pipeline. At config time it: - **Discovers routes** — every `src/routes/**/*.koze` file becomes a URL pattern. - **Auto-maintains `wrangler.jsonc`** — fields for Durable Objects, containers, sandboxes, queues, pipelines, and assets are kept in sync with `src/server/*.do.ts`, `*.workflow.ts`, `*.queue.ts`, `*.pipeline.ts`, `*.sandbox.ts`, `*.container.ts`. - **Synthesizes virtual modules** — `koze:worker`, `koze:request`, `koze:layout`, `koze:app`, and friends materialize at build time. - **Extracts client fragments** — template-body `

Hello, {name}

``` ## Validating RPC Input Attach schemas by method name with `static schemas`. Koze validates input before invoking the method. ```ts import { DurableObject } from 'cloudflare:workers'; import { schema, type InferSchema } from '@kuratchi/koze'; export default class UserDO extends DurableObject { static schemas = { setProfile: schema({ name: schema.string().min(1), likesDogs: schema.boolean().optional(false), }), }; async setProfile(data: InferSchema<(typeof UserDO.schemas).setProfile>) { await this.ctx.storage.put('profile', data); } } ``` **Rules:** - Schema-backed RPC methods accept one object argument - `static schemas` keys must match the public method names exactly - Invalid payloads return `400` - Use `InferSchema<(typeof MyDO.schemas).methodName>` for typed parameters ## Schema Examples **With defaults and arrays:** ```ts import { DurableObject } from 'cloudflare:workers'; import { schema, type InferSchema } from '@kuratchi/koze'; export default class UserDO extends DurableObject { static schemas = { savePreferences: schema({ tags: schema.string().list(), marketingEmails: schema.boolean().optional(false), }), }; async savePreferences(data: InferSchema<(typeof UserDO.schemas).savePreferences>) { await this.ctx.storage.put('preferences', data); return { ok: true }; } } ``` ## Stub Resolution By default, the framework's auto-discovered DOs resolve via `idFromName('global')` — every binding points to a singleton instance per worker. Apps that need per-user / per-tenant routing register a custom resolver at runtime: ```ts // src/server/do-routing.ts import { __registerDoResolver } from '@kuratchi/koze/runtime/do.js'; import { getCurrentUser } from '@kuratchi/kyzen'; import { env } from 'cloudflare:workers'; __registerDoResolver('USER_DO', async () => { const user = await getCurrentUser(); if (!user?.organizationId) return null; const ns = (env as any).USER_DO; return ns.get(ns.idFromName(user.organizationId)); }); ``` Import the routing module from `src/middleware.ts` (any side-effect import works) so the resolver registers before the first request: ```ts import './server/do-routing'; ``` For multi-tenant org databases specifically, `kyzen` ships `getOrgClient(organizationId)` (resolves the routing key from the admin DB then returns a stub) and `getOrgStubByName(doName)` (sync; pass the routing key directly). The auth package uses `getOrgStubByName` internally during signin/signup, so apps usually just call `getCurrentUser()` and let the package handle routing — see [Organizations and Schema](/docs/kyzen/organizations-and-schema) for the full pattern. ## Storage API Use the Durable Object storage API for persistence: ```ts export default class CounterDO extends DurableObject { async increment() { const current = (await this.ctx.storage.get('count')) || 0; await this.ctx.storage.put('count', current + 1); return current + 1; } async getCount() { return (await this.ctx.storage.get('count')) || 0; } async reset() { await this.ctx.storage.delete('count'); } } ``` ## Alarms Schedule future work with alarms: ```ts export default class ReminderDO extends DurableObject { async scheduleReminder(delayMs: number) { const alarmTime = Date.now() + delayMs; await this.ctx.storage.setAlarm(alarmTime); return { scheduled: new Date(alarmTime).toISOString() }; } async alarm() { // Called when the alarm fires await this.sendReminder(); } } ``` ## WebSockets Handle WebSocket connections in Durable Objects: ```ts export default class ChatRoomDO extends DurableObject { async fetch(request: Request) { const upgradeHeader = request.headers.get('Upgrade'); if (upgradeHeader === 'websocket') { const pair = new WebSocketPair(); const [client, server] = Object.values(pair); this.ctx.acceptWebSocket(server); return new Response(null, { status: 101, webSocket: client, }); } return new Response('Expected WebSocket', { status: 400 }); } async webSocketMessage(ws: WebSocket, message: string) { // Broadcast to all connected clients for (const client of this.ctx.getWebSockets()) { client.send(message); } } } ``` ## Wrangler Configuration The framework auto-syncs Durable Object bindings to `wrangler.jsonc`: ```jsonc { "durable_objects": { "bindings": [ { "name": "USER_DO", "class_name": "UserDO" } ] }, "migrations": [ { "tag": "v1", "new_classes": ["UserDO"] } ] } ``` ### Getting Started Package: Koze Canonical: https://kuratchi.dev/docs/koze/getting-started Markdown: https://kuratchi.dev/docs/koze/getting-started.md > Install koze, koze/vite, and boot a first app on Cloudflare Workers ## Install Koze is **Vite-first**. Every new project runs through `vite dev` / `vite build` with the Koze plugin. ### Scaffold with `koze` ```bash npx koze create my-app cd my-app bun install bun run dev ``` The scaffolder drops a working Vite project with routes, assets, and a `wrangler.jsonc` pointed at `src/worker.ts`. ### Or install into an existing project ```bash npm install @kuratchi/koze npm install -D vite wrangler @cloudflare/workers-types ``` Optional packages (install only what you need): | Package | When | | --- | --- | | `kunii` | D1 / Durable Object SQLite schema + query builder | | `kyzen` | Credentials, sessions, OAuth, RBAC, Turnstile | | `kuzan` | Ready-made HTML component library | | `tailwindcss` `@tailwindcss/vite` | Tailwind CSS (plain Vite plugin) | | `lightningcss` | CSS transformer (configure via `vite.config.ts`) | ## Minimal project shape ```text my-app/ ├─ package.json ├─ vite.config.ts ├─ wrangler.jsonc ├─ tsconfig.json └─ src/ ├─ worker.ts export { default } from 'koze:worker' ├─ middleware.ts defineMiddleware({ ... }) ├─ app.koze document shell ├─ app.css global stylesheet (optional) ├─ assets/ static files served verbatim at / ├─ server/ auto-discovered DOs / workflows / queues / pipelines / containers └─ routes/ ├─ layout.koze shared fragment └─ index.koze / ``` There is no project-level config file. Build-time options (security headers, directory overrides) are passed to the `koze()` Vite plugin. Request-time concerns (auth, ORM auto-migration, custom steps) live in `src/middleware.ts`. For a full reference of every special file, see [Project structure](/docs/koze/project-structure). ## `vite.config.ts` The Koze plugin owns routing, virtual modules, and Wrangler sync. Nothing else to configure for the common case. ```ts import { defineConfig } from 'vite'; import { koze } from '@kuratchi/koze/vite'; export default defineConfig({ plugins: [koze()], }); ``` ## `wrangler.jsonc` ```jsonc { "name": "my-app", "main": "src/worker.ts", "compatibility_date": "2026-01-01", "compatibility_flags": ["nodejs_compat"], "assets": { "directory": "src/assets" } } ``` The Koze plugin auto-maintains `durable_objects`, `containers`, `queues.consumers`, `pipelines`, and `migrations[]` entries based on what it discovers in `src/server/`. Do not hand-edit those fields. ## `package.json` scripts ```json { "scripts": { "dev": "vite", "build": "vite build", "deploy": "vite build && wrangler deploy" } } ``` ## `src/worker.ts` ```ts export { default } from 'koze:worker'; ``` That's the whole file. The virtual module synthesizes `fetch` + `queue` handlers, Durable Object exports, and any discovered convention classes. ## `src/app.koze` — document shell ```html My App ``` Optional — the framework provides a minimal default shell when absent. Don't add a `` for your global CSS; Koze injects it (see next section). ## `src/routes/layout.koze` — shared fragment Wraps every page. Not a document — no ``, no ``. ```html
Home About
``` ## `src/routes/index.koze` — first page ```html

Hello Koze

if (items.length > 0) {
    for (const item of items) {
  • {item}
  • }
} else {

No items yet.

} ``` The page is server-rendered. The top `
    for (const item of items) {
  • {item.title}
  • }
``` The same script runs on the server (resolving the `await`) and in the browser (wiring up `confirmDelete`). No `addEventListener`, no `document.querySelector`, no `typeof document` guards. See [Client interactivity](/docs/koze/client-interactivity) for the event-handler reference, SSR data hydration, and browser-only DOM patterns inside the single route script. ## Run locally ```bash bun install bun run dev ``` `vite dev` serves the app on `http://localhost:5173` with the Cloudflare Vite plugin providing a Workers-accurate runtime. ## What to read next - [Project structure](/docs/koze/project-structure) — every special file reference - [Configuration](/docs/koze/configuration) — where each kind of config lives - [Routing](/docs/koze/routing) — dynamic segments, template syntax, components - [Actions](/docs/koze/actions) — native form POSTs with server functions - [Middleware](/docs/koze/middleware) — request lifecycle, hooks, composition - [Request APIs](/docs/koze/request-apis) — `koze:request`, redirects, env helpers - [ORM Getting Started](/docs/kunii/getting-started) — schemas, migrations, queries - [Auth Getting Started](/docs/kyzen/getting-started) — credentials, sessions, OAuth, RBAC ### Middleware Package: Koze Canonical: https://kuratchi.dev/docs/koze/middleware Markdown: https://kuratchi.dev/docs/koze/middleware.md > Compose request-time concerns — auth, migrations, custom logic — in src/middleware.ts `src/middleware.ts` is where cross-cutting request-time policy composes. It's a single file that exports a default `defineMiddleware({...})` object; each property is a named **step** with up to four lifecycle hooks. The framework runs them in declaration order around matched pages and API routes. Middleware is not the application router. Keep endpoint-specific HTTP behavior in [API Routes](/docs/koze/api-routes) under `src/routes/api/`; use middleware for policy that should wrap many routes, such as auth, migrations, route guards, response headers, tenant lookup, or forwarding a whole protocol to a Durable Object. ## Minimum file ```ts // src/middleware.ts import { defineMiddleware } from 'koze:middleware'; export default defineMiddleware({ // empty — the framework runs without any custom middleware }); ``` A real app layers steps on top: ```ts // src/middleware.ts import { defineMiddleware } from 'koze:middleware'; import { autoMigrate } from '@kuratchi/kunii'; import { kyzenAuthMiddleware } from '@kuratchi/kyzen/middleware'; import { adminSchema } from './server/schemas/admin'; import { authConfig } from './server/auth-config'; export default defineMiddleware({ // 1. Apply pending D1 migrations on the first request per worker isolate. migrate: autoMigrate({ DB: adminSchema }), // 2. Sessions, guards, OAuth, rate-limit, turnstile. auth: kyzenAuthMiddleware(authConfig), // 3. Your own steps. logging: { async request(ctx, next) { const start = Date.now(); const res = await next(); console.log(`${ctx.request.method} ${ctx.url.pathname} ${Date.now() - start}ms`); return res; }, }, }); ``` That's the entire integration shape. `autoMigrate` and `kyzenAuthMiddleware` are not framework features — they're ordinary middleware steps. Swap them for any third-party auth/ORM/observability tool by replacing the step. ## The four hooks Each named step can declare up to four async hooks. Every hook is optional; declare only the ones you need. | Hook | When it runs | Receives | Returns | | --- | --- | --- | --- | | `request` | Before routing | `(ctx, next)` | A `Response` (short-circuits) or `await next()` | | `route` | After routing, before render | `(ctx, next)` | A `Response` or `await next()` | | `response` | After render | `(ctx, response)` | A `Response` (the final one to send) | | `error` | When any prior phase throws | `(ctx, error)` | A `Response` (custom error page) or null | ### `request` — gate, redirect, forward broad protocols Runs before the framework matches a route. Common uses: - Auth gates (redirect unauthenticated users) - Multi-tenant routing (look up a hostname → tenant) - Forwarding to a Durable Object (agents, sites) - Rate limiting Do not build a large API switchboard here. If the path is an endpoint such as `/api/v1/platform/sites/:id/files`, put it in `src/routes/api/v1/platform/sites/[id]/files.ts` and export method handlers there. ```ts auth: { async request(ctx, next) { if (ctx.url.pathname.startsWith('/admin/') && !ctx.locals.user) { return new Response('Unauthorized', { status: 401 }); } return next(); }, }, ``` Returning a `Response` short-circuits — the framework stops here and skips routing/rendering. Calling `next()` continues the pipeline. ### `route` — augment the matched route Runs after the framework has matched a route (`ctx.params` is populated) but before render. Useful for: - Per-route logging that needs the matched pattern - Adding common props to `ctx.locals` - Final auth checks where the route pattern matters ```ts audit: { async route(ctx, next) { ctx.locals.routePattern = ctx.params; // available to load() and actions return next(); }, }, ``` ### `response` — modify the outgoing response Runs after the route has rendered. Receives the final `Response`; return a (possibly modified) one. ```ts headers: { async response(ctx, response) { // Headers from `Response.redirect()` and similar are immutable. Clone // to a mutable shape before setting custom headers. const out = new Response(response.body, response); out.headers.set('X-App-Version', '2.4.1'); return out; }, }, ``` ### `error` — custom error pages Runs when any earlier phase throws. Return a `Response` to override the framework's default 500 page; return `null`/`undefined` to let the framework handle it. ```ts errors: { async error(ctx, error) { if (error instanceof MyDomainError) { return new Response(error.userMessage, { status: 400 }); } return null; // let the framework render the default 500 }, }, ``` ## The middleware context Every hook receives a `MiddlewareContext`: ```ts interface MiddlewareContext { request: Request; // the incoming request env: Env; // worker bindings (D1, KV, R2, DO, …) ctx: ExecutionContext; // Cloudflare execution context url: URL; // parsed URL params: Record; // route params (after `route` phase) locals: Record; // shared scratchpad across hooks } ``` `ctx.locals` is the scratchpad — set values in one hook, read them in another, then read the same values in `load()` / actions / `$server/*` modules via `locals`. ```ts auth: { async request(ctx, next) { ctx.locals.user = await loadUser(ctx); return next(); }, }, ``` ```ts // any $server/*.ts module or route action import { locals } from 'koze:request'; export async function load() { const { user } = locals as App.Locals & { user: { id: string; email: string } | null }; return { user }; } ``` Type `App.Locals` in `src/app.d.ts` to get IDE autocomplete on `ctx.locals`: ```ts declare global { namespace App { interface Locals { user: { id: string; email: string } | null; } } } export {}; ``` ## Execution order Steps run in **declaration order** for `request` and `route` phases, **reverse declaration order** for the `response` phase (so the first `request` hook is the last to see the response — like Express middleware). ```ts defineMiddleware({ outer: { /* request → route → response → error */ }, inner: { /* request → route → response → error */ }, }); ``` Per-request flow: ``` outer.request → inner.request → routing → inner.route → outer.route → render ← inner.response ← outer.response (reverse order) ``` If `inner.request` throws, `outer.error` runs first (then `inner.error`). If `outer.request` short-circuits with a `Response`, no other steps run; that response goes back through the (empty) response chain. ## Real example: kuratchi.cloud `apps/web` is the framework's reference dashboard. Its `src/middleware.ts` composes four steps: ```ts export default defineMiddleware({ // 1. Cold-start ORM migrations (idempotent, runs once per isolate). migrate: autoMigrate({ DB: adminSchema }), // 2. Auth — sessions, guards, rate-limit, turnstile, OAuth. auth: kyzenAuthMiddleware(authConfig), // 3. Per-tenant site rendering — short-circuits when the hostname maps to a site. sites: { async request(ctx, next) { const siteResponse = await resolveSiteRequest(ctx.request); if (siteResponse) return siteResponse; return next(); }, }, // 4. AI agent routing — forwards /agents/* to per-session DOs. agents: { async request(ctx, next) { if (!ctx.url.pathname.startsWith('/agents/')) return next(); // verify token, forward to the right DO // … }, }, }); ``` Read `apps/web/src/middleware.ts` for the full source. ## Patterns ### Conditional middleware (dev vs prod) ```ts import { dev } from 'koze:environment'; defineMiddleware({ auth: dev ? { async request(_ctx, next) { return next(); } } // bypass in dev : kyzenAuthMiddleware(authConfig), }); ``` `dev` is a compile-time constant (`true` during `vite dev`, `false` during `vite build`), so the unused branch tree-shakes out of the production worker. ### Sharing data without globals `ctx.locals` is per-request and reset between requests. Don't reach for module-level variables to share state — they leak across requests in long-lived isolates. The `locals` object is the right scratchpad. ### Returning early without a `Response` Hooks must return either a `Response` (short-circuit) or the result of `await next()` (continue). Returning `undefined`/`null` from `request`/`route` is treated as "did nothing, continue" by the framework, but explicit `return next()` is clearer. ### Handling `Response.redirect()` and immutable headers `Response.redirect(...)` and similar constructors produce responses with frozen headers. The framework already copies these to a mutable shape before passing them to your `response` hooks, so you can call `response.headers.set(...)` directly. If you build your own response in code (e.g. inside an action), wrap it with `new Response(body, response)` before mutating headers. ## Where things live | Use case | Where | | --- | --- | | Auth, ORM auto-migration, route guards, custom request logic | `src/middleware.ts` | | JSON APIs, webhooks, uploads, health checks, agent-visible HTTP endpoints | `src/routes/api/**/*.ts` | | Build-time options (security headers, directory overrides) | `koze({...})` in `vite.config.ts` | | Cloudflare deployment (secrets, routes, custom bindings) | `wrangler.jsonc` | | ORM schema definitions | `src/server/schemas/*.ts` (imported by middleware) | See [Configuration](/docs/koze/configuration) for the full split. ## Read next - [Request APIs](/docs/koze/request-apis) — the request context shape, redirect helpers, env helpers - [API Routes](/docs/koze/api-routes) — file-based HTTP endpoints under `src/routes/api` - [Auth](/docs/kyzen/getting-started) — wire `kyzenAuthMiddleware` into your project - [ORM Migrations](/docs/kunii/migrations) — `autoMigrate` as a middleware step ### Pipelines Package: Koze Canonical: https://kuratchi.dev/docs/koze/pipelines Markdown: https://kuratchi.dev/docs/koze/pipelines.md > Send events to Cloudflare Pipelines with convention-based bindings Koze auto-discovers `.pipeline.ts` files in `src/server/`. Each file creates a Cloudflare Pipelines Worker binding in `wrangler.jsonc`, a typed `koze:pipeline` handle you can use from routes, actions, RPC, and server modules, and optional generated setup artifacts under `_cloudflare/pipelines//`. Cloudflare Pipelines bindings send JSON records to streams from Workers without managing API tokens. Koze wires the binding and generates schema, SQL, and setup commands when you declare them. Cloudflare still owns the remote stream, sink, and pipeline resources, so creation is an explicit provisioning step instead of a build-time side effect. ## Quick start ```ts // src/server/analytics.pipeline.ts // Empty is valid. Koze uses the filename as the framework name, // binding name, and Cloudflare stream/pipeline name. ``` This produces: ```jsonc { "pipelines": [ { "pipeline": "analytics", "binding": "ANALYTICS_PIPELINE" } ] } ``` Use it from server code: ```ts import { pipelines } from 'koze:pipeline'; type AnalyticsEvent = { userId: string; event: 'view' | 'click'; path: string; }; export async function POST({ request }: { request: Request }) { const event = await request.json(); await pipelines.analytics.send(event); return new Response(null, { status: 204 }); } ``` `send()` accepts one record or an array of records. The underlying Cloudflare binding receives an array. ## Naming | File | Binding | `pipelines` handle | Cloudflare resource | | --- | --- | --- | --- | | `analytics.pipeline.ts` | `ANALYTICS_PIPELINE` | `pipelines.analytics` | `analytics` unless overridden | | `clickstream.pipeline.ts` | `CLICKSTREAM_PIPELINE` | `pipelines.clickstream` | `clickstream` unless overridden | | `data-lake.pipeline.ts` | `DATA_LAKE_PIPELINE` | `pipelines['data-lake']` | `data-lake` unless overridden | Override the binding only when you need to match an existing Worker binding name: ```ts export const pipeline = 'analytics-stream-id-or-name'; export const binding = 'ANALYTICS'; ``` ## Generated setup Declare a stream schema and an R2 Data Catalog sink in the same `.pipeline.ts` file when the pipeline should be provisioned from the app's source of truth: ```ts // src/server/activity-log.pipeline.ts export const pipeline = 'pims_activity_log_stream'; export const pipelineName = 'pims-activity-log'; export const schema = { schema_version: 'int32!', id: 'string!', created_at: 'string!', action: 'string!', status: 'string!', searchable_text: 'string', }; export const sink = { type: 'r2-data-catalog', name: 'activity_log_sink', bucket: 'pims-activity-log', namespace: 'default', table: 'activity_logs', rollInterval: 10, }; ``` Koze emits: ```txt _cloudflare/pipelines/activity-log/schema.json _cloudflare/pipelines/activity-log/pipeline.sql _cloudflare/pipelines/activity-log/setup.ps1 _cloudflare/pipelines/activity-log/README.md ``` The generated stream command disables the HTTP endpoint by default because Koze apps write through the Worker Pipeline binding. Enable HTTP in Cloudflare only when non-Worker systems need to push directly to the stream. ## Types Discovered pipeline names are emitted into `src/app.d.ts` as a string-literal union and as typed properties on `pipelines`. Passing an unknown name to `pipeline()` or `sendPipeline()` is a type error after the Vite plugin regenerates types. ```ts import { pipelines, sendPipeline } from 'koze:pipeline'; await pipelines.analytics.send({ userId: 'u_123', event: 'view', path: '/dashboard' }); await sendPipeline('analytics', [{ userId: 'u_123', event: 'click', path: '/pricing' }]); ``` ## Cloudflare setup For source-controlled apps, prefer declaring the schema and sink in `.pipeline.ts`, then run the generated `_cloudflare/pipelines//setup.ps1` commands when creating or recreating the Cloudflare resources. For one-off or externally managed pipelines, create the stream/pipeline with Wrangler or the Cloudflare dashboard, then put its name or ID in the `.pipeline.ts` file. Koze still keeps the Worker binding in `wrangler.jsonc` current. Related Cloudflare docs: - [Writing to streams](https://developers.cloudflare.com/pipelines/streams/writing-to-streams/) - [Pipelines overview](https://developers.cloudflare.com/pipelines/) ### Project structure Package: Koze Canonical: https://kuratchi.dev/docs/koze/project-structure Markdown: https://kuratchi.dev/docs/koze/project-structure.md > Every special file in a Koze app and what it's for Koze is convention-driven. Drop a file in the right place with the right name and the compiler wires it into the Worker automatically — no explicit registration. ## Canonical project shape ```text my-app/ ├─ package.json ├─ vite.config.ts plugin install point + plugin options ├─ wrangler.jsonc Cloudflare deployment config (auto-synced) ├─ tsconfig.json └─ src/ ├─ worker.ts one-line re-export of koze:worker ├─ middleware.ts request/route/response/error hooks ├─ app.koze document shell (, , ) ├─ app.css global stylesheet (optional; auto-linked) ├─ assets/ static files served verbatim at the URL root ├─ routes/ │ ├─ layout.koze shared fragment wrapping every page │ ├─ index.koze / │ └─ blog/[slug]/index.koze /blog/:slug ├─ server/ │ ├─ *.ts private server-only modules │ ├─ *.do.ts Durable Object classes │ ├─ *.workflow.ts Workflow classes │ ├─ *.queue.ts Queue consumers │ ├─ *.sandbox.ts Sandbox classes │ ├─ *.container.ts Container classes │ ├─ *.agent.ts Agent classes │ └─ schemas/ ORM schema modules └─ lib/ shared browser-safe utilities and components (imported via $lib/*) ``` There is **no project-level config file**. Build-time options (security headers, directory overrides) are passed to the `koze()` Vite plugin in `vite.config.ts`. Request-time concerns (auth, ORM auto-migration, custom steps) live in `src/middleware.ts`. Anything generated (the `.koze/` directory, `dist/`) is build output. Never commit edits there. ## Route files ### `src/app.koze` — document shell The outer HTML document. Owns ``, ``, ``, and ``. Exactly one `` where the layout+page stream renders. ```html My App ``` - Optional. When absent, the framework synthesizes a minimal default shell. - A top `
Home About
``` - Fragment, not a document. Think "wrapper component". - Works like any other route file: top `

Items ({items.length})

    for (const item of items) {
  • {item.title}
  • }
``` - `index.koze` is the default file per segment. - Dynamic segments use `[brackets]`: `[slug]` → `:slug`, `[...rest]` → catchall. - See [Routing](/docs/koze/routing) for the full template syntax. ### Render chain When a user hits `/blog/hello`: ```text app.render(data, layout.render(data, page.render(data))) ``` All three files use the same ` ``` ### `src/server/schemas/` ORM schema modules. Imported by `src/middleware.ts` and passed to `autoMigrate({ DB: schema })` to keep the live database in sync with the declared schema. Same modules feed `kunii(env.DB, schema)` for query-time type safety. ### `.koze/` (generated) Build output — compiled routes, Worker module, DO proxies, transformed server modules, public asset mirror. Add it to `.gitignore`. Never edit. ## Virtual modules Imported as `'koze:*'`. The Vite plugin synthesizes them at build time — they are not real files. | Module | What it provides | | --- | --- | | `koze:request` | `url`, `pathname`, `searchParams`, `params`, `slug`, `method` for the current request | | `koze:environment` | `dev` (compiled to a literal boolean) | | `koze:workflow` | `workflowStatus()` and typed workflow helpers | | `koze:pipeline` | `pipelines`, `pipeline()`, and `sendPipeline()` for discovered Pipeline bindings | | `koze:content` | `content..list()` and `content..render(id)` for Markdown under `src/content/` | | `koze:navigation` | `redirect()` and related helpers | | `koze:worker` | The full Worker module (`fetch` + `queue` + DO exports) | | `koze:middleware` | Resolves your `src/middleware.ts` | | `koze:app`, `koze:layout` | Compiled app shell + layout renderers | You rarely need to import the last two directly — the compiler wires them into every route. ### Queues Package: Koze Canonical: https://kuratchi.dev/docs/koze/queues Markdown: https://kuratchi.dev/docs/koze/queues.md > Build queue consumers with convention-based discovery ## Queue Consumers Koze auto-discovers `.queue.ts` files in `src/server/`. Each file becomes a queue consumer handler. ```ts // src/server/notifications.queue.ts import type { MessageBatch } from 'cloudflare:workers'; export default async function (batch: MessageBatch, env: Env, ctx: ExecutionContext) { for (const message of batch.messages) { console.log('Processing:', message.body); message.ack(); } } ``` ## Naming Convention Queue binding names are derived from the filename: | File | Binding | |------|---------| | `notifications.queue.ts` | `NOTIFICATIONS` | | `email-jobs.queue.ts` | `EMAIL_JOBS` | | `data-sync.queue.ts` | `DATA_SYNC` | ## Export Patterns The framework supports two export patterns: **Default export (recommended):** ```ts export default async function (batch: MessageBatch, env: Env, ctx: ExecutionContext) { // handle messages } ``` **Named export:** ```ts export async function queue(batch: MessageBatch, env: Env, ctx: ExecutionContext) { // handle messages } ``` ## Wrangler Configuration Add the queue binding to `wrangler.jsonc`: ```jsonc { "queues": { "consumers": [ { "queue": "notifications-queue", "max_batch_size": 10, "max_batch_timeout": 30 } ] } } ``` The binding name in Wrangler must match the auto-generated binding from the filename. ## Producing Messages To send messages to a queue, use the binding directly in your server code: ```ts // In a server action or RPC await env.NOTIFICATIONS.send({ type: 'welcome', userId: user.id, }); ``` ## Message Types Define your message types for type safety: ```ts // src/server/notifications.queue.ts import type { MessageBatch, Message } from 'cloudflare:workers'; interface NotificationMessage { type: 'welcome' | 'reminder' | 'alert'; userId: string; data?: Record; } export default async function ( batch: MessageBatch, env: Env, ctx: ExecutionContext ) { for (const message of batch.messages) { const { type, userId, data } = message.body; switch (type) { case 'welcome': await sendWelcomeEmail(userId); break; case 'reminder': await sendReminder(userId, data); break; case 'alert': await sendAlert(userId, data); break; } message.ack(); } } ``` ## Error Handling Messages that throw errors are automatically retried by Cloudflare. Use `message.ack()` to mark successful processing: ```ts export default async function (batch: MessageBatch, env: Env, ctx: ExecutionContext) { for (const message of batch.messages) { try { await processMessage(message.body); message.ack(); } catch (err) { // Message will be retried console.error('Failed to process:', err); } } } ``` ## Batch Processing Process entire batches for efficiency: ```ts export default async function (batch: MessageBatch, env: Env, ctx: ExecutionContext) { const results = await Promise.allSettled( batch.messages.map(async (message) => { await processMessage(message.body); message.ack(); }) ); const failed = results.filter(r => r.status === 'rejected'); if (failed.length > 0) { console.error(`${failed.length} messages failed`); } } ``` ### Request APIs Package: Koze Canonical: https://kuratchi.dev/docs/koze/request-apis Markdown: https://kuratchi.dev/docs/koze/request-apis.md > Request context, redirects, environment helpers — what's safe in templates and what belongs server-side This page covers the runtime APIs your route templates and `$server/*` modules use to read request data, set cookies, redirect, and access environment values. For lifecycle hooks (`request` / `route` / `response` / `error`), see [Middleware](/docs/koze/middleware). ## Virtual modules in route ` ``` | Export | Type | Description | |--------|------|-------------| | `url` | `URL` | Full request URL | | `pathname` | `string` | URL pathname | | `searchParams` | `URLSearchParams` | Query parameters | | `params` | `Record` | Route params (e.g. `{ id: '123' }`) | | `slug` | `string \| undefined` | First param value or `params.slug` | | `method` | `string` | HTTP method | The compiler enforces this allow-list in route scripts. That's deliberate: anything *not* in this list (`request`, `headers`, `locals`, anything derived from auth state) cannot be safely serialized to the client. ### `koze:navigation` Route-script redirect: ```html ``` `redirect()` throws a `RedirectError` that the framework catches and converts to a 303 response. Only meaningful server-side; calling it during client hydration is a no-op the framework strips at compile time. The same `redirect()` import also works inside `src/server/*.ts`. Client navigation: ```html ``` `navigateTo(path, { replace })` is browser-only. For same-origin URLs it fetches the HTML page, updates `history.pushState()` or `history.replaceState()`, swaps the document, and dispatches `koze:navigation`. If the fetch fails or does not return HTML, Koze falls back to normal `location.assign()` / `location.replace()` navigation. To refetch the current route after a mutation without pushing a new history entry, use `refreshRoute()`: ```html ``` `refreshRoute()` uses the same same-origin HTML navigation pipeline as `navigateTo()`, but replaces the current history entry. ## Server module helpers Inside `src/server/*.ts` modules, prefer the server-capable `koze:*` modules: ```ts import { request, url, headers, params, locals } from 'koze:request'; import { redirect } from 'koze:navigation'; import { cookies } from 'koze:cookies'; ``` Use `cloudflare:workers` directly for `env`. `locals` is the single request-scoped scratchpad server code reads after middleware writes to `ctx.locals`: ```ts // src/middleware.ts auth: { async request(ctx, next) { ctx.locals.user = await loadUser(ctx); return next(); }, }, ``` ```ts // src/server/items.ts import { locals } from 'koze:request'; export async function listItems() { const { user } = locals as App.Locals & { user: { id: string } | null }; if (!user) return []; // … } ``` Type `App.Locals` in `src/app.d.ts` to get autocomplete on `locals`: ```ts declare global { namespace App { interface Locals { user: { id: string; email: string } | null; } } } export {}; ``` ## Cookies Server modules can read and write cookies through `koze:cookies`: ```ts import { cookies } from 'koze:cookies'; const theme = cookies.get('theme'); cookies.set('theme', 'dark', { path: '/', sameSite: 'Lax' }); cookies.delete('theme', { path: '/' }); ``` ## Environment helpers ### `dev` — compile-time environment flag ```ts import { dev } from 'koze:environment'; ``` - During `vite dev` → `dev` evaluates to `true` - During `vite build` → `dev` evaluates to `false` Because `dev` is compiled to a literal boolean, the unused branch in any `if (dev) { … }` block tree-shakes out of the production worker. Use it to bypass auth gates locally, log verbosely, or inline dev-only stubs: ```ts import { dev } from 'koze:environment'; defineMiddleware({ auth: dev ? { async request(_ctx, next) { return next(); } } // dev bypass : kyzenAuthMiddleware(authConfig), }); ``` `dev` is safe to import from: - Route `
    for (const item of items) {
  • {item.title}
  • }
``` What runs where: - `await $server/*` functions execute on the server at render time. The resolved values are serialized into the HTML response and hydrated into the browser copy of the script — no second round-trip for SSR data. - Non-awaited `$server/*` calls return async values exposing `.pending`, `.error`, and `.success`. On the server they participate in streaming boundaries; in the browser the same shape is preserved by RPC stubs. - Helper functions (`function onDelete(id)`) and synchronous top-level code stay in the browser copy of the script. The framework wraps DOM listeners automatically via the [`on={fn(args)}` directive](/docs/koze/client-interactivity#event-handler-directive). - Top-level statements whose bodies contain `await` are stripped from the browser bundle after hydration wiring is in place — they already ran server-side. Use `src/server/*` for private modules and backend logic. `$server/*` functions called from the browser become RPC stubs that POST to a framework-managed endpoint. For the full hydration model, dispatch rules, and `__kozeReadData` escape hatch, see [Client interactivity](/docs/koze/client-interactivity). ## Browser-only code Routes allow one `
    for (const item of items) {
  • {item.title}
  • }
``` If a third-party module touches `window` during import, wrap the import in a lazy `$lib/*` helper and call that helper from a browser event. ## Layouts `src/routes/layout.koze` is a **fragment** that wraps every page. Not a document — no ``, no ``, no ``. Those live in `app.koze`. ```html
Home Items
``` ### Nested layouts Drop a `layout.koze` at any directory depth. It wraps every route under that directory, composed with the root layout outside and the page inside. ```text src/routes/ ├─ layout.koze ← wraps every page ├─ index.koze ← / └─ admin/ ├─ layout.koze ← wraps /admin/* ├─ users/ │ └─ index.koze ← /admin/users (root + admin layouts) └─ settings/ ├─ layout.koze ← wraps /admin/settings/* └─ index.koze ← /admin/settings (root + admin + settings) ``` Composition at render time goes **innermost first**: the route's HTML is wrapped by its nearest layout, the result is wrapped by the next layout up, and so on until the root layout hands its output to the app shell. That means an author writing `` in a nested layout receives the already-wrapped child markup — the inner layout is closer to the content it's decorating than the outer one. Each layout is a self-contained fragment: its top ` ``` Template text and attributes that read reactive state are also live: ```html

{selectOptions.length} available cells

``` Loop locals stay available to client bindings: ```html for (const item of rows) { } ``` ### Components ```html

Live

``` Package components like `kuzan/*` are optional. Local `.koze` components and package components are first-class. ### Component Props Components receive props via `koze:component`. Destructure in the component's `
if (title) {

{title}

}
``` **Props patterns:** - Import and destructure with defaults: `const { title, size = 'md' } = props<{ title?: string; size?: string }>();` - Access directly in template: `{props.title}` or `data-variant={props.variant}` - Use `class:` for className (reserved word): `const { class: className = '' } = props<{ class?: string }>();` - Children go in `` ## Client reactivity Use `$:` inside the top ` ``` Rules: - The top ` if (status.error) { } else if (status.status === 'running') { } else if (status.status === 'complete') { } ``` `status` is an `AsyncValue` with: - `status` — the current workflow phase (`'queued' | 'running' | 'complete' | 'errored' | ...`) - `output` — whatever your workflow `run()` returned - `error` — non-null when the status fetch itself failed - `pending` / `success` — standard `AsyncValue` flags ### `{ poll }` auto-refresh When you pass `poll`, the framework: 1. Records the interval on request-scoped state while rendering. 2. Injects a tiny directive script into the page before sending it. 3. Uses that directive on the client to re-fetch the URL on each tick and swap `` with the freshly rendered HTML. 4. Stops polling when `until(status)` returns `true`. The default predicate treats `'complete'`, `'completed'`, `'errored'`, or `'terminated'` as terminal. ```ts interface WorkflowStatusOptions { poll?: string | number; // e.g. '2s', 500, '1m' until?: (value: T) => boolean; // override the default terminal predicate } ``` Because every tick is a full server render, any `{status.*}` reference in your template reflects the newest data with no client-side reactivity to wire up. ### Multiple polls on one page You can call `workflowStatus(..., { poll })` several times on the same page (e.g. one per active instance). The framework uses the **shortest** interval that was registered and only stops when **every** registered call reports terminal. ```html ``` ### Custom `until` ```ts const status = await workflowStatus('migration', params.id, { poll: '2s', until: (s) => s.status === 'ready-for-review', }); ``` ### Without polling Omit `poll` for a one-shot read. The route won't auto-refresh, but you still get the same resolved `AsyncValue`. ```ts const status = await workflowStatus('migration', params.id); ``` ## Workflow Steps Use `step.do()` for durable steps that survive restarts: ```ts async run(event: WorkflowEvent, step: WorkflowStep) { // Each step is durable - if the workflow restarts, // completed steps are skipped const users = await step.do('fetch-users', async () => { return await db.users.findMany(); }); // Steps can depend on previous results const processed = await step.do('process-users', async () => { return await processUsers(users); }); return { processed: processed.length }; } ``` ## Workflow Events Access the triggering event in your workflow: ```ts async run(event: WorkflowEvent<{ sourceId: string }>, step: WorkflowStep) { const { sourceId } = event.payload.params; const data = await step.do('fetch', async () => { return await fetchFromSource(sourceId); }); return { fetched: data.length }; } ``` ## Sleep and Delays Use `step.sleep()` for durable delays: ```ts async run(event: WorkflowEvent, step: WorkflowStep) { await step.do('send-welcome', async () => { await sendWelcomeEmail(event.payload.userId); }); // Wait 24 hours (survives restarts) await step.sleep('wait-for-followup', '24 hours'); await step.do('send-followup', async () => { await sendFollowupEmail(event.payload.userId); }); } ``` ## Error Handling Workflows automatically retry failed steps. Handle errors explicitly when needed: ```ts async run(event: WorkflowEvent, step: WorkflowStep) { try { await step.do('risky-operation', async () => { return await riskyOperation(); }); } catch (err) { await step.do('handle-failure', async () => { await notifyAdmin(err); await cleanupPartialWork(); }); throw err; // Re-throw to mark workflow as errored } } ``` ## Wrangler Configuration The framework auto-syncs workflow bindings to `wrangler.jsonc`: ```jsonc { "workflows": [ { "name": "migration-workflow", "binding": "MIGRATION_WORKFLOW", "class_name": "MigrationWorkflow", "script_name": "src/worker.ts" } ] } ``` ## Kunii D1 and Durable Object SQLite ORM ### kunii Package: Kunii Canonical: https://kuratchi.dev/docs/kunii Markdown: https://kuratchi.dev/docs/kunii/index.md > Workers-native ORM docs for Cloudflare D1 and Durable Object SQLite ## SQLite for the runtime you already have `kunii` is a lightweight ORM for Cloudflare D1 and Durable Object SQLite. It keeps the API Worker-safe, uses a compact schema DSL, and stays close to SQL instead of hiding the database behind a large abstraction layer. ## What it gives you - [Schema DSL](/docs/kunii/schema-dsl): Define tables, mixins, indexes, JSON columns, enums, and foreign keys with a compact object format. - [Chainable queries](/docs/kunii/querying): Query with `where`, `orderBy`, `limit`, `offset`, `count`, and `distinct` without leaving SQL-shaped concepts behind. - [Relations and JSON](/docs/kunii/relations): Load related rows, filter by relation existence, and query JSON columns with dotted paths. - [Write helpers](/docs/kunii/writes-and-transactions): Use `returning`, `upsert`, and `db.batch()` for common write workflows and atomic commits. - [D1 and DO support](/docs/kunii/getting-started): Use the same ORM across D1 bindings and Durable Object SQLite storage. - [Framework adapters](/docs/kunii/framework-adapters): Initialize D1 migrations and clients in Cloudflare-hosted SvelteKit, Next, Nuxt, Astro, and Koze apps. - [Migration helpers](/docs/kunii/migrations): Generate initial SQL, diff schemas, and run tracked runtime migrations when needed. ## Design constraints The ORM is built around a few hard constraints: - No Node-only runtime assumptions - No dependency-heavy query layer - SQL semantics stay visible - Same mental model across D1 and Durable Object SQLite ## Read next - [Getting Started](/docs/kunii/getting-started) - [Schema DSL](/docs/kunii/schema-dsl) - [Querying](/docs/kunii/querying) - [Relations](/docs/kunii/relations) - [JSON Columns](/docs/kunii/json-columns) - [Writes and Transactions](/docs/kunii/writes-and-transactions) - [Aggregates and Reporting](/docs/kunii/aggregates-and-reporting) - [Type Generation](/docs/kunii/type-generation) - [Framework Adapters](/docs/kunii/framework-adapters) - [Migrations](/docs/kunii/migrations) ### Aggregates and Reporting Package: Kunii Canonical: https://kuratchi.dev/docs/kunii/aggregates-and-reporting Markdown: https://kuratchi.dev/docs/kunii/aggregates-and-reporting.md > Use aggregate helpers, groupBy, having, and report-style selects ## Aggregate helpers Aggregate terminals follow the same shape as `count()`: single column in, scalar out. ```ts await db.orders.where({ status: 'paid' }).sum('amount'); // -> number | null await db.orders.avg('amount'); // -> number | null await db.users.min('createdAt'); // -> number | null await db.users.max('createdAt'); // -> number | null ``` JSON path columns work too: ```ts await db.orders.sum('payload.amount'); ``` ## `groupBy` + `having` + object-form `select` For reporting queries, `select` accepts an object form that projects plain columns alongside aggregates. Each entry key becomes the SQL alias. ```ts db.posts .where({ published: 1 }) .select({ userId: true, total: { sum: 'views' }, n: { count: '*' }, }) .groupBy('userId') // single col or array .having({ total: { gte: 1000 }, n: { gte: 5 } }) .orderBy({ total: 'desc' }) .limit(10) .many(); ``` Aggregate types: `count`, `countDistinct`, `sum`, `avg`, `min`, `max`. `having` reuses the full `where` vocabulary (`gt`, `gte`, `lt`, `lte`, `eq`, `ne`, `between`, `notBetween`, and so on) operating on the aliases from your select object. ## Read next - [Querying](/docs/kunii/querying) - [Relations](/docs/kunii/relations) - [Writes and Transactions](/docs/kunii/writes-and-transactions) ### Framework Adapters Package: Kunii Canonical: https://kuratchi.dev/docs/kunii/framework-adapters Markdown: https://kuratchi.dev/docs/kunii/framework-adapters.md > Use kunii from Cloudflare-hosted SvelteKit, Next, Nuxt, Astro, and Koze apps `kunii` is Cloudflare-only. The adapters below do not add support for Node SQLite, Postgres, or external SQL providers. They make the same D1 migration and query-client flow work wherever Cloudflare exposes `env`. Each adapter exports an adapter-specific `initKuniiORM`. All adapters run `migrateOnce(env)` before your handler and expose schema-aware clients. Direct client creation (`kunii`, `orm.db`, and `orm.clients`) never runs DDL by itself. ## Plain Workers Plain Workers do not need an adapter. Use the top-level ORM primitives inside the native `fetch(request, env, ctx)` handler: ```ts import { initKuniiORM } from '@kuratchi/kunii'; import { appSchema } from './schemas/app'; const orm = initKuniiORM({ DB: appSchema }); export default { async fetch(request, env, ctx) { await orm.migrateOnce(env); const db = orm.db(env, 'DB'); const todos = await db.todos.orderBy({ created_at: 'desc' }).many(); return Response.json(todos.data); }, }; ``` ## Koze ```ts import { defineMiddleware } from '@kuratchi/koze'; import { initKuniiORM } from '@kuratchi/kunii/koze'; import { appSchema } from './server/schemas/app'; const orm = initKuniiORM({ DB: appSchema }); export default defineMiddleware({ orm: orm.middleware(), }); ``` The existing `autoMigrate({ DB: appSchema })` helper remains supported for compatibility. ## SvelteKit Use this in `src/hooks.server.ts` with `@sveltejs/adapter-cloudflare`: ```ts import { initKuniiORM } from '@kuratchi/kunii/sveltekit'; import { appSchema } from '$lib/server/schemas/app'; const orm = initKuniiORM({ DB: appSchema }); export const handle = orm.handle(); ``` The adapter stores clients on `event.locals.koze.DB`. ## Astro Use this with `@astrojs/cloudflare` middleware: ```ts import { initKuniiORM } from '@kuratchi/kunii/astro'; import { appSchema } from './schemas/app'; const orm = initKuniiORM({ DB: appSchema }); export const onRequest = orm.middleware(); ``` The adapter reads Cloudflare bindings from `context.locals.runtime.env` and stores clients on `context.locals.koze.DB`. ## Nuxt Use this in Nitro server middleware on Cloudflare: ```ts import { initKuniiORM } from '@kuratchi/kunii/nuxt'; import { appSchema } from '../schemas/app'; const orm = initKuniiORM({ DB: appSchema }); export default orm.eventHandler(); ``` The adapter reads `event.context.cloudflare.env` and stores clients on `event.context.koze.DB`. Plain Vue apps do not have a server ORM lifecycle. On Cloudflare, a Vue SPA uses a Worker backend, so use the top-level Worker primitives there. ## Next.js Use this with Next.js deployed through OpenNext on Cloudflare: ```ts import { initKuniiORM } from '@kuratchi/kunii/next'; import { appSchema } from '@/server/schemas/app'; const orm = initKuniiORM({ DB: appSchema }); export const GET = orm.route(async ({ db }) => { const todos = await db.DB.todos.many(); return Response.json(todos.data); }); ``` The Next adapter migrates inside the request wrapper. It does not rely on startup instrumentation because Cloudflare Workers do not allow D1 I/O outside a request context. ## Durable Objects Durable Objects still use synchronous setup in the constructor: ```ts import { initKuniiDO } from '@kuratchi/kunii'; export class OrgDO extends DurableObject { db; constructor(ctx, env) { super(ctx, env); this.db = initKuniiDO(ctx.storage, orgSchema); } } ``` ### Getting Started Package: Kunii Canonical: https://kuratchi.dev/docs/kunii/getting-started Markdown: https://kuratchi.dev/docs/kunii/getting-started.md > Install kunii and use it with D1 or Durable Object SQLite ## Install ```bash npm install @kuratchi/kunii ``` ## D1 usage Bind the ORM directly to a Cloudflare D1 database: ```ts import { kunii } from '@kuratchi/kunii'; import { env } from 'cloudflare:workers'; import { appSchema } from './server/schemas/app'; const db = kunii(env.DB, appSchema); ``` Pass the schema and you get JSON path queries, soft-delete filtering, and insert validation. Without a schema the ORM still works for raw queries — but JSON columns stay opaque. For module-level singletons, prefer lazy binding so the database is resolved at query time: ```ts const db = kunii(() => env.DB, appSchema); ``` ## Basic D1 operations ```ts await db.todos.insert({ title: 'Hello' }); const todos = await db.todos .orderBy({ created_at: 'desc' }) .many(); ``` ## Auto-migration on cold start For Cloudflare-hosted framework apps, use the adapter that matches where your framework exposes Cloudflare `env`. The adapter runs D1 migrations on the first request per worker isolate and then gives you schema-aware clients. Plain Workers/Wrangler: ```ts import { initKuniiORM } from '@kuratchi/kunii'; import { appSchema } from './server/schemas/app'; const orm = initKuniiORM({ DB: appSchema }); export default { async fetch(request, env, ctx) { await orm.migrateOnce(env); const db = orm.db(env, 'DB'); const todos = await db.todos.many(); return Response.json(todos.data); }, }; ``` Koze: ```ts // src/middleware.ts import { defineMiddleware } from '@kuratchi/koze'; import { initKuniiORM } from '@kuratchi/kunii/koze'; import { appSchema } from './server/schemas/app'; const orm = initKuniiORM({ DB: appSchema }); export default defineMiddleware({ orm: orm.middleware(), }); ``` The older `autoMigrate({ DB: appSchema })` middleware helper is still supported. See [Framework Adapters](/docs/kunii/framework-adapters) for SvelteKit, Next, Nuxt, Astro, Workers, and Koze examples. ## Durable Object SQLite usage Inside a Durable Object, initialize synchronously in the constructor: ```ts import { DurableObject } from 'cloudflare:workers'; import { initKuniiDO } from '@kuratchi/kunii'; import { appSchema } from './schemas/app'; export class AppDO extends DurableObject { db; constructor(ctx, env) { super(ctx, env); this.db = initKuniiDO(ctx.storage, appSchema); } async list() { return this.db.todos.many(); } } ``` `initKuniiDO(ctx.storage, schema)` runs `CREATE TABLE IF NOT EXISTS` plus additive `ALTER TABLE ADD COLUMN` for any new columns, then returns the query client. It's idempotent across DO restarts. Pass `ctx.storage` (preferred) so atomic `db.batch()` can use `transactionSync`. Pass `ctx.storage.sql` if you only need the SQL surface. ## Helpful errors If a query hits a missing table, the ORM rewrites the error to point you at `autoMigrate`: ```text no such table: users. Did you forget to wire `autoMigrate({ : schema })` into your middleware? See [Migrations](/docs/kunii/migrations) for details. ``` For an opt-in dev-mode sanity check, drop `assertSchemaInSync` into your middleware and it'll throw if the live database has drifted: ```ts import { assertSchemaInSync } from '@kuratchi/kunii'; export default defineMiddleware({ schemaCheck: import.meta.env.DEV ? assertSchemaInSync({ DB: appSchema }) : null, // ... }); ``` ## Core exports - `initKuniiORM({ BINDING: schema })` from framework adapter paths - D1 migration/client wiring for Cloudflare-hosted apps - `initKuniiDO(storage, schema)` from `kunii` - synchronous DO setup plus query client - `kunii(binding, schema?)` — query client - `autoMigrate(storage, schema)` — synchronous DO DDL primitive - `autoMigrate({ BINDING: schema })` — D1 middleware step - `assertSchemaInSync({ BINDING: schema })` — dev-mode drift check - `createRuntimeOrm`, `createSchemaClient` — lower-level building blocks - Schema helpers from `kunii` - Migration helpers from `kunii/migrations` (`runMigrations`, `buildInitialSql`, `buildDiffSql`, `diffSchemas`) for BYO migration workflows ## Read next - [Schema DSL](/docs/kunii/schema-dsl) - [Querying](/docs/kunii/querying) - [Relations](/docs/kunii/relations) - [JSON Columns](/docs/kunii/json-columns) - [Writes and Transactions](/docs/kunii/writes-and-transactions) - [Framework Adapters](/docs/kunii/framework-adapters) - [Koze integration](/docs/kunii/koze-integration) - [Migrations](/docs/kunii/migrations) ### JSON Columns Package: Kunii Canonical: https://kuratchi.dev/docs/kunii/json-columns Markdown: https://kuratchi.dev/docs/kunii/json-columns.md > Query and update JSON columns with dotted paths and SQLite JSON functions ## Schema Declare a column as `type: 'json'` in the schema (or `json` in the DSL) and the ORM treats it as a first-class JSON document. Whole-object writes are stringified, reads are parsed back, and dotted keys against JSON columns compile to SQLite JSON SQL. ```ts const schema = defineSchema({ name: 'app', version: 1, tables: { events: { id: 'integer primary key', kind: 'text not null', payload: 'json', }, }, }); ``` ## Reads Dotted keys become `->>` expressions: ```ts // payload is { user: { tier: 'pro' } } await db.events.where({ 'payload.user.tier': 'pro' }).many(); // SELECT * FROM events WHERE payload ->> '$.user.tier' = ? await db.events.where({ 'payload.score': { gte: 50 } }).many(); // SELECT * FROM events WHERE payload ->> '$.score' >= ? await db.events.where({ 'payload.tier': { in: ['pro', 'enterprise'] } }).many(); // payload ->> '$.tier' IN (SELECT value FROM json_each(?)) await db.events.orderBy({ 'payload.priority': 'desc' }).many(); // ORDER BY payload ->> '$.priority' DESC await db.events.select(['id', 'payload.user.email']).many(); // SELECT id, payload ->> '$.user.email' AS "payload.user.email" FROM events ``` Array indexing works inline: ```ts db.events.where({ 'payload.tags[0]': 'urgent' }); // payload ->> '$.tags[0]' = ? ``` ## Writes Dotted keys become `json_set` calls: ```ts await db.events.where({ id: 1 }).update({ 'payload.tier': 'pro' }); // UPDATE events SET payload = json_set(payload, '$.tier', ?) WHERE id = ? ``` Multiple paths on the same column merge into a single `json_set` call: ```ts await db.events.where({ id: 1 }).update({ 'payload.tier': 'pro', 'payload.seats': 5, }); // UPDATE events SET payload = json_set(payload, '$.tier', ?, '$.seats', ?) WHERE id = ? ``` Object and array values are bound as JSON via `json(?)` so SQLite stores them as nested JSON, not literal strings: ```ts await db.events.where({ id: 1 }).update({ 'payload.flags': { beta: true } }); // UPDATE events SET payload = json_set(payload, '$.flags', json(?)) WHERE id = ? // bound: '{"beta":true}' ``` Whole-object writes still work and replace the entire blob: ```ts await db.events.where({ id: 1 }).update({ payload: { whole: 'replace' } }); // UPDATE events SET payload = ? WHERE id = ? ``` ## Why this matters Without surgical JSON SQL, filtering on `payload.tier` would require pulling every row's `payload`, parsing it in your Worker, then filtering in JavaScript. With first-class JSON support, SQLite does the work and only matching rows cross the wire. ## Read next - [Querying](/docs/kunii/querying) - [Type Generation](/docs/kunii/type-generation) ### Koze Integration Package: Kunii Canonical: https://kuratchi.dev/docs/kunii/koze-integration Markdown: https://kuratchi.dev/docs/kunii/koze-integration.md > Wire kunii into your Koze app via middleware The framework no longer reads a project-level config file. The ORM plugs in through ordinary middleware steps and explicit constructor calls inside Durable Objects — so `kunii` is a library you opt into, not a framework feature. ## D1 (cold-start initialization) Wire the Koze adapter into `src/middleware.ts`. The first request per worker isolate runs migrations against the D1 binding; subsequent requests short-circuit via an in-isolate flag. ```ts // src/middleware.ts import { defineMiddleware } from '@kuratchi/koze'; import { initKuniiORM } from '@kuratchi/kunii/koze'; import { appSchema } from './server/schemas/app'; const orm = initKuniiORM({ DB: appSchema }); export default defineMiddleware({ orm: orm.middleware(), }); ``` The map key (`DB`) must match the Wrangler binding name. You can register multiple D1 databases in one init: ```ts const orm = initKuniiORM({ DB: appSchema, ANALYTICS: analyticsSchema }); ``` The older `autoMigrate({ DB: appSchema })` helper remains supported: ```ts import { autoMigrate } from '@kuratchi/kunii'; export default defineMiddleware({ migrate: autoMigrate({ DB: appSchema }), }); ``` ## D1 (query client) Inside any server module, `$server/*` route helper, action, or RPC handler: ```ts // src/server/items.ts import { kunii } from '@kuratchi/kunii'; import { env } from 'cloudflare:workers'; import { appSchema } from './schemas/app'; const db = kunii(() => env.DB, appSchema); export async function listItems() { return db.items.orderBy({ created_at: 'desc' }).many(); } ``` Lazy binding (`() => env.DB`) lets the module live at top-level without resolving `env` at import time. ## Durable Objects Run `initKuniiDO(ctx.storage, schema)` synchronously in the constructor: ```ts // src/server/notes.do.ts import { DurableObject } from 'cloudflare:workers'; import { initKuniiDO } from '@kuratchi/kunii'; import { notesSchema } from './schemas/notes'; export default class NotesDO extends DurableObject { db; constructor(ctx, env) { super(ctx, env); this.db = initKuniiDO(ctx.storage, notesSchema); } async list() { return this.db.notes.orderBy({ created_at: 'desc' }).many(); } } ``` `initKuniiDO` runs `CREATE TABLE IF NOT EXISTS` plus additive `ALTER TABLE ADD COLUMN` for any new columns, then returns the query client. Idempotent across DO restarts. Pass `ctx.storage` (preferred) so atomic `db.batch()` can use `transactionSync`. Pass `ctx.storage.sql` if you only need the SQL surface. ## Opt-out: external migrations If you'd rather manage D1 migrations out-of-band (CI running `wrangler d1 migrations apply` against hand-written SQL), simply omit the ORM middleware step. `kunii(env.DB, schema)` is a query client — it never issues DDL. For BYO migration workflows, the underlying DDL helpers are exported under `kunii/migrations`: ```ts import { buildInitialSql, buildDiffSql, diffSchemas } from '@kuratchi/kunii/migrations'; // First-time creation const sql = buildInitialSql(appSchema); // Diff against an existing DB shape const { sql: upgradeSql, warnings } = buildDiffSql(currentSchema, appSchema); ``` See [Migrations](/docs/kunii/migrations) for the full reference. ## Dev-mode schema drift check Drop `assertSchemaInSync` into your middleware behind an `import.meta.env.DEV` guard to throw if the live database has drifted from the declared schema — catches missing migration wiring before it surfaces as confusing `no such table` errors at runtime: ```ts import { assertSchemaInSync } from '@kuratchi/kunii'; import { initKuniiORM } from '@kuratchi/kunii/koze'; const orm = initKuniiORM({ DB: appSchema }); export default defineMiddleware({ schemaCheck: import.meta.env.DEV ? assertSchemaInSync({ DB: appSchema }) : null, orm: orm.middleware(), // ... }); ``` ## Migrating from `kuratchi.config.ts` Earlier versions had an `orm: kuratchiOrmConfig({ databases: {...} })` block in `kuratchi.config.ts`. Each entry maps cleanly: | Old | New | | --- | --- | | `DB: { schema: appSchema }` | `orm: initKuniiORM({ DB: appSchema }).middleware()` in `src/middleware.ts` | | `DB: { schema: appSchema, skipMigrations: true }` | Omit the binding from `initKuniiORM({...})` | | `NOTES_DO: { schema: notesSchema, type: 'do' }` | `initKuniiDO(ctx.storage, notesSchema)` in the DO constructor | The `kuratchiOrmConfig` adapter and the `kuratchi.config.ts` file are gone — delete both. ### Migrations Package: Kunii Canonical: https://kuratchi.dev/docs/kunii/migrations Markdown: https://kuratchi.dev/docs/kunii/migrations.md > Generate SQL, diff schemas, and run tracked runtime migrations ## What the package provides `kunii/migrations` exposes runtime helpers for schema migration workflows. The implementation tracks migration history in `_kuratchi_migrations`. ## Initial SQL generation Use the generator helpers when you need SQL from a normalized schema: ```ts import { buildInitialSql } from '@kuratchi/kunii/migrations'; ``` The generated SQL includes: - `CREATE TABLE IF NOT EXISTS` - `CREATE INDEX IF NOT EXISTS` ## Diffing schemas The migration generator can diff two schemas and emit additive SQL plus warnings for unsafe changes. That is especially important for operations like: - adding `NOT NULL` columns without defaults - dropping columns - changing checks - rebuilding constraint-heavy definitions ## Runtime migrations Use `runMigrations()` to apply schema changes at runtime: ```ts import { runMigrations } from '@kuratchi/kunii/migrations'; await runMigrations({ execute: (sql, params) => env.DB.prepare(sql).bind(...(params || [])).all(), schema: appSchema, }); ``` Behavior: - first run builds the full schema - later runs diff the current database and target schema - identical schema hashes short-circuit - migration history is recorded in `_kuratchi_migrations` ## Pending checks Use `hasPendingMigrations()` when you only need to know whether the target schema differs from the last applied migration. ## Cloudflare framework adapters Most Cloudflare-hosted apps should use a framework adapter instead of calling `runMigrations()` directly. The adapters wrap the framework's request lifecycle, run `migrateOnce(env)` for D1 bindings, and then expose ORM clients. ```ts import { initKuniiORM } from '@kuratchi/kunii'; import { appSchema } from './schemas/app'; const orm = initKuniiORM({ DB: appSchema }); export default { async fetch(request, env, ctx) { await orm.migrateOnce(env); const db = orm.db(env, 'DB'); return Response.json((await db.todos.many()).data); }, }; ``` See [Framework Adapters](/docs/kunii/framework-adapters) for SvelteKit, Next, Nuxt, Astro, Workers, and Koze examples. ## Durable Object additive migrations `autoMigrate(ctx.storage, schema)` handles Durable Object schema setup differently from tracked D1 runtime migrations: 1. It snapshots existing DO tables. 2. It runs initial `CREATE TABLE IF NOT EXISTS` SQL. 3. It inspects existing columns with `PRAGMA table_info`. 4. It adds missing columns with `ALTER TABLE ... ADD COLUMN`. That makes DO schema evolution additive by default. There is no migration history table on the DO side — every restart re-applies the idempotent `IF NOT EXISTS` statements, which is cheap and self-correcting. ## Legacy auto-migration middleware For D1 specifically, `autoMigrate({ DB: schema })` returns a `MiddlewareStep` that calls `runMigrations` on the first request per worker isolate. Drop it into `src/middleware.ts`: ```ts import { defineMiddleware } from '@kuratchi/koze'; import { autoMigrate } from '@kuratchi/kunii'; import { appSchema } from './server/schemas/app'; export default defineMiddleware({ migrate: autoMigrate({ DB: appSchema }), }); ``` This is the most common pattern. For total control (running migrations from a queue handler, a scheduled trigger, or a CLI script) call `runMigrations({...})` directly. ### Querying Package: Kunii Canonical: https://kuratchi.dev/docs/kunii/querying Markdown: https://kuratchi.dev/docs/kunii/querying.md > Use the chainable table API for inserts, filters, selection, and safe writes ## Query result shape Most ORM operations return: ```ts type QueryResult = { success: boolean; data?: T; results?: T; error?: string; meta?: { rowsRead?: number; rowsWritten?: number; duration?: number; }; } ``` ## The chain is the API Every operation in `kunii` is a chain. `db.todos` is an empty query that means "all rows". Each method either continues the chain (returns the same `Table` shape) or terminates it (returns a `Promise`). There are no shortcuts and no second API. Cardinality always comes from the chain, not the verb: ```ts db.todos.where({ id: 1 }).first(); // one row db.todos.where({ id: 1 }).update({ done: 1 }); // updates one row (id is unique) db.todos.where({ done: 0 }).update({ done: 1 }); // updates many rows (broad where) db.todos.where({ id: 1 }).delete(); // deletes one row db.todos.where({ done: 1 }).delete(); // deletes many rows ``` `update` and `delete` always require a preceding `where` to prevent accidental table-wide writes. To affect a single row when the where is broad, narrow it (typically by primary key) or chain `.limit(1)`. ## Inserts ```ts await db.todos.insert({ title: 'Hello', done: 0 }); await db.todos.insert([ { title: 'First' }, { title: 'Second' }, ]); ``` Fields not present in the schema are skipped when using a schema-aware client. ## Reads ```ts const all = await db.todos.many(); const first = await db.todos.first(); const exact = await db.todos.where({ id: 1 }).one(); // errors if not exactly 1 const exists = await db.todos.where({ id: 1 }).exists(); const total = await db.todos.count(); const proCount = await db.todos.where({ tier: 'pro' }).count(); const titles = await db.todos.where({ done: 0 }).distinct('title'); ``` ## Filtering The value side of `where({ ... })` carries intent in two ways: by shape for the common cases (a primitive means equality, `null` means `IS NULL`, an array means `IN`), and by operator object for everything else. ### Value-shape shortcuts ```ts db.todos.where({ done: 0 }) // done = 0 db.todos.where({ deleted_at: null }) // deleted_at IS NULL db.todos.where({ tier: ['pro', 'enterprise'] }) // tier IN (...) ``` Bare strings are always equality. There is no auto-`LIKE` magic. Use `{ contains }`, `{ like }`, or `{ notLike }` to opt into pattern matching. ### Operator object ```ts // Comparisons db.todos.where({ id: { gt: 5 } }) db.todos.where({ age: { gte: 18, lt: 65 } }) db.todos.where({ id: { ne: 3 } }) // Substring / pattern db.todos.where({ title: { contains: 'urgent' } }) // LIKE %urgent% with %/_ escaped db.todos.where({ title: { like: '%hello%' } }) // raw LIKE pattern (escape hatch) db.todos.where({ title: { notLike: '%spam%' } }) // Sets db.todos.where({ id: { in: [1, 2, 3] } }) db.todos.where({ id: { notIn: [4, 5] } }) // Ranges db.todos.where({ id: { between: [1, 100] } }) db.todos.where({ priority: { notBetween: [1, 3] } }) // Null db.todos.where({ deleted_at: { isNull: true } }) // IS NULL db.todos.where({ verified_at: { isNull: false } }) // IS NOT NULL ``` Multiple operators on the same column AND together: ```ts db.todos.where({ priority: { gte: 5, lt: 10 } }) // -> priority >= ? AND priority < ? ``` ### contains vs like Use `contains` when you have a literal needle. The ORM wraps it in `%...%` and escapes any `%`/`_` in the input so a search for `'discount_50%'` matches the literal substring instead of being interpreted as a wildcard. Use `like` when you want raw LIKE pattern syntax (for example `_` single-character wildcards or pre-shaped patterns). ### IN scales without limits `{ in: [...] }` (and the array value-shape shortcut) compile to a single bound parameter using SQLite's `json_each`: ```sql SELECT * FROM todos WHERE id IN (SELECT value FROM json_each(?)) ``` This avoids D1's per-statement placeholder limit (around 100), so you can pass arrays of any size. ### Empty arrays are predictable ```ts db.todos.where({ id: [] }) // -> WHERE 0 (matches nothing) db.todos.where({ id: { notIn: [] } }) // -> WHERE 1 (matches everything) ``` Pass dynamic arrays without a length guard. The result is always sensible. ### OR conditions ```ts db.todos.whereAny([ { done: 0, priority: { gte: 5 } }, { title: { contains: 'urgent' } }, ]) // -> WHERE (done = ? AND priority >= ?) OR (title LIKE ? ESCAPE '\') ``` `whereAny` is the canonical OR. It accepts an array of full filters. Each entry is a `where`-style object whose keys AND together, and the entries OR with each other. ## Raw SQL conditions You can add raw predicates without giving up parameter binding: ```ts db.todos.sql({ query: 'title LIKE ? OR done = ?', params: ['%hello%', 1], }) ``` The query builder rejects obvious interpolation and multi-statement patterns. Use `?` placeholders and a params array. ## Ordering and selection ```ts db.todos.orderBy({ created_at: 'desc' }) db.todos.orderBy([{ done: 'asc' }, { created_at: 'desc' }]) db.todos.orderBy('created_at DESC') db.todos.select(['id', 'title']) db.todos.limit(10).offset(20) ``` ## Updates and deletes Both terminals require a preceding `where` for safety. Cardinality follows the chain: ```ts // Single row (id is unique). await db.todos.where({ id: 1 }).update({ done: 1 }); await db.todos.where({ id: 1 }).delete(); // Many rows. await db.todos.where({ done: 0 }).update({ banner_seen: 1 }); await db.todos.where({ done: 1 }).delete(); ``` To affect exactly one row when the where could match many, chain `.limit(1)`: ```ts await db.todos .where({ done: 0 }) .orderBy({ created_at: 'asc' }) .limit(1) .update({ in_progress: 1 }); ``` ## Soft delete If a table has a `deleted_at` column, the ORM filters soft-deleted rows by default. Use `withDeleted()` on the query builder when you need everything: ```ts const allTodos = await db.todos.withDeleted().many(); ``` ## Read next - [Relations](/docs/kunii/relations) - [JSON Columns](/docs/kunii/json-columns) - [Writes and Transactions](/docs/kunii/writes-and-transactions) - [Aggregates and Reporting](/docs/kunii/aggregates-and-reporting) - [Type Generation](/docs/kunii/type-generation) ### Relations Package: Kunii Canonical: https://kuratchi.dev/docs/kunii/relations Markdown: https://kuratchi.dev/docs/kunii/relations.md > Use foreign keys for eager loading and relation-aware filtering ## Define the relation in the schema Mark foreign keys with the `-> table.column` syntax: ```ts posts: { id: 'integer primary key', userId: 'integer -> users.id', title: 'text not null', } ``` The ORM uses these declarations for two things: eager loading via `include()`, and relation predicates inside `where()`. ## Eager loading with `include()` ```ts // Default: load all child rows for each parent. const result = await db.users.include({ posts: true }).many(); ``` Filter, order, limit, or narrow the children. The relation config takes the same options as a normal chain: ```ts // Per-user, only published posts, newest 10 each, only id + title. await db.users.include({ posts: { where: { published: 1 }, orderBy: { createdAt: 'desc' }, limit: 10, // top 10 per parent (window function) select: ['id', 'title'], // FK is auto-included for stitching }, }).many(); ``` Explicit key mapping for non-conventional foreign key names: ```ts await db.users.include({ posts: { foreignKey: 'authorId', localKey: 'id', as: 'articles' }, }).many(); ``` ## Relation predicates inside `where()` When `posts.userId -> users.id` is declared, you can filter `users` by what `posts` exist: ```ts // Users with at least one published post. await db.users.where({ posts: { any: { published: 1 } } }).many(); // -> WHERE EXISTS (SELECT 1 FROM posts WHERE posts.userId = users.id AND published = ?) // Users with no posts at all. await db.users.where({ posts: { none: {} } }).many(); // -> WHERE NOT EXISTS (SELECT 1 FROM posts WHERE posts.userId = users.id) // Users with at least 5 posts. await db.users.where({ posts: { gte: 5 } }).many(); // -> WHERE (SELECT count(*) FROM posts WHERE posts.userId = users.id) >= ? // Users with exactly 0 posts (alternate spelling of `none: {}`). await db.users.where({ posts: 0 }).many(); // Users with between 5 and 10 posts. await db.users.where({ posts: { between: [5, 10] } }).many(); ``` The relation value accepts: | shape | meaning | |---|---| | { any: <where> } | EXISTS, at least one matching child | | { none: <where> } | NOT EXISTS, no matching children | | number | count equals N | | { eq \| ne \| gt \| gte \| lt \| lte: N } | count comparison | | { between \| notBetween: [low, high] } | count range | Nested relation predicates work too: ```ts // Users with any post that has any comment containing "great". await db.users.where({ posts: { any: { comments: { any: { body: { contains: 'great' } } } } } }).many(); ``` For workloads beyond what these compose into, fall back to `db.raw(sql, params)`. ## Read next - [Querying](/docs/kunii/querying) - [JSON Columns](/docs/kunii/json-columns) - [Writes and Transactions](/docs/kunii/writes-and-transactions) ### Schema DSL Package: Kunii Canonical: https://kuratchi.dev/docs/kunii/schema-dsl Markdown: https://kuratchi.dev/docs/kunii/schema-dsl.md > Define tables, columns, mixins, indexes, and references with SchemaDsl ## Basic shape Define schemas with `SchemaDsl`: ```ts import type { SchemaDsl } from '@kuratchi/kunii'; export const appSchema: SchemaDsl = { name: 'app', version: 1, tables: { todos: { id: 'integer primary key', title: 'text not null', done: 'integer not null default 0', }, }, }; ``` ## Supported column types Column definition strings use: ```text [constraints...] ``` Supported types: - `text` - `integer` - `real` - `blob` - `json` - `boolean` - `timestamp_ms` `boolean` and `timestamp_ms` normalize to integer columns with mode metadata. ## Constraints and defaults Supported modifiers include: - `primary key` - `not null` - `unique` - `default now` - `default null` - `default 0` - `default (json_object())` - `enum(active,inactive,lead)` - `-> users.id` - `-> users.id cascade` Example: ```ts roles: { id: 'text primary key not null', name: 'text not null unique', permissions: 'json', status: 'enum(active,inactive,lead)', ownerId: 'text not null -> users.id cascade', } ``` ## Mixins Use mixins to share column sets across tables: ```ts export const appSchema: SchemaDsl = { name: 'app', version: 1, mixins: { timestamps: { updated_at: 'text default now', created_at: 'text default now', deleted_at: 'text', }, }, tables: { todos: { id: 'integer primary key', title: 'text not null', '...timestamps': true, }, }, }; ``` ## Indexes Indexes are declared per table: ```ts indexes: { todos: { idx_todos_done: { columns: ['done'] }, idx_todos_title: { columns: ['title'], unique: true }, }, } ``` ## JSON columns Columns declared as `json` are stored as `TEXT`, then auto-serialized on writes and parsed on reads when using a schema-aware client. ## Normalization The ORM exposes: - `normalizeSchema()` - `ensureNormalizedSchema()` - `isDatabaseSchema()` Use them when you need the internal `DatabaseSchema` form for tooling or migration generation. ### Type Generation Package: Kunii Canonical: https://kuratchi.dev/docs/kunii/type-generation Markdown: https://kuratchi.dev/docs/kunii/type-generation.md > Generate TypeScript types from your ORM schema for row-aware query inference ## `kunii/codegen` For full row-type inference on every `db..where(...).first()`, generate a `.d.ts` from your schema: ```ts import { generateTypes } from '@kuratchi/kunii/codegen'; import { appSchema } from './schema'; import { writeFile } from 'node:fs/promises'; const dts = generateTypes(appSchema); await writeFile('src/db-types.d.ts', dts); ``` The generated file exports one `XxxRow` interface per table plus an `XxxOrm` top-level interface. Use it to type your `db`: ```ts import { kunii } from '@kuratchi/kunii'; import type { AppOrm } from './db-types'; import { appSchema } from './schema'; const db = kunii(env.DB, appSchema) as unknown as AppOrm; const u = await db.users.where({ id: 1 }).first(); // u.data.email -> typed as string // u.data.name -> typed as string | null ``` ## Type mapping | Schema column | TS type | |---|---| | `text not null` | `string` | | `text` | `string \| null` | | `integer` | `number \| null` | | `boolean` mode | `boolean \| null` | | `timestamp_ms` mode | `number \| null` | | `real` | `number \| null` | | `blob` | `ArrayBuffer \| null` | | `enum(a,b,c)` | `"a" \| "b" \| "c"` | | `json` (default) | `any \| null` | | primary key | always non-null | ## JSON column shapes By default JSON columns are `any | null`. Override per-column with the `jsonShapes` option: ```ts const dts = generateTypes(appSchema, { jsonShapes: { users: { preferences: "import('./types').UserPrefs" }, }, }); ``` The override value is the TS expression emitted verbatim, typically an imported type alias. ## Options - `ormName` - name of the top-level interface. Defaults to `Orm` PascalCased. - `importFrom` - module specifier for the `Table` import. Defaults to `'kunii'`. - `jsonShapes` - per-table per-column JSON shape overrides. ## Limitations - Relations do not currently flow through the type system. `include({ posts: ... })` works at runtime, but the resulting `posts` field on each parent row is still `any`. ## Read next - [Querying](/docs/kunii/querying) - [JSON Columns](/docs/kunii/json-columns) - [Schema DSL](/docs/kunii/schema-dsl) ### Writes and Transactions Package: Kunii Canonical: https://kuratchi.dev/docs/kunii/writes-and-transactions Markdown: https://kuratchi.dev/docs/kunii/writes-and-transactions.md > Use returning, upsert, and batch writes without leaving the chain API ## `returning()` Chain `returning()` before any write terminal to emit a SQL `RETURNING` clause and surface the affected rows in `result.data`. This halves the number of D1 round-trips for the common create-then-read pattern. ```ts // Insert and get the row back (including auto-assigned id). const res = await db.users.returning().insert({ email: 'a@b.com', name: 'Ada' }); // res.data = [{ id: 42, email: 'a@b.com', name: 'Ada', ... }] // Narrow the projection. const res = await db.users.returning(['id']).insert({ email: 'a@b.com' }); // res.data = [{ id: 42 }] // Update and read back the new state. const res = await db.users .where({ id: 1 }) .returning() .update({ tier: 'pro' }); // Delete and capture what was removed. const res = await db.users .where({ id: 1 }) .returning() .delete(); ``` `returning()` works on `insert`, `update`, and `delete`. JSON columns are parsed back to objects on the returned rows just like a normal read. ## `upsert()` `upsert()` compiles to SQLite's `INSERT ... ON CONFLICT(...) DO UPDATE / DO NOTHING`. Three common conflict-resolution modes: ```ts // Refresh every non-target column from the new values. await db.users.upsert({ values: { id: 1, email: 'a@b.com', tier: 'pro' }, onConflict: 'id', update: 'all', }); // Leave existing rows alone if conflict. await db.users.upsert({ values: { id: 1, email: 'a@b.com' }, onConflict: 'id', update: 'ignore', }); // Explicit set values (supports JSON-path keys). await db.users.upsert({ values: { id: 1, email: 'a@b.com', meta: { plan: 'free' } }, onConflict: 'id', update: { tier: 'pro', 'meta.plan': 'pro' }, }); // Read back the result in the same call. const { data } = await db.users .returning() .upsert({ values: {...}, onConflict: 'id', update: 'all' }); ``` `onConflict` accepts a single column name or an array for composite unique keys. `values` accepts a single row or an array. ## `db.batch()` Wrap a set of writes in `db.batch(async tx => ...)` to commit them atomically. On D1 this is one HTTP round-trip via the native `db.batch([...])` API. On Durable Objects it's wrapped in `transactionSync` when `autoMigrate(ctx.storage, schema)` was passed `ctx.storage` rather than `ctx.storage.sql`. ```ts const result = await db.batch(async (tx) => { await tx.users.where({ id: 1 }).update({ tier: 'pro' }); await tx.audit.insert({ event: 'tier_change', userId: 1 }); }); // result.success: boolean // result.results: per-statement QueryResult[] ``` Inside the callback, write terminals (`insert`, `update`, `delete`) stage statements instead of running them. The actual commit happens after the callback resolves. Read terminals (`many`, `first`, `one`, `count`, `exists`, `distinct`) are not supported inside batch. Run them before or after. The result data is not available until commit, so reads inside a batch cannot make decisions. The `tx` argument has the same fluent shape as `db`, so chains (`where`, `orderBy`, `select`, `returning`, and so on) compose normally. ## Read next - [Querying](/docs/kunii/querying) - [Aggregates and Reporting](/docs/kunii/aggregates-and-reporting) - [Koze Integration](/docs/kunii/koze-integration) ## Kyzen Auth middleware and sessions ### kyzen Package: Kyzen Canonical: https://kuratchi.dev/docs/kyzen Markdown: https://kuratchi.dev/docs/kyzen/index.md > The Koze quickstart auth library — credentials, sessions, RBAC, OAuth, rate-limiting, Turnstile ## A starter, not a standard `kyzen` is the Koze quickstart auth library. It gets you to "users can sign in" fast — credentials, sessions, role-based permissions, rate limiting, OAuth, Turnstile bot protection — driven by a single config object passed to a middleware factory. It's a starter, not a standard. The framework has no built-in auth; `kyzen` plugs in via ordinary middleware, the same shape any other auth library would use. If you outgrow it (federated identity, complex authorization graphs, custom session backends), swap it out — nothing else in your app cares. ## What it gives you - [Credentials and sessions](/docs/kyzen/credentials-and-sessions): Email and password signup, signin, signout, session cookies, current-user lookup, and password reset flows. - [Magic links](/docs/kyzen/magic-links): Passwordless email sign-in with Cloudflare Email Service or your own sender callback, plus session creation on link consume. - [Auth context](/docs/kyzen/auth-context): Use `getAuth()` for lazy session access, role checks, cookie helpers, redirects, and auth-aware JSON responses. - [OAuth and RBAC](/docs/kyzen/oauth-and-rbac): Configure GitHub or Google login, define roles, and check permissions with wildcard support. - [Runtime controls](/docs/kyzen/guards-rate-limit-turnstile): Protect routes, throttle requests, and require Turnstile verification before route handlers run. ## Not the same as Cloudflare Access `kyzen` answers **"is this app user signed in?"** — verified against your D1 database via session cookies. Cloudflare Access answers **"is this user from my org?"** — verified at the edge against your IdP, surfaced as a JWT in `cf-access-jwt-assertion`. The two compose orthogonally. An admin panel can require both: Access at the edge for org employees + `kyzen` for any other identity surface. Either, neither, or both — all valid. See [Cloudflare Access](/docs/koze/cloudflare-access) for the framework primitive. ## Package model The package is split into focused runtime modules: - credentials and sessions - magic-link sign-in - low-level auth context - activity logging - OAuth providers - guards - rate limiting - Turnstile verification - organization-aware multi-tenancy ## When to swap this out Reasonable signals to outgrow `kyzen`: - You need WebAuthn / passkeys. - You need federated identity beyond the included OAuth providers (e.g. SAML, OIDC against arbitrary providers). - You want sessions in something other than D1 (Redis, JWT, KV). - You need fine-grained permission graphs beyond role → permission lists. - You need MFA, non-email magic links, or device-based session pinning. - Your team prefers a battle-tested third-party for security-critical surface area. When that day comes: replace the `kyzenAuthMiddleware` step with whatever you're swapping to, drop `kyzen` from your deps, and the rest of your app keeps working. That's the point of middleware-only integration — it's the same kind of seam Express / Hono / Next.js give you. ## Read next - [Getting Started](/docs/kyzen/getting-started) - [Credentials and Sessions](/docs/kyzen/credentials-and-sessions) - [Magic Links](/docs/kyzen/magic-links) - [Auth Context](/docs/kyzen/auth-context) - [OAuth and RBAC](/docs/kyzen/oauth-and-rbac) - [Cloudflare Access](/docs/koze/cloudflare-access) — the framework's edge-identity primitive ### Auth Context Package: Kyzen Canonical: https://kuratchi.dev/docs/kyzen/auth-context Markdown: https://kuratchi.dev/docs/kyzen/auth-context.md > Use getAuth() for lazy session access, role checks, cookies, and redirect helpers ## `getAuth()` `getAuth()` is the low-level backend auth API for route loads, actions, RPC handlers, and other server code. ```ts import { getAuth } from '@kuratchi/kyzen'; const auth = getAuth(); const session = await auth.getSession(); ``` ## What it exposes The returned auth context includes: - `getSession()` - `getUser()` - `isAuthenticated()` - `hasPermission()` - `hasRole()` - `getPermissions()` - `getSessionCookie()` - `getCookies()` - `buildSetCookie()` - `buildClearCookie()` - `redirect()` - `forbidden()` - `json()` - `getAuthEnv()` - `locals` - `getRequest()` - `getEnv()` ## Session loading model `getAuth()` is lazy: - it reads from the current Koze request context - it decrypts the session cookie only when needed - it caches the resolved session for the lifetime of the `getAuth()` instance If you need to customize the session pipeline, `getAuth()` accepts options for: - custom env mapping - custom session decoding - custom session loading - static role-to-permission maps - explicit runtime context injection ## Redirect safety The built-in `auth.redirect()` helper blocks open redirects. Relative paths are allowed, but external origins are rejected and replaced with `/`. ## When to use this layer Use `getAuth()` when you need explicit request-scoped auth behavior. Use `getCurrentUser()` when all you need is the authenticated user object for the current request. ### Credentials and Sessions Package: Kyzen Canonical: https://kuratchi.dev/docs/kyzen/credentials-and-sessions Markdown: https://kuratchi.dev/docs/kyzen/credentials-and-sessions.md > Signup, signin, signout, current-user lookup, password reset, email verification, and invitations For passwordless email login, see [Magic Links](/docs/kyzen/magic-links). ## Credentials config Configure credentials by passing options to `kyzenAuthMiddleware({...})`. The runtime helpers read those options on first request: ```ts // src/middleware.ts import { kyzenAuthMiddleware } from '@kuratchi/kyzen/middleware'; export default defineMiddleware({ auth: kyzenAuthMiddleware({ cookieName: 'kuratchi_session', sessionEnabled: true, credentials: { binding: 'DB', // D1 binding name defaultRole: 'member', sessionDuration: 30 * 24 * 60 * 60 * 1000, minPasswordLength: 8, signUpRedirect: '/auth/verify-email', signInRedirect: '/', signOutRedirect: '/auth/signin', // Email verification (optional — enables the verify flow) requireEmailVerified: true, sendVerificationEmail: async ({ email, token }) => { // your transactional sender (Resend, SendGrid, AWS SES, env.EMAIL, etc.) }, sendWelcomeEmail: async ({ email }) => { /* ... */ }, // Invitations (org-mode only) sendInviteEmail: async ({ email, token, invitedBy }) => { /* ... */ }, }, }), }); ``` Then call the helpers anywhere request code runs: ```ts import { signUp, signIn, signOut, getCurrentUser, requestPasswordReset, resetPassword, verifyEmail, resendEmailVerification, inviteMember, acceptInvite, } from '@kuratchi/kyzen'; ``` ### Supported credentials options | Option | Type | Default | Purpose | | --- | --- | --- | --- | | `binding` | `string` | `'DB'` | D1 binding name for the admin database | | `defaultRole` | `string` | `'user'` | Role assigned at signup | | `sessionDuration` | `number` | `30 * 24 * 60 * 60 * 1000` | Session lifetime in ms | | `minPasswordLength` | `number` | `8` | Minimum password length | | `signUpRedirect` | `string` | `'/auth/login'` | Redirect after successful signup | | `signInRedirect` | `string` | `'/admin'` | Redirect after successful signin | | `signOutRedirect` | `string` | `'/auth/login'` | Redirect after signout | | `excludeFields` | `string[]` | `['passwordHash']` | Fields stripped from `getCurrentUser()` | | `requireEmailVerified` | `boolean` | `false` | Reject signin when `users.emailVerified` is null | | `unverifiedMessage` | `string` | `'Please verify your email...'` | Error returned when `requireEmailVerified` blocks signin | | `tokenExpiryMs` | `number` | `24 * 60 * 60 * 1000` | Lifetime of verification + invite tokens | | `sendVerificationEmail` | `(params) => Promise` | — | App-supplied callback that delivers a verification email after signUp | | `sendWelcomeEmail` | `(params) => Promise` | — | Optional welcome email after `verifyEmail` succeeds | | `sendInviteEmail` | `(params) => Promise` | — | Required for `inviteMember` (org-mode invitations) | ## Signup `signUp(formData)` expects: - `email` (required) - `password` (required) - `name` (optional) - `organizationName` (required when organizations are configured — see [Organizations](/docs/kyzen/organizations-and-schema)) The helper: 1. Validates password length (rejects if shorter than `minPasswordLength`). 2. Hashes the password with `AUTH_SECRET` as a pepper. 3. Creates the user (in the org's Durable Object when org-mode is active, in admin D1 otherwise). 4. **If `sendVerificationEmail` is configured**: issues a verification token, calls the callback, redirects to `signUpRedirect` (default `/auth/verify-email`) **without auto-login**. 5. **If not configured**: auto-creates a session and redirects to `signUpRedirect` (default `/`). 6. Logs `user.signup` activity. ## Signin `signIn(formData)` expects: - `email` - `password` On success it: 1. Looks up the user (via `organizationUsers` email→org index in org-mode). 2. Verifies the password hash. 3. **If `requireEmailVerified` is true and `emailVerified` is null** → throws `unverifiedMessage`. 4. Creates a session token + record (in the org DO or admin DB). 5. Builds a signed session cookie carrying the DO routing key. 6. Sets the cookie and redirects to `signInRedirect`. 7. Logs `user.login` activity. ## Signout `signOut()` resolves the current session from the cookie, deletes the session record (DO RPC in org-mode), clears the cookie, and redirects. ## Current user lookup `getCurrentUser()` reads the signed session cookie, validates the backing session, and returns a safe user object: ```ts { id: string, email: string, name?: string | null, role: string, emailVerified?: number | null, // org-mode adds these: organizationId: string, // the actual org UUID — use this for FKs organizationName?: string, // human label for chrome // ...other user columns } ``` `passwordHash` is excluded by default. Override with `excludeFields`. ## Password reset ```ts import { requestPasswordReset, resetPassword } from '@kuratchi/kyzen'; ``` `requestPasswordReset({ formData })` reads `formData.email`, generates a reset token, stores its hash in `passwordResets`, and (if you've wired it) hands the plaintext token to your email-sender. `resetPassword({ formData })` reads `token` + `password`, verifies the token, updates the password hash, and invalidates active sessions. ## Email verification When `sendVerificationEmail` is configured, signUp issues a token and your callback delivers the email. The user clicks the link → your route calls `verifyEmail(token)`: ```ts // src/routes/auth/verify-email/index.koze if (verified) {

Email verified. Sign in.

} else if (error) {

{error}

} ``` `verifyEmail(token)` returns `{ success: boolean; error?: string }` — never throws. `resendEmailVerification({ formData })` is a form-action; it reads `formData.email` and re-issues a token. Both flows automatically route through the org DO when org-mode is active. If `sendWelcomeEmail` is configured, it fires after a successful `verifyEmail`. ## Invitations See [Organizations and schema](/docs/kyzen/organizations-and-schema#invitations) — invitations are org-mode-only. ## Cookie model The session cookie is AES-GCM-encrypted with `AUTH_SECRET` and contains `{ doName, tokenHash }`: - `doName` is the per-org DO routing key in org-mode, or `'default'` in single-tenant mode. - `tokenHash` is the hash of the session token; the plaintext token never leaves the cookie. Session persistence uses the `sessions` table (admin DB or per-org DO), not the cookie itself — invalidating a session deletes the row, which is checked on every `getCurrentUser()` call. ### Getting Started Package: Kyzen Canonical: https://kuratchi.dev/docs/kyzen/getting-started Markdown: https://kuratchi.dev/docs/kyzen/getting-started.md > Install kyzen and wire it into your Koze middleware ## What this is `kyzen` is the Koze quickstart auth library. It gets you to "users can sign in" fast - credentials, magic links, sessions, role-based permissions, rate limiting, CSRF/origin checks, OAuth, Turnstile bot protection - driven by a single config object that you pass to a middleware factory. It's a **starter, not a standard.** The framework has no built-in auth; `kyzen` plugs in via ordinary middleware, the same shape any other auth library would use. If you outgrow it (federated identity, complex authorization graphs, custom session backends), swap it out - nothing else in your app cares. ## Install ```bash npm install @kuratchi/kyzen @kuratchi/kunii ``` `kyzen` depends on ORM-backed tables for users, sessions, magic-link tokens, OAuth accounts, activity logs, and related auth state. ## Minimal setup Auth is wired through middleware. For Koze, the primary adapter is `kyzen/koze`. Keep the config in its own module so it's easy to find and easy to test, then import it into `src/middleware.ts`. ```ts // src/server/auth-config.ts import { kyzenAuthConfig } from '@kuratchi/kyzen/adapter'; export const authConfig = kyzenAuthConfig({ cookieName: 'kuratchi_session', sessionEnabled: true, credentials: { binding: 'DB' }, csrf: { paths: ['/auth/*'], }, guards: { paths: ['/admin/*'], redirectTo: '/auth/login', }, }); ``` ```ts // src/middleware.ts import { defineMiddleware } from '@kuratchi/koze'; import { initKyzenAuth } from '@kuratchi/kyzen/koze'; import { authConfig } from '$server/auth-config'; const auth = initKyzenAuth(authConfig); export default defineMiddleware({ auth: auth.middleware(), }); ``` That is the primary integration point. `kyzen/middleware` remains available as the lower-level compatibility path, but `kyzen/koze` is the first-class adapter for framework apps. ## Adapter defaults `kyzenAuthConfig({...})` is an optional helper that fills in sensible defaults. You can pass a plain object to `auth.middleware()` or `kyzenAuthMiddleware()` directly; the adapter just adds: - `cookieName`: `kuratchi_session` - `secretEnvKey`: `AUTH_SECRET` - `sessionEnabled`: `true` ## Common imports Once the middleware is wired, the auth helpers become available in any route, action, or `$server/*` module: ```ts import { signUp, signIn, signOut, getCurrentUser, getAuth, requestMagicLink, consumeMagicLink, requestPasswordReset, resetPassword, verifyEmail, resendEmailVerification, inviteMember, acceptInvite, logActivity, hasRole, hasPermission, startOAuth, handleOAuthCallback, requireAuthGuard, verifyTurnstile, } from '@kuratchi/kyzen'; ``` All of these read from request-scoped locals populated by auth middleware. Call them from route handlers, actions, RPC functions - the auth context is available wherever the framework runs request code. ## Single-tenant vs multi-tenant `kyzen` supports two modes: | Mode | When | Storage | | --- | --- | --- | | Single-tenant | One shared user pool. No organizations. | All auth tables on the admin D1 binding. | | **Org-mode** | Per-organization isolation. Each org gets its own SQLite-backed Durable Object. | Admin D1 stores `organizations` + `organizationUsers` + verification tokens; users + sessions live inside each org's DO. | Org-mode is the more interesting case (and the one the framework's reference app, `apps/web`, runs in). Switch it on with the `organizations` config block - see [Organizations and Schema](/docs/kyzen/organizations-and-schema). ## Required env At minimum, auth flows expect: - `AUTH_SECRET` Feature-specific flows can also require: - `RESEND_API_KEY` - `EMAIL_FROM` - `GITHUB_CLIENT_ID` - `GITHUB_CLIENT_SECRET` - `GOOGLE_CLIENT_ID` - `GOOGLE_CLIENT_SECRET` - `TURNSTILE_SECRET` - `TURNSTILE_SITE_KEY` - `ORIGIN` Set these via Cloudflare secrets (`wrangler secret put`) or `.dev.vars` for local development. ## What to read next - [Credentials and Sessions](/docs/kyzen/credentials-and-sessions) - [Magic Links](/docs/kyzen/magic-links) - [Auth Context](/docs/kyzen/auth-context) - [OAuth and RBAC](/docs/kyzen/oauth-and-rbac) - [Guards, CSRF, Rate Limits, and Turnstile](/docs/kyzen/guards-rate-limit-turnstile) - [Organizations and Schema](/docs/kyzen/organizations-and-schema) - `send_email` Worker binding (when using built-in magic-link delivery on Cloudflare Workers) ### Guards, CSRF, Rate Limits, and Turnstile Package: Kyzen Canonical: https://kuratchi.dev/docs/kyzen/guards-rate-limit-turnstile Markdown: https://kuratchi.dev/docs/kyzen/guards-rate-limit-turnstile.md > Apply runtime auth controls before route handlers run These subsystems live as nested options on `kyzenAuthMiddleware({...})`. They run inside the auth middleware's pre-route check phase before route handlers: rate-limit, CSRF/origin checks, Turnstile, then guards. ```ts // src/middleware.ts kyzenAuthMiddleware({ guards: { /* see Guards below */ }, csrf: { /* see CSRF below */ }, rateLimit: { /* see Rate limiting below */ }, turnstile: { /* see Turnstile below */ }, }); ``` ## Guards Guards are configured with: - `paths` - `exclude` - `redirectTo` Example: ```ts guards: { paths: ['/admin/*', '/dashboard/*'], exclude: ['/auth/login'], redirectTo: '/auth/login', } ``` Runtime helpers: - `checkGuard()` - `requireAuthGuard` Important behavior: `checkGuard()` only checks for the presence of the session cookie. It does not fully validate the session record. Route code should still call `getCurrentUser()` or `getAuth().getSession()` before trusting the request as authenticated. ## CSRF and origin checks CSRF protection is opt-in because existing apps may use different credential route names. Configure it on routes that mutate auth state, such as sign-in and sign-up form actions: ```ts csrf: { paths: ['/auth/signin', '/auth/signup'], trustedOrigins: ['https://app.example.com', 'https://*.tenant.example.com'], } ``` Behavior: - Cross-site top-level POST navigations without cookies are rejected using Fetch Metadata headers. - Unsafe requests with cookies validate the `Origin` header against the request origin plus `trustedOrigins`. - Wildcard trusted origins match tenant subdomains. Runtime helpers: - `checkCsrf()` - `configureCsrf(config)` ## Rate limiting Configure rate limits per route: ```ts rateLimit: { defaultWindowMs: 60000, defaultMaxRequests: 10, kvBinding: 'RATE_LIMIT', routes: [ { id: 'auth-signin', path: '/auth/signin', methods: ['POST'], maxRequests: 5, windowMs: 60000, message: 'Too many sign-in attempts.', }, ], } ``` Runtime helpers: - `checkRateLimit()` - `getRateLimitInfo(routeId)` The limiter uses `cf-connecting-ip` and can persist counts in KV when configured, with an in-memory fallback per isolate. ## Turnstile Configure Turnstile with: - `secretEnv` - `siteKeyEnv` - `skipInDev` - `routes` Example: ```ts turnstile: { secretEnv: 'TURNSTILE_SECRET', siteKeyEnv: 'TURNSTILE_SITE_KEY', routes: [ { id: 'auth-signin', path: '/auth/signin', methods: ['POST'], expectedAction: 'signin', }, ], } ``` Runtime helpers: - `checkTurnstile()` - `verifyTurnstile(token, options)` The verifier reads tokens from common headers or form fields, then calls Cloudflare's Turnstile verification endpoint. ### Magic Links Package: Kyzen Canonical: https://kuratchi.dev/docs/kyzen/magic-links Markdown: https://kuratchi.dev/docs/kyzen/magic-links.md > Passwordless email sign-in with Cloudflare Email Service or a custom sender callback ## What it gives you `kyzen` ships a first-class passwordless email flow: - request a one-time sign-in link with `requestMagicLink({ formData })` - deliver the email through a Cloudflare `send_email` binding or your own callback - consume the link with `consumeMagicLink(token)` - create a normal session cookie backed by the standard `sessions` table This keeps the app SSR-first: native form post to request the link, native GET to consume it, no client auth SDK required. ## Minimal setup ```ts // src/server/auth-config.ts import { kyzenAuthConfig } from '@kuratchi/kyzen/adapter'; export const authConfig = kyzenAuthConfig({ cookieName: 'app_session', sessionEnabled: true, magicLinks: { binding: 'DB', emailBinding: 'EMAIL', from: 'noreply@example.com', fromName: 'My App', appName: 'My App', linkLabel: 'Sign in to My App', requestRedirect: '/auth/check-email', verifyPath: '/auth/verify-email', signInRedirect: '/', tokenExpiryMs: 15 * 60 * 1000, requireExistingUser: true, }, }); ``` ```ts // src/middleware.ts import { defineMiddleware } from '@kuratchi/koze'; import { kyzenAuthMiddleware } from '@kuratchi/kyzen/middleware'; import { authConfig } from '$server/auth-config'; export default defineMiddleware({ auth: kyzenAuthMiddleware(authConfig), }); ``` ## Built-in email delivery When `emailBinding` is configured, the auth package can send a branded magic-link email without a custom callback. Supported options: | Option | Type | Default | Purpose | | --- | --- | --- | --- | | `binding` | `string` | `'DB'` | D1 binding name for auth state | | `emailBinding` | `string` | `'EMAIL'` | Cloudflare `send_email` binding name | | `from` | `string` | `env.EMAIL_FROM` | Sender address | | `fromName` | `string` | — | Optional display name for the sender | | `appName` | `string` | `'your account'` | Product name used in subject/body copy | | `subject` | `string` | `Your {appName} sign in link` | Email subject override | | `linkLabel` | `string` | `'Sign in'` | CTA label in the email body | | `requestRedirect` | `string` | `verifyPath + ?email=` | Where to send the browser after link request | | `verifyPath` | `string` | `'/auth/verify-email'` | Route that receives `?token=` | | `signInRedirect` | `string` | `'/'` | App landing page after link consumption | | `tokenExpiryMs` | `number` | `15 * 60 * 1000` | Link lifetime | | `sessionDuration` | `number` | `30 * 24 * 60 * 60 * 1000` | Session lifetime | | `requireExistingUser` | `boolean` | `true` | Only issue links for known users | | `fallbackToConsoleOnSendFailure` | `boolean` | `true` on localhost-style origins | Log the link instead of throwing when local email send fails | ## Custom email delivery If you want complete control over the outgoing message, pass `sendMagicLinkEmail`: ```ts magicLinks: { binding: 'DB', verifyPath: '/auth/verify-email', sendMagicLinkEmail: async ({ email, magicUrl, expiresAt }) => { await env.EMAIL.send({ to: email, from: 'noreply@example.com', subject: 'Your secure sign-in link', html: `

Sign in

`, text: `Sign in: ${magicUrl}`, }); }, } ``` The package still handles token storage, expiry, single use, and session creation. Your callback only owns delivery. ## Request the link ```ts // src/server/auth/magic-link.ts import { requestMagicLink } from '@kuratchi/kyzen'; export async function requestSigninMagicLink({ formData }: { formData: FormData }) { await requestMagicLink({ formData }); } ``` ```html
``` `requestMagicLink()` reads `formData.email`, creates a one-time token, stores only the hash in `magicLinks`, sends the email, and redirects to `requestRedirect`. When `requireExistingUser` is `true`, unknown emails do not get a link, but the browser still follows the same redirect. That avoids account enumeration. ## Consume the link ```ts // src/routes/auth/verify-email/index.koze if (error) {

{error}

} ``` `consumeMagicLink(token)`: 1. hashes the plaintext token 2. looks up the matching `magicLinks` row 3. rejects expired or already-consumed tokens 4. resolves the user in single-tenant or org-mode storage 5. creates a normal auth session 6. marks the link consumed and removes outstanding links for that email ## Schema Magic links add one table to the auth schema: ```ts magicLinks: { id: 'text primary key not null', email: 'text not null', userId: 'text', organizationId: 'text', tokenHash: 'text not null unique', expiresAt: 'timestamp_ms not null', consumedAt: 'timestamp_ms', createdAt: 'text default now', updatedAt: 'text default now', deletedAt: 'text', } ``` If you're using the exported auth schema tables, this is included automatically. ## Cloudflare setup Built-in delivery uses the Workers `send_email` binding: ```jsonc { "send_email": [ { "name": "EMAIL" } ] } ``` For development: - local Workers simulation logs outbound email details by default - if you want real sends during local development, use a remote binding - if email send fails on a localhost-style origin and `fallbackToConsoleOnSendFailure` is enabled, the auth package logs the full magic URL instead of failing the request ## Read next - [Getting Started](/docs/kyzen/getting-started) - [Credentials and Sessions](/docs/kyzen/credentials-and-sessions) - [Organizations and Schema](/docs/kyzen/organizations-and-schema) ### OAuth and RBAC Package: Kyzen Canonical: https://kuratchi.dev/docs/kyzen/oauth-and-rbac Markdown: https://kuratchi.dev/docs/kyzen/oauth-and-rbac.md > Configure provider login, define roles, and check permissions ## OAuth providers The package currently includes built-in provider wiring for: - GitHub - Google Configure providers via the `oauth` option on `kyzenAuthMiddleware({...})`: ```ts // src/middleware.ts kyzenAuthMiddleware({ oauth: { providers: { github: { clientIdEnv: 'GITHUB_CLIENT_ID', clientSecretEnv: 'GITHUB_CLIENT_SECRET', }, google: { clientIdEnv: 'GOOGLE_CLIENT_ID', clientSecretEnv: 'GOOGLE_CLIENT_SECRET', }, }, loginRedirect: '/admin', }, }); ``` Provider client IDs and secrets are looked up from `env` at request time using the env var names you specify — keep the literal values in Cloudflare secrets, not in code. Runtime helpers: - `getOAuthData()` - `startOAuth(formData)` - `handleOAuthCallback()` - `getOAuthProviders()` The callback flow: - verifies state - exchanges the code for tokens - fetches the profile - creates or links the user - stores encrypted OAuth tokens - creates a normal auth session ## Roles Define role mappings with `defineRoles()`: ```ts const Roles = defineRoles({ admin: ['*'], editor: ['posts.*', 'users.read'], user: ['posts.read'], }, { defaultRole: 'user', }); ``` Available helpers: - `hasRole(user, role)` - `hasPermission(user, permission)` - `getPermissionsForRole(role)` - `getRoleDefinitions()` - `getAllRoles()` - `getDefaultRole()` Wildcard behavior: - `*` matches everything - `posts.*` matches `posts.read`, `posts.create`, and the base `posts` namespace ## Assigning roles `assignRole(formData)` reads: - `userId` - `role` and requires the current user to have `users.update`. ## Activity helpers The auth package also includes built-in activity logging: - `defineActivities()` - `logActivity()` - `getActivity()` - `getActivityDefinitions()` Use this layer for audit-friendly auth and admin events. ### Organizations and schema Package: Kyzen Canonical: https://kuratchi.dev/docs/kyzen/organizations-and-schema Markdown: https://kuratchi.dev/docs/kyzen/organizations-and-schema.md > Multi-tenant auth with one Durable Object per organization `kyzen` can run in two modes: - **Single-tenant** — one shared `users` and `sessions` table on the admin D1. - **Org-mode** — per-organization Durable Object instances. Each org owns its own SQLite storage with its own users + sessions. Admin D1 holds only the email→org index. Org-mode is the more interesting case (and the one the framework's reference app, `apps/web`, uses). It scales horizontally without table-level partitioning and isolates each org's data in its own DO instance. ## Architecture ``` ┌──────────────────────────────────────────┐ │ Cloudflare Worker │ │ │ │ src/middleware.ts │ │ ↓ kyzenAuthMiddleware(authConfig) │ │ ↓ │ │ parses cookie → { doName, tokenHash } │ └──────────┬───────────────────┬─────────────┘ │ │ ▼ ▼ ┌────────────────────┐ ┌──────────────────────┐ │ Admin D1 (env.DB) │ │ Per-org DO (env.ORG_DB) │ │ │ │ │ organizations │ │ users │ │ organizationUsers │ │ sessions │ │ emailVerifications │ │ emailVerifications │ │ oauthAccounts │ │ passwordResets │ │ │ │ activityLog │ └────────────────────┘ └──────────────────────┘ ^ ^ │ │ │ stub = getOrgStubByName(doName) │ stub.createUser(...) / .getSession(...) etc. ``` What lives where: | Table | Database | Why | | --- | --- | --- | | `organizations` | Admin D1 | Org records + their `doName` (the DO routing key). Needed before any DO lookup. | | `organizationUsers` | Admin D1 | Email → org index. Lets signin resolve which DO to talk to *before* the user has a session. | | `emailVerifications` | Admin D1 | Verification + invite tokens. Tokens reference `userId` + `organizationId`; the token lookup happens before the user can authenticate. | | `oauthAccounts` | Admin D1 | OAuth provider links. | | `users`, `sessions` | Per-org DO | The app's actual user records + active sessions. Every read/write goes through the DO stub. | | `passwordResets`, `activityLog` | Per-org DO | Per-org concerns. | ## Configuration ```ts // src/middleware.ts import { defineMiddleware } from '@kuratchi/koze'; import { kyzenAuthMiddleware } from '@kuratchi/kyzen/middleware'; import { authConfig } from '$server/auth-config'; export default defineMiddleware({ auth: kyzenAuthMiddleware(authConfig), }); ``` ```ts // src/server/auth-config.ts import { kyzenAuthConfig } from '@kuratchi/kyzen/adapter'; export const authConfig = kyzenAuthConfig({ cookieName: 'app_session', sessionEnabled: true, credentials: { binding: 'DB', // admin D1 binding requireEmailVerified: true, sendVerificationEmail: async ({ email, token }) => { /* ... */ }, sendInviteEmail: async ({ email, token, invitedBy }) => { /* ... */ }, sendWelcomeEmail: async ({ email }) => { /* ... */ }, }, organizations: { binding: 'ORG_DB', // DO namespace binding }, }); ``` The `organizations` block is what flips org-mode on. Without it, `kyzen` writes users + sessions to the admin DB. ## The Durable Object You write the DO class. The auth package defines the **contract** — which RPC methods the package will call on the stub — but not the implementation. This keeps the app in control of: - which schema lives in the DO - where extra app-specific user fields go - which extra RPC methods the org's data exposes ```ts // src/server/auth.do.ts import { DurableObject } from 'cloudflare:workers'; import { autoMigrate, kunii } from '@kuratchi/kunii'; import { orgSchema } from './schemas/org'; export default class OrgAuth extends DurableObject { static binding = 'ORG_DB'; declare db: any; constructor(ctx: DurableObjectState, env: any) { super(ctx, env); autoMigrate(ctx.storage, orgSchema); this.db = kunii(ctx.storage.sql, orgSchema); } // ── Auth contract (called by kyzen) ───────────────────── async createUser(input: { email: string; name?: string | null; passwordHash?: string | null; role?: string }) { const id = crypto.randomUUID(); await this.db.users.insert({ id, email: input.email, name: input.name ?? null, passwordHash: input.passwordHash ?? null, role: input.role ?? 'member', }); const result = await this.db.users.where({ id }).first(); return result.data; } async getUserByEmail(email: string) { const r = await this.db.users.where({ email }).first(); return r.data ?? null; } async getUserById(id: string) { const r = await this.db.users.where({ id }).first(); return r.data ?? null; } async createSession(input: { sessionToken: string; userId: string; expires: number }) { await this.db.sessions.insert(input); } async getSession(tokenHash: string) { const s = await this.db.sessions.where({ sessionToken: tokenHash }).first(); if (!s.data) return null; if (s.data.expires < Date.now()) { await this.db.sessions.where({ sessionToken: tokenHash }).delete(); return null; } const u = await this.db.users.where({ id: s.data.userId }).first(); if (!u.data) return null; const { passwordHash, ...safeUser } = u.data; return { user: safeUser, ...s.data }; } async deleteSession(tokenHash: string) { await this.db.sessions.where({ sessionToken: tokenHash }).delete(); } async markEmailVerified(userId: string) { await this.db.users.where({ id: userId }).update({ emailVerified: Date.now() }); } async completeInvite(input: { userId: string; passwordHash: string; name?: string | null }) { const updates: Record = { passwordHash: input.passwordHash, emailVerified: Date.now(), }; if (input.name) updates.name = input.name; await this.db.users.where({ id: input.userId }).update(updates); } } ``` The framework auto-discovers `*.do.ts` files in `src/server/` and adds the matching `durable_objects.bindings` + SQLite migration entry to `wrangler.jsonc`. Filename → binding: `auth.do.ts` → `ORG_DB`-named DO if `static binding = 'ORG_DB'` is set on the class. ## Schema `kyzen` exports two table maps you spread into your schemas: | Export | Where it goes | | --- | --- | | `authAdminTables` | Spread into your **admin D1 schema** | | `authOrgTables` | Spread into your **per-org DO schema** | ```ts // src/server/schemas/admin.ts import type { SchemaDsl } from '@kuratchi/kunii'; import { authAdminTables } from '@kuratchi/kyzen'; const { users: _, sessions: _s, passwordResets: _p, ...authRouting } = authAdminTables; export const adminSchema = { name: 'admin', tables: { ...authRouting, // organizations, organizationUsers, emailVerifications, oauthAccounts, activityLog // your app tables sites: { /* ... */ }, databases: { /* ... */ }, }, } satisfies SchemaDsl; ``` ```ts // src/server/schemas/org.ts import type { SchemaDsl } from '@kuratchi/kunii'; import { authOrgTables } from '@kuratchi/kyzen'; export const orgSchema = { name: 'org', tables: { ...authOrgTables, // users, sessions, emailVerifications, passwordResets, activityLog // optional app-specific user extensions users: { ...(authOrgTables.users as Record), firstName: 'text', lastName: 'text', phone: 'text', }, }, } satisfies SchemaDsl; ``` In org-mode you typically **don't include `authAdminTables.users` / `authAdminTables.sessions`** in the admin schema (destructure them out, as above) — those tables only matter to single-tenant mode. The DO version of `users`/`sessions` is what the package writes to. ### `authAdminTables` shape | Table | Columns (high level) | | --- | --- | | `users` | `id`, `email`, `name`, `role`, `passwordHash`, `image`, `emailVerified`, timestamps. **Single-tenant only.** | | `sessions` | `sessionToken` (PK, hashed), `userId`, `expires`, timestamps. **Single-tenant only.** | | `passwordResets` | `userId`, `tokenHash`, `expiresAt`, timestamps. **Single-tenant only.** | | `emailVerifications` | `id`, `token` (hashed), `userId`, `organizationId`, `email`, `expires`, timestamps. Used by both modes for verification + invitation tokens. | | `oauthAccounts` | `id`, `userId`, `provider`, `providerAccountId`, tokens, timestamps. | | `organizations` | `id`, `name`, `doName` (DO routing key), `email`, timestamps. | | `organizationUsers` | `id`, `organizationId`, `email`, timestamps. | | `activityLog` | `id`, `userId`, `action`, `detail`, `ip`, `userAgent`, `createdAt`. | ### `authOrgTables` shape | Table | Columns | | --- | --- | | `users` | Same shape as admin `users`. Lives inside the org DO. | | `sessions` | Same shape as admin `sessions`. | | `emailVerifications` | Same shape as admin. Useful when password-reset / re-verify flows want to hit the org DO directly. | | `passwordResets` | Same shape as admin. | | `activityLog` | Same shape. | ## Helpers ```ts import { getCurrentUser, getOrgStubByName, getOrgClient, resolveOrgDatabaseName, inviteMember, acceptInvite, isOrgAvailable, } from '@kuratchi/kyzen'; ``` | Helper | Use case | | --- | --- | | `getCurrentUser()` | Returns the signed-in user enriched with `organizationId` + `organizationName`. | | `getOrgStubByName(doName)` | Get a DO stub by its routing key (sync; assumes you already know the `doName`). | | `getOrgClient(organizationId)` | Resolve `doName` from admin D1, then return the stub. Async. | | `resolveOrgDatabaseName(organizationId)` | Just the lookup, no stub. Returns `string \| null`. | | `isOrgAvailable()` | True when the auth package has been configured with an `organizations.binding`. | | `inviteMember({ email, name?, role? })` | Invite an email into the **current authenticated user's** organization. | | `acceptInvite({ formData })` | Form-action invitee runs to set their password and activate the user. | ## Signup in org-mode ```ts // src/routes/auth/signup/index.koze
``` `organizationName` is **required** in org-mode — the package uses it to generate the new DO's routing key, create the `organizations` row, then provision the org's DO and create the user inside it. ## Invitations `inviteMember()` is the org-mode invite flow: ```ts // src/server/database/users.ts import { inviteMember } from '@kuratchi/kyzen'; export async function inviteUser({ formData }: FormData) { await inviteMember({ email: formData.get('email') as string, name: (formData.get('name') as string) ?? null, role: (formData.get('role') as string) ?? 'member', }); } ``` What happens: 1. The current authenticated user must have an `organizationId` (they have to be signed in to invite). 2. A placeholder user is created in the org's DO via `stub.createUser()` with `passwordHash: null`. 3. An `organizationUsers` row is added on the admin DB so the invitee can later look up which org they belong to. 4. A token is generated, stored in `emailVerifications`, and handed to your `sendInviteEmail` callback. The invitee clicks the link → routes to `acceptInvite`: ```ts // src/routes/auth/accept-invite/index.koze
``` `acceptInvite` verifies the token, hashes the password, calls `stub.completeInvite({ userId, passwordHash, name })`, deletes the token, and redirects to signin. ## Reference implementation The framework's own dashboard, `apps/web`, runs in org-mode end-to-end. Read `apps/web/src/server/auth.do.ts` for the canonical DO shape, `apps/web/src/server/schemas/admin.ts` + `org.ts` for the schema split, and `apps/web/src/server/auth-config.ts` for the email-callback wiring. ## Kuzan Koze component library ### UI Components Package: Kuzan Canonical: https://kuratchi.dev/docs/kuzan Markdown: https://kuratchi.dev/docs/kuzan/index.md > Server-rendered HTML component library for Koze ## What is kuzan? `kuzan` is an **optional** server-rendered HTML component library designed specifically for Koze applications. It provides a collection of pre-built, accessible UI components that work seamlessly with the Koze framework. ## Key features - **Server-rendered** — Components are `.html` templates that compile to server-side code - **Zero JavaScript by default** — Most components work without client-side JS - **Dark mode built-in** — Automatic theme switching with localStorage persistence - **Customizable theming** — CSS variables for colors, spacing, shadows, and radii - **Accessible** — Semantic HTML with ARIA attributes where needed - **Framework-native** — Uses Koze's template syntax and component model ## Philosophy Unlike traditional component libraries that ship heavy JavaScript bundles, `kuzan` embraces server-side rendering: - Components are imported as `.html` files directly into your routes - Styling is handled via a single `theme.css` file with CSS variables - Interactive components use minimal vanilla JavaScript - No build-time CSS processing required (though Tailwind is supported) ## When to use it `kuzan` is ideal when you want: - A consistent design system without building components from scratch - Server-rendered UI with minimal client-side JavaScript - Dark mode support out of the box - Semantic HTML with sensible defaults ## When not to use it You might prefer building custom components if: - You need highly specialized or branded UI components - You want complete control over every CSS detail - You're already using another UI framework (Tailwind, DaisyUI, etc.) ## Component categories `kuzan` includes components for: - **Layout** — App shells, sidebars, headers, dashboards - **Content** — Cards, alerts, callouts, empty states - **Forms** — Inputs, labels, fieldsets (styled via semantic HTML) - **Navigation** — Breadcrumbs, tab navigation, dropdowns - **Data display** — Badges, status chips, data lists, progress bars - **Interactive** — Dialogs, drawers, accordions, theme toggles - **Utilities** — Loading states, tooltips, hints ## Next steps - [Getting Started](/docs/kuzan/getting-started) — Install and configure `kuzan` - [Theming](/docs/kuzan/theming) — Customize colors, dark mode, and radii - [Components](/docs/kuzan/components) — Browse the full component reference ### Components Package: Kuzan Canonical: https://kuratchi.dev/docs/kuzan/components Markdown: https://kuratchi.dev/docs/kuzan/components.md > Complete reference for all kuzan components ## Layout Components ### AppShell Container for the entire application layout. ```html ``` ### AppHeader Sticky header bar for application navigation. ```html My App ``` ### Sidebar Collapsible sidebar navigation. ```html

Navigation

Home Settings
``` **Props:** - `data_side` — `'left'` | `'right'` (default: `'left'`) - `data_variant` — `'sidebar'` | `'floating'` | `'inset'` - `data_collapsible` — `'offcanvas'` | `'icon'` | `'none'` ### Dashboard Pre-built dashboard layout with sidebar. ```html

Dashboard

``` --- ## Content Components ### Card Bordered container for content sections. ```html

Card content goes here.

``` **Props:** - `title` — Card heading - `bodyClass` — Custom class for card body - `data_compact` — Reduced padding - `data_side` — Horizontal layout with image - `data_variant` — `'danger'` | `'warning'` (adds colored left border) ### InfoCard Card with icon and description. ```html ``` **Props:** - `title` — Card title - `value` — Main value to display - `icon` — Icon or emoji ### MetricCard Card for displaying key metrics. ```html ``` **Props:** - `label` — Metric label - `value` — Primary metric value - `subvalue` — Secondary description ### StatsCard Card with icon, value, and trend indicator. ```html ``` **Props:** - `label` — Stat label - `value` — Primary value - `description` — Additional context - `icon` — Icon or emoji - `trend` — Trend value (e.g., `"+12%"`) - `trendLabel` — Trend description ### Alert Contextual alert messages. ```html This is an informational message. This is a dismissible error alert. ``` **Props:** - `data_variant` — `'info'` | `'success'` | `'warning'` | `'danger'` | `'destructive'` | `'error'` - `dismissible` — Show close button ### Callout Highlighted content block with optional icon. ```html This is important information. ``` **Props:** - `title` — Callout heading - `data_variant` — `'info'` | `'success'` | `'warning'` | `'danger'` ### EmptyState Placeholder for empty lists or missing content. ```html ``` **Props:** - `title` — Empty state title - `description` — Additional context --- ## Navigation Components ### Breadcrumb Hierarchical navigation trail. ```html Home Products Current Page ``` ### TabNav Tabbed navigation interface. ```html ``` **Props:** - `tabs` — Array of `{ label, href, active? }` objects ### Dropdown Dropdown menu with trigger button. ```html Edit Delete ``` **Props:** - `label` — Dropdown trigger text --- ## Data Display Components ### Badge Small status or label indicator. ```html Default Active Error Pending ``` **Props:** - `data_variant` — `'secondary'` | `'outline'` | `'destructive'` | `'danger'` | `'success'` | `'warning'` | `'info'` ### StatusChip Status indicator with color-coded variants. ```html Active Pending Error ``` **Props:** - `status` — `'active'` | `'pending'` | `'error'` | `'success'` | `'warning'` ### DataList Vertical list of data items. ```html ``` ### Progress Progress bar indicator. ```html ``` **Props:** - `value` — Current progress value - `max` — Maximum value (default: `100`) - `data_variant` — `'primary'` | `'success'` | `'warning'` | `'danger'` --- ## Interactive Components ### Dialog Modal dialog overlay. ```html

Are you sure you want to proceed?

``` **Props:** - `title` — Dialog heading ### Drawer Slide-out panel. ```html

Drawer content

``` **Props:** - `title` — Drawer heading - `data_side` — `'left'` | `'right'` (default: `'right'`) ### Accordion Collapsible content sections. ```html Content for section 1 Content for section 2 ``` ### ThemeToggle Button to switch between light and dark themes. ```html ``` Automatically: - Toggles `.dark` class on `` - Persists choice to `localStorage` - Syncs all toggle instances --- ## Utility Components ### Loading Loading spinner indicator. ```html ``` **Props:** - `data_size` — `'sm'` | `'md'` | `'lg'` ### Tooltip Hover tooltip (CSS-only). ```html ``` **Props:** - `text` — Tooltip content ### Hint Inline help text or hint. ```html This field is required Invalid input ``` **Props:** - `data_variant` — `'default'` | `'error'` | `'success'` ### ThemeInit Script to initialize theme from localStorage (prevents FOUC). ```html ``` Place in `` before stylesheets. --- ## Form Components `kuzan` styles native HTML form elements via semantic selectors. No component imports needed: ```html
``` Styled elements: - `` — Text, email, password, number, search, tel, url - `