Organizations and schema
Multi-tenant auth with one Durable Object per organization
kyzen can run in two modes:
- Single-tenant — one shared
usersandsessionstable 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
// 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),
});
// 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
// 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<string, any> = {
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 |
// 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;
// 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<string, any>),
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
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
// src/routes/auth/signup/index.koze
<script>
import { signUp } from '@kuratchi/kyzen';
</script>
<form action={signUp} method="POST">
<input type="text" name="organizationName" required />
<input type="text" name="name" />
<input type="email" name="email" required />
<input type="password" name="password" required minlength="8" />
<button type="submit">Create account</button>
</form>
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:
// 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:
- The current authenticated user must have an
organizationId(they have to be signed in to invite). - A placeholder user is created in the org's DO via
stub.createUser()withpasswordHash: null. - An
organizationUsersrow is added on the admin DB so the invitee can later look up which org they belong to. - A token is generated, stored in
emailVerifications, and handed to yoursendInviteEmailcallback.
The invitee clicks the link → routes to acceptInvite:
// src/routes/auth/accept-invite/index.koze
<script>
import { acceptInvite } from '@kuratchi/kyzen';
import { searchParams } from '@kuratchi/koze/request';
const token = searchParams.get('token');
</script>
<form action={acceptInvite} method="POST">
<input type="hidden" name="token" value={token} />
<input type="text" name="name" />
<input type="password" name="password" required minlength="8" />
<button type="submit">Accept invitation</button>
</form>
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.