# Organizations and schema

> Multi-tenant auth with one Durable Object per organization

Package: Kyzen
Canonical: https://kuratchi.dev/docs/kyzen/organizations-and-schema
Markdown: https://kuratchi.dev/docs/kyzen/organizations-and-schema.md

`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<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** |

```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<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

```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
<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:

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