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
32 changes: 29 additions & 3 deletions cli/src/commands/pull.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import prompts from "prompts";
import { api, ApiRequestError, Blueprint } from "../api.js";
import { isAuthenticated } from "../config.js";
import { writeFile, mkdir, readFile } from "fs/promises";
import { join, dirname } from "path";
import { dirname, resolve, relative } from "path";
import { existsSync } from "fs";
import {
trackBlueprint,
Expand All @@ -19,6 +19,19 @@ interface PullOptions {
track?: boolean; // Track the blueprint for future syncs (default: true)
}

/**
* Resolve a path under a root directory, rejecting traversal attempts.
* Throws if the resolved path escapes the root.
*/
function safePath(root: string, untrusted: string): string {
const resolved = resolve(root, untrusted);
const rel = relative(root, resolved);
if (rel.startsWith("..") || resolve(root, rel) !== resolved) {
throw new Error(`Path traversal blocked: ${untrusted}`);
}
return resolved;
}

// Mapping of blueprint types to filenames
const TYPE_TO_FILENAME: Record<string, string> = {
AGENTS_MD: "AGENTS.md",
Expand Down Expand Up @@ -135,7 +148,14 @@ async function pullHierarchy(
for (const bp of blueprints) {
// Determine output path - use repository_path if available
const filename = bp.repository_path || TYPE_TO_FILENAME[bp.type] || "ai-config.md";
const outputPath = join(options.output, filename);
let outputPath: string;
try {
outputPath = safePath(resolve(options.output), filename);
} catch {
console.log(chalk.red(` ✗ Skipped (invalid path): ${filename}`));
skipped++;
continue;
}

// Check if file exists
if (existsSync(outputPath) && !options.yes) {
Expand Down Expand Up @@ -290,7 +310,13 @@ async function pullBlueprint(

// Determine output filename - use repository_path if available for hierarchy blueprints
const filename = blueprint.repository_path || TYPE_TO_FILENAME[blueprint.type] || "ai-config.md";
const outputPath = join(options.output, filename);
let outputPath: string;
try {
outputPath = safePath(resolve(options.output), filename);
} catch {
console.error(chalk.red(`✗ Invalid file path: ${filename}`));
process.exit(1);
}

// Check if file exists and show diff preview
let localContent: string | null = null;
Expand Down
104 changes: 77 additions & 27 deletions src/app/api/auth/sso/callback/oidc/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { createHmac } from "crypto";
import { createHmac, randomUUID } from "crypto";
import { prismaUsers } from "@/lib/db-users";
import { ENABLE_SSO } from "@/lib/feature-flags";
import { decryptSSOConfig } from "@/lib/sso-encryption";
Expand Down Expand Up @@ -57,7 +57,7 @@ export async function GET(request: NextRequest) {
// Find team and SSO config
const team = await prismaUsers.team.findUnique({
where: { slug: teamSlug },
include: { ssoConfig: true },
select: { id: true, slug: true, maxSeats: true, ssoConfig: true },
});

if (!team?.ssoConfig || !team.ssoConfig.enabled || team.ssoConfig.provider !== "OIDC") {
Expand Down Expand Up @@ -147,31 +147,68 @@ export async function GET(request: NextRequest) {
}
}

// Find or create user, then add to team
let user = await prismaUsers.user.findUnique({ where: { email } });

if (!user) {
user = await prismaUsers.user.create({
data: {
email,
name,
},
});
// Atomically find-or-create user and enforce seat limit in a serializable transaction
// to prevent concurrent SSO logins from exceeding maxSeats.
// Retry up to 3 times on serialization conflicts (Prisma P2034).
const MAX_RETRIES = 3;
let user: { id: string; email: string | null; name: string | null } | undefined;

for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
let seatLimitReached = false;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
user = await prismaUsers.$transaction(async (tx: any) => {
let u = await tx.user.findUnique({ where: { email } });
const existingMembership = u
? await tx.teamMember.findUnique({
where: { teamId_userId: { teamId: team.id, userId: u.id } },
})
: null;

if (!existingMembership) {
const currentMembers = await tx.teamMember.count({
where: { teamId: team.id },
});
if (currentMembers >= team.maxSeats) {
seatLimitReached = true;
throw new Error("SEAT_LIMIT_REACHED");
}
}

if (!u) {
u = await tx.user.create({ data: { email, name } });
}

if (!existingMembership) {
await tx.teamMember.create({
data: { teamId: team.id, userId: u.id, role: "MEMBER" },
});
}

return u;
}, { isolationLevel: "Serializable" });
break; // Success — exit retry loop
} catch (err) {
if (seatLimitReached) {
return NextResponse.redirect(
new URL("/auth/signin?error=SSOSeatLimitReached", request.url)
);
}
// P2034 = serialization failure, P2002 = unique constraint (concurrent insert)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const code = (err as any)?.code;
if ((code === "P2034" || code === "P2002") && attempt < MAX_RETRIES - 1) {
continue;
}
throw err;
}
}

// Ensure user is a member of the team
const existingMembership = await prismaUsers.teamMember.findUnique({
where: { teamId_userId: { teamId: team.id, userId: user.id } },
});

if (!existingMembership) {
await prismaUsers.teamMember.create({
data: {
teamId: team.id,
userId: user.id,
role: "MEMBER",
},
});
// If we get here without user, all retries failed with serialization conflicts
if (!user) {
return NextResponse.redirect(
new URL("/auth/signin?error=SSOTemporaryError", request.url)
);
}

// Update last SSO sync
Expand All @@ -180,15 +217,28 @@ export async function GET(request: NextRequest) {
data: { lastSyncAt: new Date() },
});

// Generate a one-time nonce for the SSO completion handoff
const nonce = randomUUID();
const expires = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes

await prismaUsers.verificationToken.create({
data: {
identifier: `sso:${user.id}`,
token: nonce,
expires,
},
});

// Redirect to the SSO sign-in completion page
// This page triggers NextAuth signIn() with the SSO credentials
const completeUrl = new URL("/auth/sso-complete", baseUrl);
completeUrl.searchParams.set("userId", user.id);
completeUrl.searchParams.set("email", email);
completeUrl.searchParams.set("teamId", team.id);
completeUrl.searchParams.set("nonce", nonce);
completeUrl.searchParams.set("callbackUrl", callbackUrl);
// Sign the params to prevent tampering
const signData = `${user.id}:${email}:${team.id}`;
// Sign all params including nonce to prevent tampering
const signData = `${user.id}:${email}:${team.id}:${nonce}`;
const signature = createHmac("sha256", secret).update(signData).digest("hex");
completeUrl.searchParams.set("sig", signature);

Expand Down
20 changes: 11 additions & 9 deletions src/app/api/billing/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,22 @@ export async function GET() {
);
}

const teamMembership = user.teamMemberships[0];
const isAdmin = user.role === "ADMIN" || user.role === "SUPERADMIN";
const teams = user.teamMemberships.map((m) => ({
id: m.team.id,
name: m.team.name,
slug: m.team.slug,
logo: m.team.logo,
role: m.role,
}));

return NextResponse.json({
plan: "free",
isAdmin,
isTeamsUser: !!teamMembership,
team: teamMembership ? {
id: teamMembership.team.id,
name: teamMembership.team.name,
slug: teamMembership.team.slug,
logo: teamMembership.team.logo,
role: teamMembership.role,
} : null,
isTeamsUser: teams.length > 0,
// Keep backward-compat: "team" returns first membership
team: teams[0] ?? null,
teams,
});
} catch (error) {
console.error("Error fetching subscription status:", error);
Expand Down
60 changes: 47 additions & 13 deletions src/app/api/blueprints/[id]/download/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export async function POST(
{ params }: { params: Promise<{ id: string }> }
) {
const session = await getServerSession(authOptions);
const { id } = await params;
const { id: rawId } = await params;

try {
const body = await request.json().catch(() => ({}));
Expand All @@ -30,8 +30,11 @@ export async function POST(
}
}

// Determine template type from ID prefix
const templateType = id.startsWith("sys_") ? "system" : "user";
// Determine template type from ID prefix and strip prefix for DB queries
const templateType = rawId.startsWith("sys_") ? "system" : "user";
const dbId = rawId.startsWith("bp_") ? rawId.slice(3)
: rawId.startsWith("sys_") ? rawId.slice(4)
: rawId;

// Deduplicate: check for recent download from same IP + template in the last hour
const clientIP = request.headers.get("cf-connecting-ip") ||
Expand All @@ -43,7 +46,7 @@ export async function POST(

const recentDownload = await prismaUsers.templateDownload.findFirst({
where: {
templateId: id,
templateId: rawId,
templateType,
ipHash,
createdAt: { gte: oneHourAgo },
Expand All @@ -60,22 +63,22 @@ export async function POST(
await prismaUsers.templateDownload.create({
data: {
userId: session?.user?.id || null,
templateId: id,
templateId: rawId,
templateType,
platform,
ipHash,
},
});

// Increment downloads count on template
// Increment downloads count on template — use dbId (without prefix)
if (templateType === "system") {
await prismaApp.systemTemplate.update({
where: { id },
where: { id: dbId },
data: { downloads: { increment: 1 } },
});
} else {
await prismaUsers.userTemplate.update({
where: { id },
where: { id: dbId },
data: { downloads: { increment: 1 } },
});
}
Expand All @@ -93,14 +96,45 @@ export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const templateType = id.startsWith("sys_") ? "system" : "user";
const { id: rawId } = await params;
const templateType = rawId.startsWith("sys_") ? "system" : "user";
const dbId = rawId.startsWith("bp_") ? rawId.slice(3)
: rawId.startsWith("sys_") ? rawId.slice(4)
: rawId;

try {
// Get total downloads
// For user blueprints, verify the requester owns it or it's public
if (templateType === "user") {
const template = await prismaUsers.userTemplate.findUnique({
where: { id: dbId },
select: { visibility: true, userId: true, teamId: true },
});
if (!template) {
return NextResponse.json({ total: 0, byPlatform: {} });
}
if (template.visibility !== "PUBLIC") {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const isOwner = template.userId === session.user.id;
let isTeamMember = false;
if (template.teamId) {
const membership = await prismaUsers.teamMember.findUnique({
where: { teamId_userId: { teamId: template.teamId, userId: session.user.id } },
});
isTeamMember = !!membership;
}
if (!isOwner && !isTeamMember) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
}
}

// Get total downloads — templateDownload stores the raw ID (with bp_ prefix)
const downloadCount = await prismaUsers.templateDownload.count({
where: {
templateId: id,
templateId: rawId,
templateType,
},
});
Expand All @@ -109,7 +143,7 @@ export async function GET(
const platformStats = await prismaUsers.templateDownload.groupBy({
by: ["platform"],
where: {
templateId: id,
templateId: rawId,
templateType,
},
_count: true,
Expand Down
10 changes: 9 additions & 1 deletion src/app/api/blueprints/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { posix as posixPath } from "path";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { prismaUsers } from "@/lib/db-users";
Expand Down Expand Up @@ -55,6 +56,13 @@ type BlueprintType = (typeof BLUEPRINT_TYPES)[number];
/**
* Determine tier based on total word count (all content including comments/headers).
*/
function sanitizeRepositoryPath(input: string | null | undefined): string | null {
if (!input?.trim()) return null;
const normalized = posixPath.normalize(input.trim()).replace(/^[/\\]+/, "");
if (normalized.startsWith("..") || normalized.includes("/..")) return null;
return normalized || null;
}

function determineTier(content: string): "SHORT" | "INTERMEDIATE" | "LONG" | "SUPERLONG" {
const wordCount = content.split(/\s+/).filter(Boolean).length;

Expand Down Expand Up @@ -501,7 +509,7 @@ export async function POST(request: NextRequest) {
// Hierarchy fields
hierarchyId: hierarchyId?.trim() || null,
parentId: validatedParentId,
repositoryPath: repositoryPath?.trim() || null,
repositoryPath: sanitizeRepositoryPath(repositoryPath) || null,
},
});

Expand Down
Loading
Loading