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
8 changes: 6 additions & 2 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,16 @@ OPENAI_API_KEY=your_openai_api_key_here
# Rate Limiting (Upstash Redis)
# UPSTASH_REDIS_REST_URL="https://your-redis-url.upstash.io"
# UPSTASH_REDIS_REST_TOKEN="your-redis-token"
# RATE_LIMIT_REQUESTS=10
# PRO_RATE_LIMIT_REQUESTS=
# FREE_RATE_LIMIT_REQUESTS=

# Analytics (PostHog)
# NEXT_PUBLIC_POSTHOG_KEY="phc_your_project_key_here"
# NEXT_PUBLIC_POSTHOG_HOST="https://app.posthog.com"

# Convex Service Role Key (Required for securing public functions)
# Generate a secure random string and add it to your Convex environment variables
# CONVEX_SERVICE_ROLE_KEY=
# CONVEX_SERVICE_ROLE_KEY=

# Stripe
# STRIPE_API_KEY=
10 changes: 0 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,3 @@ Enable AI to search the web for up-to-date information:
```env
EXA_API_KEY=your_exa_api_key_here
```

### Rate Limiting

Protect your API with Redis-based rate limiting:

```env
UPSTASH_REDIS_REST_URL="https://your-redis-url.upstash.io"
UPSTASH_REDIS_REST_TOKEN="your-redis-token"
RATE_LIMIT_REQUESTS=10
```
14 changes: 7 additions & 7 deletions app/api/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { systemPrompt } from "@/lib/system-prompt";
import { createTools } from "@/lib/ai/tools";
import { pauseSandbox } from "@/lib/ai/tools/utils/sandbox";
import { generateTitleFromUserMessageWithWriter } from "@/lib/actions";
import { getUserID } from "@/lib/auth/get-user-id";
import { getUserIDAndPro } from "@/lib/auth/get-user-id";
import type { ChatMode, Todo } from "@/types";
import { checkRateLimit } from "@/lib/rate-limit";
import { ChatSDKError } from "@/lib/errors";
Expand Down Expand Up @@ -52,16 +52,16 @@ export async function POST(req: NextRequest) {
regenerate?: boolean;
} = await req.json();

const userID = await getUserID(req);
const { userId, isPro } = await getUserIDAndPro(req);
const userLocation = geolocation(req);

// Check rate limit for the user
await checkRateLimit(userID);
await checkRateLimit(userId, isPro);

// Handle initial chat setup, regeneration, and save user message
const { isNewChat } = await handleInitialChatAndUserMessage({
chatId,
userId: userID,
userId,
messages,
regenerate,
});
Expand All @@ -71,14 +71,14 @@ export async function POST(req: NextRequest) {
const { executionMode, truncatedMessages } = await processChatMessages({
messages,
mode,
userID,
userID: userId,
posthog,
});

const stream = createUIMessageStream({
execute: async ({ writer }) => {
const { tools, getSandbox, getTodoManager } = createTools(
userID,
userId,
writer,
mode,
executionMode,
Expand Down Expand Up @@ -108,7 +108,7 @@ export async function POST(req: NextRequest) {
if (chunk.chunk.type === "tool-call") {
if (posthog) {
posthog.capture({
distinctId: userID,
distinctId: userId,
event: "hackerai-" + chunk.chunk.toolName,
});
}
Expand Down
54 changes: 54 additions & 0 deletions app/api/entitlements/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from "next/server";
import { WorkOS } from "@workos-inc/node";

const workos = new WorkOS(process.env.WORKOS_API_KEY!, {
clientId: process.env.WORKOS_CLIENT_ID!,
});

export async function GET(req: NextRequest) {
try {
// Get the session cookie
const sessionCookie = req.cookies.get("wos-session")?.value;

if (!sessionCookie) {
return NextResponse.json(
{ error: "No session cookie found" },
{ status: 401 },
);
}

// Load the original session
const session = workos.userManagement.loadSealedSession({
cookiePassword: process.env.WORKOS_COOKIE_PASSWORD!,
sessionData: sessionCookie,
});

const refreshResult = await session.refresh();
const { sealedSession, entitlements } = refreshResult as any;

const hasProPlan = (entitlements || []).includes("pro-monthly-plan");

// Create response with entitlements
const response = NextResponse.json({
entitlements: entitlements || [],
hasProPlan,
});

// Set the updated refresh session data in a cookie
if (sealedSession) {
response.cookies.set("wos-session", sealedSession, {
httpOnly: true,
sameSite: "lax",
secure: true,
});
}

return response;
} catch (error) {
console.error("💥 [Entitlements API] Error refreshing session:", error);
return NextResponse.json(
{ error: "Failed to refresh session" },
{ status: 500 },
);
}
}
7 changes: 7 additions & 0 deletions app/api/stripe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string, {
apiVersion: "2025-08-27.basil",
});

export { stripe };
140 changes: 140 additions & 0 deletions app/api/subscribe/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { stripe } from "../stripe";
import { workos } from "../workos";
import { getUserID } from "@/lib/auth/get-user-id";
import { NextRequest, NextResponse } from "next/server";

export const POST = async (req: NextRequest) => {
try {
// Get user ID from authenticated session
const userId = await getUserID(req);

// Get user details from WorkOS to use email as organization name
const user = await workos.userManagement.getUser(userId);
const orgName = user.email;
const subscriptionLevel = "pro-monthly-plan";

// Check if user already has an organization
const existingMemberships =
await workos.userManagement.listOrganizationMemberships({
userId,
});

let organization;

if (existingMemberships.data && existingMemberships.data.length > 0) {
// User already has an organization, use the first one
const membership = existingMemberships.data[0];
organization = await workos.organizations.getOrganization(
membership.organizationId,
);
} else {
// Create new organization for the user
organization = await workos.organizations.createOrganization({
name: orgName,
});

await workos.userManagement.createOrganizationMembership({
organizationId: organization.id,
userId,
roleSlug: "admin",
});
}

// Retrieve price ID from Stripe
// The Stripe look up key for the price *must* be the same as the subscription level string
let price;

try {
price = await stripe.prices.list({
lookup_keys: [subscriptionLevel],
});

// Check if price data exists and has at least one item
if (!price.data || price.data.length === 0) {
console.error(
`No price found for lookup key: ${subscriptionLevel}. This is likely because the products and prices have not been created yet. Run the setup script \`pnpm run setup\` to automatically create them.`,
);
return NextResponse.json(
{
error: "Subscription plan not found",
details: `No price found for plan: ${subscriptionLevel}`,
},
{ status: 404 },
);
}
} catch (error) {
console.error(
`Error retrieving price from Stripe for lookup key: ${subscriptionLevel}. This is likely because the products and prices have not been created yet. Run the setup script \`pnpm run setup\` to automatically create them.`,
error,
);
return NextResponse.json(
{ error: "Error retrieving price from Stripe" },
{ status: 500 },
);
}

// Check if organization already has a Stripe customer
let customer;

// Try to find existing customer by email and organization metadata
const existingCustomers = await stripe.customers.list({
email: user.email,
limit: 10, // Get more to check metadata
});

// Look for a customer with matching organization ID in metadata
const matchingCustomer = existingCustomers.data.find(
(c) => c.metadata.workOSOrganizationId === organization.id,
);

if (matchingCustomer) {
customer = matchingCustomer;
}

if (!customer) {
// Create new Stripe customer
customer = await stripe.customers.create({
email: user.email,
metadata: {
workOSOrganizationId: organization.id,
},
});

// Update WorkOS organization with Stripe customer ID
// This will allow WorkOS to automatically add entitlements to the access token
await workos.organizations.updateOrganization({
organization: organization.id,
stripeCustomerId: customer.id,
});
}

const baseUrl = process.env.NEXT_PUBLIC_BASE_URL;
if (!baseUrl) {
return NextResponse.json(
{ error: "NEXT_PUBLIC_BASE_URL is not configured" },
{ status: 500 },
);
}

const session = await stripe.checkout.sessions.create({
customer: customer.id,
billing_address_collection: "auto",
line_items: [
{
price: price.data[0].id,
quantity: 1,
},
],
mode: "subscription",
success_url: `${process.env.NEXT_PUBLIC_BASE_URL}`,
cancel_url: `${process.env.NEXT_PUBLIC_BASE_URL}`,
});

return NextResponse.json({ url: session.url });
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : "An error occurred";
console.error(errorMessage, error);
return NextResponse.json({ error: errorMessage }, { status: 500 });
}
};
7 changes: 7 additions & 0 deletions app/api/workos.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { WorkOS } from "@workos-inc/node";

const workos = new WorkOS(process.env.WORKOS_API_KEY, {
clientId: process.env.WORKOS_CLIENT_ID,
});

export { workos };
Loading