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