Custom store & adapter

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.

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

code
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 and StorageAdapter.

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

code
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