Custom store & adapter
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.
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:
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
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
getConversationreturnsnullfor a missing conversation AND for one owned by a different user — the two cases must be indistinguishable. Never return another user’s row.ensureConversationthrowsConversationOwnershipError(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.listMessagesscopes to the bound user. A foreignconversationIdreturns[], not the other user’s messages.saveTurnverifies ownership independently — defence in depth even though the handler already calledensureConversation. Idempotent on message id: a message whose id already exists is skipped, not duplicated.listMessagesclampslimitto 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.
uploadandresignreturn expiring signed URLs only. - User-namespaced paths. Derive the storage path from the bound
userId; never let callers inject a path.resignandremovemust verify the path belongs to the bound user’s namespace before acting on it. resignreturnsnull(never throws) when the object is missing or outside the namespace — one broken blob must not take down a conversation history load.removeis idempotent. Deleting a missing object is a no-op.
Example skeleton
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
ChatStorereference — every method signature and its exact contract.StorageAdapterreference — the three security rules and every method.- Security model — the threat the invariants protect against.