Skip to content
Merged
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
87 changes: 86 additions & 1 deletion convex/http.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,28 @@
import { httpRouter } from "convex/server";
import { httpRouter, ROUTABLE_HTTP_METHODS } from "convex/server";
import { httpAction } from "./_generated/server";
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter, createContext } from './trpc/router';
import { autumnHandler } from "autumn-js/convex";

// Clerk JWT verification (duplicated from tRPC router for HTTP identify)
async function verifyClerkJwt(token: string): Promise<{ id: string; email?: string } | null> {
try {
const issuer = process.env.CLERK_JWT_ISSUER_DOMAIN;
if (!issuer) throw new Error('Missing CLERK_JWT_ISSUER_DOMAIN');
const audience = process.env.CLERK_JWT_AUDIENCE;
const { verifyToken } = await import('@clerk/backend');
const options: { jwtKey?: string; audience?: string } = { jwtKey: issuer };
if (audience) options.audience = audience;
const verified = (await verifyToken(token, options)) as { sub?: string; email?: string };
const sub = verified.sub;
const email = verified.email;
if (!sub) return null;
return { id: sub, email };
} catch (error) {
console.error('verifyClerkJwt failed', error);
return null;
}
}

// HTTP action to handle tRPC requests
const trpcHandler = httpAction(async (ctx, request) => {
Expand Down Expand Up @@ -45,4 +66,68 @@ http.route({
handler: trpcHandler,
});

// Simple in-memory rate limiter per token (limits Autumn identify calls)
const identifyRateBuckets = new Map<string, { tokens: number; lastRefill: number }>();
const IDENTIFY_LIMIT = 30; // 30 req/min per token
const IDENTIFY_REFILL_MS = 60_000;

function allowIdentify(tokenKey: string | undefined): boolean {
const key = tokenKey || 'anon';
const now = Date.now();
const bucket = identifyRateBuckets.get(key) || { tokens: IDENTIFY_LIMIT, lastRefill: now };
if (now - bucket.lastRefill >= IDENTIFY_REFILL_MS) {
bucket.tokens = IDENTIFY_LIMIT;
bucket.lastRefill = now;
}
if (bucket.tokens <= 0) {
identifyRateBuckets.set(key, bucket);
return false;
}
bucket.tokens -= 1;
identifyRateBuckets.set(key, bucket);
return true;
}

// Initialize Autumn handler for Convex HTTP routes
const autumn = autumnHandler({
httpAction,
identify: async ({ request }) => {
const authHeader = request.headers.get('authorization') || request.headers.get('Authorization');
const token = typeof authHeader === 'string' && authHeader.toLowerCase().startsWith('bearer ')
? authHeader.slice(7).trim()
: undefined;

if (!allowIdentify(token)) {
return { customerId: undefined, customerData: {} };
}

let customerId: string | undefined;
let email: string | undefined;

if (token) {
const verified = await verifyClerkJwt(token);
if (verified) {
customerId = verified.id;
email = verified.email;
}
}

return {
customerId,
customerData: {
email,
},
};
},
});

// Route all /api/autumn/* requests to the Autumn handler for every routable method
for (const method of ROUTABLE_HTTP_METHODS) {
http.route({
pathPrefix: "/api/autumn/",
method,
handler: autumn,
});
}

export default http;
22 changes: 21 additions & 1 deletion convex/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Id } from "./_generated/dataModel";
import { enforceRateLimit } from "./rateLimit";
import { enforceAIRateLimit } from "./aiRateLimit";
import DOMPurify from 'dompurify';
import { Autumn as autumn } from 'autumn-js';

// Security utility functions
const generateSecureToken = async (length: number): Promise<string> => {
Expand Down Expand Up @@ -250,7 +251,18 @@ export const createMessage = mutation({
if (!chat || chat.userId !== identity.subject) {
throw new Error("Chat not found or access denied");
}


// Autumn feature gate: limit user message sends by plan
if (args.role === "user") {
const { data } = await autumn.check({
customer_id: identity.subject,
feature_id: "messages",
});
if (!data?.allowed) {
throw new Error("No more messages for your current plan");
}
}

// Rate limiting
await enforceRateLimit(ctx, "sendMessage");

Expand Down Expand Up @@ -324,6 +336,14 @@ export const createMessage = mutation({
}

const messageId = await ctx.db.insert("messages", messageData);

// Track usage in Autumn after successful user message
if (args.role === "user") {
await autumn.track({
customer_id: identity.subject,
feature_id: "messages",
});
}

// Update the chat's updatedAt timestamp
await ctx.db.patch(args.chatId, {
Expand Down
8 changes: 8 additions & 0 deletions env-template.txt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ STRIPE_PRICE_PRO_YEAR=price_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_ENTERPRISE_MONTH=price_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
STRIPE_PRICE_ENTERPRISE_YEAR=price_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

# === Autumn Billing (Autumn.js) ===
# Secret server key for Autumn API (backend-only)
AUTUMN_SECRET_KEY=am_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Frontend provider backend URL for Autumn endpoints (use your Convex URL)
VITE_AUTUMN_BACKEND_URL=https://xxxxx.convex.cloud
# Optional: Override product ids used on the frontend
VITE_AUTUMN_PRODUCT_PRO_ID=pro

# Removed Polar billing configuration

# === REQUIRED for server-side fetches to own APIs ===
Expand Down
Loading
Loading