---
title: "Custom store & adapter"
description: "Implement ChatStore and StorageAdapter yourself — Prisma, DynamoDB, S3/R2, or any backend — while preserving the same security guarantees."
---

The `ChatStore` and `StorageAdapter` interfaces are the complete persistence
contract. Any implementation that satisfies them — Prisma, DynamoDB, S3/R2,
a test double — is interchangeable with the built-in Drizzle and Supabase
defaults. Same handler, same security model.

## The factory shape

Both contracts follow the same pattern: a factory function that accepts the
server-verified `userId` and returns a bound instance.

```ts
import type { ChatStore, ChatStoreFactory } from '@mordn/chat-widget/server';
import type { StorageAdapter, StorageAdapterFactory } from '@mordn/chat-widget/server';

const myStore: ChatStoreFactory = (userId) => ({
  userId,

  // Conversations
  listConversations: async () => { /* … */ },
  getConversation: async (id) => { /* … */ },
  ensureConversation: async (id, init) => { /* … */ },
  renameConversation: async (id, title) => { /* … */ },
  deleteConversation: async (id) => { /* … */ },

  // Messages
  listMessages: async (conversationId, opts) => { /* … */ },
  saveTurn: async (input) => { /* … */ },
});
```

Pass the factory — not an instance — to `createChatHandler`:

```ts
export const { GET, POST, DELETE } = createChatHandler({
  getUserId: getChatUserId,
  model: anthropic('claude-sonnet-4-5'),
  store: myStore,
  storage: myStorage, // optional; omit to disable uploads
});
```

## Why no `userId` on the methods

> **The factory shape is the security model**
>
> Each factory is called **after** the handler has authenticated the request and
> derived `userId` from the server session. The resulting instance is bound to
> exactly one user for the lifetime of that request. None of the methods accept a
> `userId` parameter — there is no argument slot through which a foreign id could
> enter. "Fetch user B's data while acting as user A" is unrepresentable at the
> type level.
>
> If you add a `userId` parameter to any method, or read identity from request
> input inside a method, you reintroduce the IDOR the design prevents. Treat
> `userId` at construction as a trusted server secret, not as user-supplied
> input.

## Invariants you must uphold

These are load-bearing security and correctness contracts, not advisory
guidelines. The full signatures and per-method contracts are at
[`ChatStore`](/docs/api/chat-store) and
[`StorageAdapter`](/docs/api/storage-adapter).

### `ChatStore` invariants

- **`getConversation`** returns `null` for a missing conversation AND for one
  owned by a different user — the two cases must be indistinguishable. Never
  return another user's row.
- **`ensureConversation`** throws `ConversationOwnershipError` (imported from
  `@mordn/chat-widget/server`) if the id exists but belongs to a different
  user. This is the single chokepoint that makes "write into someone else's
  conversation" impossible.
- **`listMessages`** scopes to the bound user. A foreign `conversationId`
  returns `[]`, not the other user's messages.
- **`saveTurn`** verifies ownership independently — defence in depth even
  though the handler already called `ensureConversation`. Idempotent on message
  id: a message whose id already exists is skipped, not duplicated.
- **`listMessages`** clamps `limit` to a server-side ceiling (max 100 is
  conventional) so a hostile client cannot request an unbounded page.

### `StorageAdapter` invariants

- **Private at rest.** Never write to a public bucket or return a permanent
  public URL.
- **Signed, short-lived reads.** `upload` and `resign` return expiring signed
  URLs only.
- **User-namespaced paths.** Derive the storage path from the bound `userId`;
  never let callers inject a path. `resign` and `remove` must verify the path
  belongs to the bound user's namespace before acting on it.
- **`resign` returns `null`** (never throws) when the object is missing or
  outside the namespace — one broken blob must not take down a conversation
  history load.
- **`remove` is idempotent.** Deleting a missing object is a no-op.

## Example skeleton

```ts title="lib/my-chat-store.ts"
import {
  ConversationOwnershipError,
  type ChatStore,
  type ChatStoreFactory,
} from '@mordn/chat-widget/server';

export const myStore: ChatStoreFactory = (userId) => ({
  userId,

  async listConversations() {
    return db.conversations.findMany({
      where: { userId },
      orderBy: { updatedAt: 'desc' },
    });
  },

  async getConversation(id) {
    const row = await db.conversations.findFirst({ where: { id, userId } });
    return row ?? null; // null for missing OR foreign — same result
  },

  async ensureConversation(id, init) {
    const existing = await db.conversations.findFirst({ where: { id } });
    if (existing && existing.userId !== userId) {
      throw new ConversationOwnershipError(id);
    }
    return db.conversations.upsert({
      where: { id },
      create: { id, userId, title: init?.title ?? 'New Chat' },
      update: {},
    });
  },

  async renameConversation(id, title) {
    await db.conversations.updateMany({ where: { id, userId }, data: { title } });
  },

  async deleteConversation(id) {
    const deleted = await db.conversations.deleteMany({ where: { id, userId } });
    return deleted.count > 0;
  },

  async listMessages(conversationId, opts) {
    const owned = await this.getConversation(conversationId);
    if (!owned) return [];
    const limit = Math.min(opts?.limit ?? 100, 100);
    return db.messages.findMany({ where: { conversationId }, take: limit });
  },

  async saveTurn({ conversationId, messages, model }) {
    const owned = await this.getConversation(conversationId);
    if (!owned) throw new ConversationOwnershipError(conversationId);
    for (const msg of messages) {
      await db.messages.upsert({
        where: { id: msg.id },
        create: { ...msg, conversationId, model: msg.role === 'assistant' ? model : null },
        update: {}, // idempotent — existing id wins
      });
    }
    await db.conversations.update({ where: { id: conversationId }, data: { updatedAt: new Date() } });
  },
});
```

## Next steps

- [`ChatStore` reference](/docs/api/chat-store) — every method signature and
  its exact contract.
- [`StorageAdapter` reference](/docs/api/storage-adapter) — the three security
  rules and every method.
- [Security model](/docs/security) — the threat the invariants protect against.
