Skip to content
Closed
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
6 changes: 6 additions & 0 deletions api/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
"types": "./src/env.ts",
"default": "./src/env.ts"
},
"./feature-flags": {
"types": "./src/feature-flags.ts",
"default": "./src/feature-flags.ts"
},
"./services/github": {
"types": "./src/services/github/index.ts",
"default": "./src/services/github/index.ts"
Expand Down Expand Up @@ -92,6 +96,8 @@
"@vendor/observability": "workspace:*",
"@vendor/unkey": "workspace:*",
"@vendor/upstash": "workspace:*",
"@vendor/vercel-flags": "workspace:*",
"@vercel/flags-core": "^1.1.0",
"@vercel/related-projects": "catalog:",
"drizzle-orm": "catalog:",
"inngest": "catalog:",
Expand Down
16 changes: 16 additions & 0 deletions api/app/src/__tests__/developer-connections-router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const connectDeveloperConnectionMock = vi.fn();
const completeSentryDeveloperConnectionAuthMock = vi.fn();
const setDeveloperConnectionSandboxEnabledMock = vi.fn();
const disconnectDeveloperConnectionMock = vi.fn();
const isDeveloperConnectionsEnabledMock = vi.fn();
const startSentryDeveloperConnectionAuthMock = vi.fn();

vi.mock("@db/app/client", () => ({ db: {} }));
Expand All @@ -34,6 +35,10 @@ vi.mock("../services/developer-connections", () => ({
startSentryDeveloperConnectionAuth: startSentryDeveloperConnectionAuthMock,
}));

vi.mock("../feature-flags", () => ({
isDeveloperConnectionsEnabled: isDeveloperConnectionsEnabledMock,
}));

const { createCallerFactory, createTRPCRouter } = await import("../trpc");
const { developerConnectionsRouter } = await import(
"../router/(pending-not-allowed)/developer-connections"
Expand Down Expand Up @@ -75,6 +80,7 @@ function caller(access = adminAccess()) {
describe("developerConnectionsRouter", () => {
beforeEach(() => {
vi.clearAllMocks();
isDeveloperConnectionsEnabledMock.mockResolvedValue(true);
listDeveloperConnectionsForOrgMock.mockResolvedValue([
{ provider: "sentry", canManage: false, connection: null },
]);
Expand Down Expand Up @@ -104,6 +110,16 @@ describe("developerConnectionsRouter", () => {
).resolves.toEqual([expect.objectContaining({ provider: "sentry" })]);
});

it("hides developer connections procedures when the feature flag is disabled", async () => {
isDeveloperConnectionsEnabledMock.mockResolvedValue(false);

await expect(caller().developerConnections.list()).rejects.toMatchObject({
code: "NOT_FOUND",
});

expect(listDeveloperConnectionsForOrgMock).not.toHaveBeenCalled();
});

it("does not expose public lease materialization on the workspace router", () => {
expect("issueLease" in developerConnectionsRouter._def.procedures).toBe(
false
Expand Down
19 changes: 18 additions & 1 deletion api/app/src/__tests__/developer-connections-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const replaceCurrentDeveloperConnectionMock = vi.fn();
const setCurrentDeveloperConnectionSandboxEnabledMock = vi.fn();
const revokeCurrentDeveloperConnectionMock = vi.fn();
const issueDeveloperConnectionLeaseMock = vi.fn();
const isDeveloperAuthBoxConfiguredMock = vi.fn(() => true);
const sentryAuthBoxStartMock = vi.fn();
const sentryAuthBoxCompleteMock = vi.fn();

Expand Down Expand Up @@ -43,6 +44,7 @@ vi.mock("@db/app", () => ({
}));

vi.mock("../services/developer-connections/auth-box", () => ({
isDeveloperAuthBoxConfigured: isDeveloperAuthBoxConfiguredMock,
sentryAuthBoxClient: {
start: sentryAuthBoxStartMock,
complete: sentryAuthBoxCompleteMock,
Expand Down Expand Up @@ -89,6 +91,7 @@ function ctx(input: { isAdmin?: boolean } = {}) {
describe("developer connection services", () => {
beforeEach(() => {
vi.clearAllMocks();
isDeveloperAuthBoxConfiguredMock.mockReturnValue(true);
listCurrentDeveloperConnectionsMock.mockResolvedValue([]);
listDeveloperConnectionLeasesForSandboxRunMock.mockResolvedValue([]);
replaceCurrentDeveloperConnectionMock.mockImplementation(
Expand Down Expand Up @@ -156,11 +159,25 @@ describe("developer connection services", () => {
await expect(listDeveloperConnectionsForOrg(ctx())).resolves.toEqual([
expect.objectContaining({ provider: "pscale", canManage: true }),
expect.objectContaining({ provider: "upstash", canManage: true }),
expect.objectContaining({ provider: "sentry", canManage: true }),
expect.objectContaining({
provider: "sentry",
canManage: true,
sentryBrowserOAuthAvailable: true,
}),
expect.objectContaining({ provider: "clerk", canManage: true }),
]);
});

it("marks Sentry browser OAuth unavailable when the auth box is not configured", async () => {
isDeveloperAuthBoxConfiguredMock.mockReturnValue(false);

const rows = await listDeveloperConnectionsForOrg(ctx());

expect(rows.find((row) => row.provider === "sentry")).toEqual(
expect.objectContaining({ sentryBrowserOAuthAvailable: false })
);
});

it("encrypts manual PlanetScale credentials and stores a current org connection", async () => {
await connectDeveloperConnection(ctx(), {
provider: "pscale",
Expand Down
73 changes: 73 additions & 0 deletions api/app/src/feature-flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { flagsEnv } from "@vendor/vercel-flags/env";
import { createClient } from "@vercel/flags-core";

interface BooleanFlagDefinition {
defaultValue: boolean;
key: string;
}

export const featureFlags = {
developerConnections: {
defaultValue: false,
key: "developer-connections",
},
} as const satisfies Record<string, BooleanFlagDefinition>;

export const DEVELOPER_CONNECTIONS_FLAG_KEY =
featureFlags.developerConnections.key;

type FlagsClient = ReturnType<typeof createClient>;

const clientOptions = {
stream: { initTimeoutMs: 2000 },
polling: { intervalMs: 30_000, initTimeoutMs: 5000 },
};

let client: FlagsClient | null = null;
let initPromise: Promise<void> | null = null;

function getFlagsClient(): FlagsClient | null {
if (client) {
return client;
}
const sdkKey = flagsEnv.FLAGS;
if (!sdkKey) {
return null;
}
client = createClient(sdkKey, clientOptions);
return client;
}

async function ensureInitialized() {
const flagsClient = getFlagsClient();
if (!flagsClient) {
return null;
}
if (!initPromise) {
const result = flagsClient.initialize();
initPromise = result instanceof Promise ? result : Promise.resolve();
}
try {
await initPromise;
} catch {
initPromise = null;
return null;
}
return flagsClient;
}

async function evaluateBooleanFlag(definition: BooleanFlagDefinition) {
const flagsClient = await ensureInitialized();
if (!flagsClient) {
return definition.defaultValue;
}
const result = await flagsClient.evaluate<boolean>(
definition.key,
definition.defaultValue
);
return result.value ?? definition.defaultValue;
}

export function isDeveloperConnectionsEnabled() {
return evaluateBooleanFlag(featureFlags.developerConnections);
}
Comment on lines +71 to +73

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Gate sandbox credential issuance too

When the developer-connections flag is disabled, this helper is only consulted by the page/sidebar/tRPC management router, but sandbox commands still call issueAllEnabledDeveloperConnectionLeases from api/app/src/services/developer-sandbox-runs/index.ts:149 and materialize any existing enabled provider credentials. In environments where the flag is turned off to keep Developer Connections dark after credentials already exist, users can no longer see or manage those connections, yet their secrets are still injected into sandboxes; the same flag check needs to cover the lease/materialization path as well.

Useful? React with 👍 / 👎.

39 changes: 33 additions & 6 deletions api/app/src/router/(pending-not-allowed)/developer-connections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
developerConnectionSetSandboxEnabledInputSchema,
developerConnectionStartAuthInputSchema,
} from "@repo/developer-connection-contract";
import { TRPCError } from "@trpc/server";
import { isDeveloperConnectionsEnabled } from "../../feature-flags";
import {
completeSentryDeveloperConnectionAuth,
connectDeveloperConnection,
Expand All @@ -19,29 +21,54 @@ import {
createTRPCRouter,
} from "../../trpc";

function developerConnectionsNotFoundError() {
return new TRPCError({
code: "NOT_FOUND",
message: "Developer connections not found",
});
}

const developerConnectionsProcedure = boundOrgProcedure.use(
async ({ next }) => {
if (!(await isDeveloperConnectionsEnabled())) {
throw developerConnectionsNotFoundError();
}
return next();
}
);

const developerConnectionsAdminProcedure = boundOrgAdminProcedure.use(
async ({ next }) => {
if (!(await isDeveloperConnectionsEnabled())) {
throw developerConnectionsNotFoundError();
}
return next();
}
);

export const developerConnectionsRouter = createTRPCRouter({
list: boundOrgProcedure.query(async ({ ctx }) =>
list: developerConnectionsProcedure.query(async ({ ctx }) =>
listDeveloperConnectionsForOrg(ctx)
),
connect: boundOrgAdminProcedure
connect: developerConnectionsAdminProcedure
.input(developerConnectionConnectInputSchema)
.mutation(async ({ ctx, input }) => connectDeveloperConnection(ctx, input)),
startSentryAuth: boundOrgAdminProcedure
startSentryAuth: developerConnectionsAdminProcedure
.input(developerConnectionStartAuthInputSchema)
.mutation(async ({ ctx, input }) =>
startSentryDeveloperConnectionAuth(ctx, input)
),
completeSentryAuth: boundOrgAdminProcedure
completeSentryAuth: developerConnectionsAdminProcedure
.input(developerConnectionCompleteAuthInputSchema)
.mutation(async ({ ctx, input }) =>
completeSentryDeveloperConnectionAuth(ctx, input)
),
setSandboxEnabled: boundOrgAdminProcedure
setSandboxEnabled: developerConnectionsAdminProcedure
.input(developerConnectionSetSandboxEnabledInputSchema)
.mutation(async ({ ctx, input }) =>
setDeveloperConnectionSandboxEnabled(ctx, input)
),
disconnect: boundOrgAdminProcedure
disconnect: developerConnectionsAdminProcedure
.input(developerConnectionProviderInputSchema)
.mutation(async ({ ctx, input }) =>
disconnectDeveloperConnection(ctx, input)
Expand Down
12 changes: 9 additions & 3 deletions api/app/src/services/developer-connections/auth-box.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,22 @@ export interface SentryAuthBoxClient {
}): Promise<SentryAuthBoxStartResult>;
}

export function isDeveloperAuthBoxConfigured() {
return Boolean(env.DEVELOPER_AUTH_BOX_ORIGIN && env.DEVELOPER_AUTH_BOX_TOKEN);
}

function authBoxConfig() {
if (!(env.DEVELOPER_AUTH_BOX_ORIGIN && env.DEVELOPER_AUTH_BOX_TOKEN)) {
const origin = env.DEVELOPER_AUTH_BOX_ORIGIN;
const token = env.DEVELOPER_AUTH_BOX_TOKEN;
if (!(origin && token)) {
throw new TRPCError({
code: "PRECONDITION_FAILED",
message: "Developer auth box is not configured.",
});
}
return {
origin: env.DEVELOPER_AUTH_BOX_ORIGIN.replace(/\/$/, ""),
token: env.DEVELOPER_AUTH_BOX_TOKEN,
origin: origin.replace(/\/$/, ""),
token,
};
}

Expand Down
4 changes: 4 additions & 0 deletions api/app/src/services/developer-connections/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type DeveloperConnectionProvider,
} from "@repo/developer-connection-contract";
import type { AuthContext } from "../../trpc";
import { isDeveloperAuthBoxConfigured } from "./auth-box";

interface DeveloperConnectionServiceContext {
auth: AuthContext;
Expand Down Expand Up @@ -33,6 +34,7 @@ export interface DeveloperConnectionCatalogRow {
description: string;
displayName: string;
provider: DeveloperConnectionProvider;
sentryBrowserOAuthAvailable: boolean;
}

export function canManageDeveloperConnections(
Expand Down Expand Up @@ -92,5 +94,7 @@ export async function listDeveloperConnectionsForOrg(
canManage,
connectAvailability: availabilityFor(canManage),
connection: shapeConnection(byProvider.get(catalogItem.provider)),
sentryBrowserOAuthAvailable:
catalogItem.provider === "sentry" && isDeveloperAuthBoxConfigured(),
}));
}
Loading
Loading