Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,18 @@ AI_CHAT_TOOLS=getCurrentDateTime,calculate
AI_CHAT_MAX_STEPS=5
AI_CHAT_RATE_LIMIT_MAX_REQUESTS=20
AI_CHAT_RATE_LIMIT_WINDOW_MS=60000
AI_CHAT_RATE_LIMIT_MAX_REQUESTS_FREE=20
AI_CHAT_RATE_LIMIT_WINDOW_MS_FREE=60000
AI_CHAT_RATE_LIMIT_MAX_REQUESTS_ORGANIZATIONS=60
AI_CHAT_RATE_LIMIT_WINDOW_MS_ORGANIZATIONS=60000

# Lifetime per-account user message cap (boilerplate abuse protection)
AI_CHAT_ENFORCE_LIFETIME_MESSAGE_LIMIT=true

# Used when enforcement above is true.
AI_CHAT_LIFETIME_MESSAGE_LIMIT=1
AI_CHAT_LIFETIME_MESSAGE_LIMIT_FREE=1
AI_CHAT_LIFETIME_MESSAGE_LIMIT_ORGANIZATIONS=500

# Convex chat history persistence (set AI_CHAT_HISTORY_ENABLED=false to disable)
AI_CHAT_HISTORY_ENABLED=true
Expand All @@ -47,7 +53,19 @@ AI_CHAT_HISTORY_MAX_MESSAGES_PER_THREAD=120
AI_CHAT_HISTORY_MAX_THREADS=50
AI_CHAT_HISTORY_THREAD_TITLE_MAX_LENGTH=80
AI_CHAT_HISTORY_QUERY_LIMIT=200
AI_CHAT_HIST_MAX_MSGS_THREAD_FREE=120
AI_CHAT_HIST_MAX_MSGS_THREAD_ORGS=240
AI_CHAT_HIST_MAX_THREADS_FREE=50
AI_CHAT_HIST_MAX_THREADS_ORGS=200

# File limits by organization plan
FILE_MAX_FILE_SIZE_BYTES_FREE=10485760
FILE_MAX_FILE_SIZE_BYTES_ORGANIZATIONS=26214400
FILE_MAX_FILES_PER_USER_FREE=100
FILE_MAX_FILES_PER_USER_ORGANIZATIONS=500

# File upload image rate limiting (set either to 0 to disable)
FILE_IMAGE_UPLOAD_RATE_LIMIT_MAX_UPLOADS=10
FILE_IMAGE_UPLOAD_RATE_LIMIT_WINDOW_MS=60000
FILE_IMG_RL_MAX_UPLOADS_FREE=10
FILE_IMG_RL_MAX_UPLOADS_ORGS=30
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
# Shipr
# Shipr (Multi-tenant Variant)

A production-ready tool for shipping SaaS fast.
This branch (`feat/multi-tenancy`) is a dedicated multi-tenant version of Shipr.

- Tenant boundary: Clerk Organizations
- Backend data isolation: Convex organization-scoped records
- Billing scope: organization plan checks
- Access model: role/permission scaffold (`org:admin`, `org:member`)

This branch is intended for SaaS products that require B2B multi-tenancy and is maintained separately from `master`.

Important: this variant uses a fresh Convex baseline for tenant-scoped `files` and `chat` data. Do not deploy it over existing single-tenant datasets from `master` without clearing/backfilling those tables first.

See `/docs/multi-tenancy.md` for architecture and implementation details.
2 changes: 2 additions & 0 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import type * as chat from "../chat.js";
import type * as files from "../files.js";
import type * as lib_auth from "../lib/auth.js";
import type * as users from "../users.js";

import type {
Expand All @@ -21,6 +22,7 @@ import type {
declare const fullApi: ApiFromModules<{
chat: typeof chat;
files: typeof files;
"lib/auth": typeof lib_auth;
users: typeof users;
}>;

Expand Down
107 changes: 62 additions & 45 deletions convex/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,33 @@ import { v } from "convex/values";
import { mutation, query } from "./_generated/server";
import type { Id } from "./_generated/dataModel";
import type { MutationCtx, QueryCtx } from "./_generated/server";
import { chatHistoryConfig } from "../src/lib/ai/chat-history-config";
import {
chatHistoryConfig,
getChatHistoryLimitsForPlan,
} from "../src/lib/ai/chat-history-config";
import { ORG_PERMISSIONS } from "../src/lib/auth/rbac";
import {
requireCurrentUser,
resolveOrganizationBillingPlanForUser,
requireOrgPermission,
type OrganizationAuthContext,
} from "./lib/auth";

type ChatCtx = QueryCtx | MutationCtx;
const DEFAULT_THREAD_TITLE = "New chat";

async function requireCurrentUser(ctx: ChatCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthorized: authentication required");
}

const user = await ctx.db
.query("users")
.withIndex("by_clerk_id", (q) => q.eq("clerkId", identity.subject))
.first();

if (!user) {
throw new Error("User not found");
}

return user;
}

async function requireOwnedThread(
async function requireOrganizationThread(
ctx: ChatCtx,
userId: Id<"users">,
auth: OrganizationAuthContext,
threadId: Id<"chatThreads">,
) {
const thread = await ctx.db.get(threadId);
if (!thread) {
throw new Error("Chat thread not found");
}

if (thread.userId !== userId) {
throw new Error("Forbidden: you do not own this chat thread");
if (thread.orgId !== auth.orgId) {
throw new Error("Forbidden: thread belongs to another organization");
}

return thread;
Expand All @@ -54,38 +46,43 @@ function normalizeThreadTitle(raw: string): string {
async function enforceThreadMessageBound(
ctx: MutationCtx,
threadId: Id<"chatThreads">,
maxMessagesPerThread: number,
) {
while (true) {
const messages = await ctx.db
.query("chatMessages")
.withIndex("by_thread_id", (q) => q.eq("threadId", threadId))
.order("asc")
.take(chatHistoryConfig.maxMessagesPerThread + 1);
.take(maxMessagesPerThread + 1);

if (messages.length <= chatHistoryConfig.maxMessagesPerThread) {
if (messages.length <= maxMessagesPerThread) {
return;
}

const overflow = messages.length - chatHistoryConfig.maxMessagesPerThread;
const overflow = messages.length - maxMessagesPerThread;
await Promise.all(
messages.slice(0, overflow).map((message) => ctx.db.delete(message._id)),
);
}
}

async function enforceThreadCountBound(ctx: MutationCtx, userId: Id<"users">) {
async function enforceThreadCountBound(
ctx: MutationCtx,
orgId: string,
maxThreadsPerWorkspace: number,
) {
while (true) {
const threads = await ctx.db
.query("chatThreads")
.withIndex("by_user_id", (q) => q.eq("userId", userId))
.withIndex("by_org_id_last_message", (q) => q.eq("orgId", orgId))
.order("asc")
.take(chatHistoryConfig.maxThreadsPerUser + 1);
.take(maxThreadsPerWorkspace + 1);

if (threads.length <= chatHistoryConfig.maxThreadsPerUser) {
if (threads.length <= maxThreadsPerWorkspace) {
return;
}

const overflow = threads.length - chatHistoryConfig.maxThreadsPerUser;
const overflow = threads.length - maxThreadsPerWorkspace;
const threadsToDelete = threads.slice(0, overflow);
for (const thread of threadsToDelete) {
const threadMessages = await ctx.db
Expand All @@ -98,23 +95,24 @@ async function enforceThreadCountBound(ctx: MutationCtx, userId: Id<"users">) {
}
}

export const listUserChatThreads = query({
export const listOrganizationChatThreads = query({
args: {},
handler: async (ctx) => {
if (!chatHistoryConfig.enabled) {
return [];
}

let user;
let auth;
try {
user = await requireCurrentUser(ctx);
({ auth } = await requireCurrentUser(ctx));
requireOrgPermission(auth, ORG_PERMISSIONS.CHAT_READ);
} catch {
return [];
}

return await ctx.db
.query("chatThreads")
.withIndex("by_user_id_last_message", (q) => q.eq("userId", user._id))
.withIndex("by_org_id_last_message", (q) => q.eq("orgId", auth.orgId))
.order("desc")
.take(chatHistoryConfig.queryLimit);
},
Expand All @@ -129,15 +127,24 @@ export const createChatThread = mutation({
return null;
}

const user = await requireCurrentUser(ctx);
const { auth, user } = await requireCurrentUser(ctx);
requireOrgPermission(auth, ORG_PERMISSIONS.CHAT_CREATE);
const orgPlan = resolveOrganizationBillingPlanForUser({ auth });
const historyLimits = getChatHistoryLimitsForPlan(orgPlan);

const now = Date.now();
const threadId = await ctx.db.insert("chatThreads", {
userId: user._id,
orgId: auth.orgId,
createdByUserId: user._id,
title: normalizeThreadTitle(args.title ?? DEFAULT_THREAD_TITLE),
lastMessageAt: now,
});

await enforceThreadCountBound(ctx, user._id);
await enforceThreadCountBound(
ctx,
auth.orgId,
historyLimits.maxThreadsPerWorkspace,
);
return threadId;
},
});
Expand All @@ -149,14 +156,15 @@ export const getThreadMessages = query({
return [];
}

let user;
let auth;
try {
user = await requireCurrentUser(ctx);
({ auth } = await requireCurrentUser(ctx));
requireOrgPermission(auth, ORG_PERMISSIONS.CHAT_READ);
} catch {
return [];
}

await requireOwnedThread(ctx, user._id, args.threadId);
await requireOrganizationThread(ctx, auth, args.threadId);

const messages = await ctx.db
.query("chatMessages")
Expand All @@ -179,8 +187,12 @@ export const saveUserChatMessage = mutation({
return null;
}

const user = await requireCurrentUser(ctx);
const thread = await requireOwnedThread(ctx, user._id, args.threadId);
const { auth, user } = await requireCurrentUser(ctx);
requireOrgPermission(auth, ORG_PERMISSIONS.CHAT_CREATE);
const orgPlan = resolveOrganizationBillingPlanForUser({ auth });
const historyLimits = getChatHistoryLimitsForPlan(orgPlan);

const thread = await requireOrganizationThread(ctx, auth, args.threadId);
const content = args.content.trim();

if (!content) {
Expand All @@ -194,7 +206,8 @@ export const saveUserChatMessage = mutation({
}

const insertedId = await ctx.db.insert("chatMessages", {
userId: user._id,
orgId: auth.orgId,
createdByUserId: user._id,
threadId: args.threadId,
role: args.role,
content,
Expand All @@ -206,7 +219,11 @@ export const saveUserChatMessage = mutation({
await ctx.db.patch(args.threadId, { title: normalizeThreadTitle(content) });
}

await enforceThreadMessageBound(ctx, args.threadId);
await enforceThreadMessageBound(
ctx,
args.threadId,
historyLimits.maxMessagesPerThread,
);

return insertedId;
},
Expand Down
Loading