Organizations and schema | Kyzen | Primitives Docs

Organizations and schema

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

// 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.tsORG_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:

  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:

// 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.