Skip to content

Commit 3dbaeff

Browse files
martinsioneclaude
andcommitted
feat: bypass managed template trial limits for Vercel team members
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent cfe64b4 commit 3dbaeff

File tree

7 files changed

+95
-2
lines changed

7 files changed

+95
-2
lines changed

apps/web/app/api/auth/vercel/callback/route.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,17 @@ mock.module("next/headers", () => ({
3737
}),
3838
}));
3939

40+
const hasAccessToVercelTeamSlugMock = mock(async () => false);
41+
4042
mock.module("@/lib/vercel/oauth", () => ({
4143
exchangeVercelCode: exchangeVercelCodeMock,
4244
getVercelUserInfo: getVercelUserInfoMock,
4345
}));
4446

47+
mock.module("@/lib/vercel/projects", () => ({
48+
hasAccessToVercelTeamSlug: hasAccessToVercelTeamSlugMock,
49+
}));
50+
4551
mock.module("@/lib/db/users", () => ({
4652
upsertUser: upsertUserMock,
4753
}));
@@ -85,6 +91,7 @@ beforeEach(() => {
8591

8692
exchangeVercelCodeMock.mockClear();
8793
getVercelUserInfoMock.mockClear();
94+
hasAccessToVercelTeamSlugMock.mockClear();
8895
upsertUserMock.mockClear();
8996
encryptMock.mockClear();
9097
encryptJWEMock.mockClear();
@@ -153,6 +160,34 @@ describe("GET /api/auth/vercel/callback", () => {
153160
expect(upsertUserMock).toHaveBeenCalledTimes(1);
154161
});
155162

163+
test("includes isAllowedTeamMember in the encrypted session", async () => {
164+
hasAccessToVercelTeamSlugMock.mockResolvedValueOnce(true);
165+
166+
const { GET } = await routeModulePromise;
167+
await GET(createRequest());
168+
169+
expect(hasAccessToVercelTeamSlugMock).toHaveBeenCalledWith(
170+
"access-token",
171+
"vercel",
172+
);
173+
const sessionArg = (
174+
encryptJWEMock.mock.calls as unknown as [Record<string, unknown>][]
175+
)[0][0];
176+
expect(sessionArg.isAllowedTeamMember).toBe(true);
177+
});
178+
179+
test("sets isAllowedTeamMember to false for non-team users", async () => {
180+
hasAccessToVercelTeamSlugMock.mockResolvedValueOnce(false);
181+
182+
const { GET } = await routeModulePromise;
183+
await GET(createRequest());
184+
185+
const sessionArg = (
186+
encryptJWEMock.mock.calls as unknown as [Record<string, unknown>][]
187+
)[0][0];
188+
expect(sessionArg.isAllowedTeamMember).toBe(false);
189+
});
190+
156191
test("allows non-Vercel emails on self-hosted deployments", async () => {
157192
process.env.VERCEL_GIT_REPO_OWNER = "someone-else";
158193
process.env.VERCEL_GIT_REPO_SLUG = "open-harness-clone";

apps/web/app/api/auth/vercel/callback/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { encrypt } from "@/lib/crypto";
44
import { upsertUser } from "@/lib/db/users";
55
import { encryptJWE } from "@/lib/jwe/encrypt";
66
import { SESSION_COOKIE_NAME } from "@/lib/session/constants";
7+
import { ALLOWED_VERCEL_TEAM_SLUG } from "@/lib/managed-template-trial";
78
import { exchangeVercelCode, getVercelUserInfo } from "@/lib/vercel/oauth";
9+
import { hasAccessToVercelTeamSlug } from "@/lib/vercel/projects";
810

911
function clearVercelOauthCookies(store: Awaited<ReturnType<typeof cookies>>) {
1012
store.delete("vercel_auth_state");
@@ -49,7 +51,10 @@ export async function GET(req: NextRequest): Promise<Response> {
4951
redirectUri,
5052
});
5153

52-
const userInfo = await getVercelUserInfo(tokens.access_token);
54+
const [userInfo, isAllowedTeamMember] = await Promise.all([
55+
getVercelUserInfo(tokens.access_token),
56+
hasAccessToVercelTeamSlug(tokens.access_token, ALLOWED_VERCEL_TEAM_SLUG),
57+
]);
5358

5459
const tokenExpiresAt = new Date(Date.now() + tokens.expires_in * 1000);
5560

@@ -74,6 +79,7 @@ export async function GET(req: NextRequest): Promise<Response> {
7479
const session = {
7580
created: Date.now(),
7681
authProvider: "vercel" as const,
82+
isAllowedTeamMember,
7783
user: {
7884
id: userId,
7985
username,

apps/web/app/api/sessions/route.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { VercelProjectSelection } from "@/lib/vercel/types";
33

44
let currentSession: {
55
authProvider?: "vercel" | "github";
6+
isAllowedTeamMember?: boolean;
67
user: {
78
id: string;
89
username: string;
@@ -147,6 +148,37 @@ describe("/api/sessions POST vercel project linking", () => {
147148
expect(createCalls).toHaveLength(0);
148149
});
149150

151+
test("allows additional sessions for Vercel team members on the managed deployment", async () => {
152+
const { POST } = await routeModulePromise;
153+
154+
currentSession = {
155+
authProvider: "vercel",
156+
user: {
157+
id: "user-1",
158+
username: "nico",
159+
name: "Nico",
160+
email: "person@example.com",
161+
},
162+
isAllowedTeamMember: true,
163+
};
164+
existingSessionCount = 5;
165+
166+
const response = await POST(
167+
createJsonRequest(
168+
{
169+
branch: "main",
170+
cloneUrl: "https://github.com/vercel/open-harness",
171+
repoOwner: "vercel",
172+
repoName: "open-harness",
173+
},
174+
"https://open-agents.dev/api/sessions",
175+
),
176+
);
177+
178+
expect(response.status).toBe(200);
179+
expect(createCalls).toHaveLength(1);
180+
});
181+
150182
test("explicit Vercel project is validated against live repo matches before it is persisted", async () => {
151183
const { POST } = await routeModulePromise;
152184

apps/web/lib/managed-template-trial.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { Session } from "@/lib/session/types";
22

33
const ALLOWED_VERCEL_EMAIL_DOMAIN = "vercel.com";
4+
export const ALLOWED_VERCEL_TEAM_SLUG = "vercel";
45
const MANAGED_TEMPLATE_HOSTS = new Set([
56
"open-agents.dev",
67
"www.open-agents.dev",
@@ -60,12 +61,16 @@ export function hasAllowedManagedTemplateEmail(email?: string) {
6061
}
6162

6263
export function isManagedTemplateTrialUser(
63-
session: Pick<Session, "authProvider" | "user"> | null | undefined,
64+
session:
65+
| Pick<Session, "authProvider" | "user" | "isAllowedTeamMember">
66+
| null
67+
| undefined,
6468
url: string | URL,
6569
) {
6670
return (
6771
session?.authProvider === "vercel" &&
6872
isManagedTemplateDeployment(url) &&
73+
!session.isAllowedTeamMember &&
6974
!hasAllowedManagedTemplateEmail(session.user.email)
7075
);
7176
}

apps/web/lib/session/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export async function getSessionFromCookie(
1212
return {
1313
created: decrypted.created,
1414
authProvider: decrypted.authProvider,
15+
isAllowedTeamMember: decrypted.isAllowedTeamMember,
1516
user: decrypted.user,
1617
};
1718
}

apps/web/lib/session/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export interface Session {
22
created: number;
33
authProvider: "vercel" | "github";
4+
isAllowedTeamMember?: boolean;
45
user: {
56
id: string;
67
username: string;

apps/web/lib/vercel/projects.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,19 @@ async function listAccessibleVercelTeams(
158158
}));
159159
}
160160

161+
export async function hasAccessToVercelTeamSlug(
162+
token: string,
163+
slug: string,
164+
): Promise<boolean> {
165+
try {
166+
const teams = await listAccessibleVercelTeams(token);
167+
return teams.some((team) => team.teamSlug === slug);
168+
} catch (error) {
169+
console.error("[hasAccessToVercelTeamSlug] failed:", error);
170+
return false;
171+
}
172+
}
173+
161174
async function listProjectsForScope(params: {
162175
token: string;
163176
repoUrl: string;

0 commit comments

Comments
 (0)