Skip to content

feat: bypass feature flag #2707

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
1 change: 1 addition & 0 deletions apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default async function SettingsPage(props: Props) {
},
},
});

if (!workspace || workspace.tenantId !== tenantId) {
return redirect("/new");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { FormField } from "@/components/ui/form";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "@/components/ui/toaster";
import { trpc } from "@/lib/trpc/client";
import { cn } from "@/lib/utils";
import { cn, getFlag } from "@/lib/utils";
import { zodResolver } from "@hookform/resolvers/zod";
import type { Workspace } from "@unkey/db";
import Link from "next/link";
Expand All @@ -29,9 +29,7 @@ const formSchema = z.object({
});

type Props = {
workspace: {
features: Workspace["features"];
};
workspace: Workspace;
api: {
id: string;
workspaceId: string;
Expand All @@ -40,9 +38,12 @@ type Props = {
};
};

export const UpdateIpWhitelist: React.FC<Props> = ({ api, workspace }) => {
export const UpdateIpWhitelist = ({ api, workspace }: Props) => {
const router = useRouter();
const isEnabled = workspace.features.ipWhitelist;
const isEnabled = getFlag(workspace, "ipWhitelist", {
devFallback: true,
prodFallback: false,
});

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
Expand Down
22 changes: 18 additions & 4 deletions apps/dashboard/app/(app)/audit/[bucket]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import { PageHeader } from "@/components/dashboard/page-header";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { getTenantId } from "@/lib/auth";
import {
DEFAULT_FREE_AUDIT_LOG_RETENTION_DAYS,
DEFAULT_PAID_AUDIT_LOG_RETENTION_DAYS,
} from "@/lib/constants";
import { db } from "@/lib/db";
import { getFlag } from "@/lib/utils";
import { clerkClient } from "@clerk/nextjs";
import type { User } from "@clerk/nextjs/server";
import type { SelectAuditLog, SelectAuditLogTarget } from "@unkey/db/src/schema";
Expand All @@ -21,6 +26,8 @@ import { Row } from "./row";
export const dynamic = "force-dynamic";
export const runtime = "edge";

const ONE_DAY_MS = 24 * 60 * 60 * 1_000;

type Props = {
params: {
bucket: string;
Expand All @@ -33,7 +40,9 @@ type Props = {
};
};

type AuditLogWithTargets = SelectAuditLog & { targets: Array<SelectAuditLogTarget> };
type AuditLogWithTargets = SelectAuditLog & {
targets: Array<SelectAuditLogTarget>;
};

/**
* Parse searchParam string arrays
Expand Down Expand Up @@ -87,9 +96,14 @@ export default async function AuditPage(props: Props) {
/**
* If not specified, default to 30 days
*/
const retentionDays =
workspace.features.auditLogRetentionDays ?? workspace.plan === "free" ? 30 : 90;
const retentionCutoffUnixMilli = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
const retentionDays = getFlag(workspace, "auditLogRetentionDays", {
devFallback: DEFAULT_PAID_AUDIT_LOG_RETENTION_DAYS,
prodFallback:
workspace.plan === "free"
? DEFAULT_FREE_AUDIT_LOG_RETENTION_DAYS
: DEFAULT_PAID_AUDIT_LOG_RETENTION_DAYS,
});
const retentionCutoffUnixMilli = Date.now() - retentionDays * ONE_DAY_MS;

const selectedActorIds = [...selectedRootKeys, ...selectedUsers];

Expand Down
3 changes: 2 additions & 1 deletion apps/dashboard/app/(app)/identities/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type * as React from "react";
import { OptIn } from "@/components/opt-in";
import { getTenantId } from "@/lib/auth";
import { db } from "@/lib/db";
import { getFlag } from "@/lib/utils";
import { redirect } from "next/navigation";

export const dynamic = "force-dynamic";
Expand All @@ -21,7 +22,7 @@ export default async function AuthorizationLayout({
return redirect("/auth/sign-in");
}

if (!workspace.betaFeatures.identities) {
if (getFlag(workspace, "identities", { prodFallback: true, devFallback: false })) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

you flipped it around, so when they had it set to true in our database, we would actually deny them access

Suggested change
if (getFlag(workspace, "identities", { prodFallback: true, devFallback: false })) {
if (!getFlag(workspace, "identities", { prodFallback: false, devFallback: true })) {

children = (
<OptIn title="Identities" description="Identities are in beta" feature="identities" />
);
Expand Down
3 changes: 2 additions & 1 deletion apps/dashboard/app/(app)/logs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { getTenantId } from "@/lib/auth";
import { clickhouse } from "@/lib/clickhouse";
import { db } from "@/lib/db";
import { getFlag } from "@/lib/utils";
import { notFound } from "next/navigation";
import { createSearchParamsCache } from "nuqs/server";
import { DEFAULT_LOGS_FETCH_COUNT } from "./constants";
Expand All @@ -28,7 +29,7 @@ export default async function Page({
return <div>Workspace with tenantId: {tenantId} not found</div>;
}

if (!workspace.betaFeatures.logsPage) {
if (getFlag(workspace, "logsPage", { devFallback: false, prodFallback: true })) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
if (getFlag(workspace, "logsPage", { devFallback: false, prodFallback: true })) {
if (!getFlag(workspace, "logsPage", { devFallback: true, prodFallback: false })) {

return notFound();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { PageHeader } from "@/components/dashboard/page-header";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { getTenantId } from "@/lib/auth";
import { DEFAULT_RATELIMIT_OVERRIDES } from "@/lib/constants";
import { db } from "@/lib/db";
import { getFlag } from "@/lib/utils";
import { Scan } from "lucide-react";
import { notFound } from "next/navigation";
import { CreateNewOverride } from "./create-new-override";
Expand Down Expand Up @@ -55,7 +57,13 @@ export default async function OverridePage(props: Props) {
actions={[
<Badge variant="secondary" className="h-8">
{Intl.NumberFormat().format(namespace.overrides.length)} /{" "}
{Intl.NumberFormat().format(namespace.workspace.features.ratelimitOverrides ?? 5)} used{" "}
{Intl.NumberFormat().format(
getFlag(namespace.workspace, "ratelimitOverrides", {
prodFallback: DEFAULT_RATELIMIT_OVERRIDES,
devFallback: DEFAULT_RATELIMIT_OVERRIDES,
}),
)}{" "}
used{" "}
</Badge>,
]}
/>
Expand Down
27 changes: 19 additions & 8 deletions apps/dashboard/app/(app)/workspace-navigations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
ShieldCheck,
TableProperties,
} from "lucide-react";
import { cn } from "../../lib/utils";
import { cn, getFlag } from "../../lib/utils";

type NavItem = {
disabled?: boolean;
Expand Down Expand Up @@ -44,7 +44,7 @@ const DiscordIcon = () => (
</svg>
);

const Tag: React.FC<{ label: string; className?: string }> = ({ label, className }) => (
const Tag = ({ label, className }: { label: string; className?: string }) => (
<div
className={cn(
"bg-background border text-content-subtle rounded text-xs px-1 py-0.5 font-mono ",
Expand All @@ -56,8 +56,7 @@ const Tag: React.FC<{ label: string; className?: string }> = ({ label, className
);

export const createWorkspaceNavigation = (
workspace: Pick<Workspace, "features"> &
Pick<Workspace, "betaFeatures"> & { llmGateways: { id: string }[] },
workspace: Workspace & { llmGateways: { id: string }[] },
segments: string[],
) => {
return [
Expand Down Expand Up @@ -91,22 +90,31 @@ export const createWorkspaceNavigation = (
href: "/monitors/verifications",
label: "Monitors",
active: segments.at(0) === "verifications",
hidden: !workspace.features.webhooks,
hidden: getFlag(workspace, "webhooks", {
Copy link
Collaborator

Choose a reason for hiding this comment

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

that's reversed too
it's too confusing this way I think
in the db I'd set it to { "webhooks": true } if I want to give someone access, but now that would revoke access...

devFallback: false,
prodFallback: true,
}),
},
{
icon: TableProperties,
href: "/logs",
label: "Logs",
active: segments.at(0) === "logs",
hidden: !workspace.betaFeatures.logsPage,
hidden: getFlag(workspace, "logsPage", {
devFallback: false,
prodFallback: true,
}),
},
{
icon: Crown,
href: "/success",
label: "Success",
active: segments.at(0) === "success",
tag: <Tag label="internal" />,
hidden: !workspace.features.successPage,
hidden: getFlag(workspace, "successPage", {
devFallback: false,
prodFallback: true,
}),
},
{
icon: DatabaseZap,
Expand All @@ -120,7 +128,10 @@ export const createWorkspaceNavigation = (
href: "/identities",
label: "Identities",
active: segments.at(0) === "identities",
hidden: !workspace.betaFeatures.identities,
hidden: getFlag(workspace, "identities", {
devFallback: false,
prodFallback: true,
}),
},
{
icon: Settings2,
Expand Down
3 changes: 3 additions & 0 deletions apps/dashboard/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const DEFAULT_RATELIMIT_OVERRIDES = 5;
export const DEFAULT_FREE_AUDIT_LOG_RETENTION_DAYS = 30;
export const DEFAULT_PAID_AUDIT_LOG_RETENTION_DAYS = 90;
8 changes: 7 additions & 1 deletion apps/dashboard/lib/trpc/routers/api/updateIpWhitelist.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from "zod";
import { insertAuditLogs } from "@/lib/audit";
import { db, eq, schema } from "@/lib/db";

import { getFlag } from "@/lib/utils";
import { auth, t } from "../../trpc";

export const updateApiIpWhitelist = t.procedure
Expand Down Expand Up @@ -58,7 +59,12 @@ export const updateApiIpWhitelist = t.procedure
});
}

if (!api.workspace.features.ipWhitelist) {
if (
getFlag(api.workspace, "ipWhitelist", {
devFallback: false,
prodFallback: true,
})
) {
throw new TRPCError({
code: "FORBIDDEN",
message:
Expand Down
11 changes: 7 additions & 4 deletions apps/dashboard/lib/trpc/routers/ratelimit/createOverride.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod";

import { insertAuditLogs } from "@/lib/audit";
import { DEFAULT_RATELIMIT_OVERRIDES } from "@/lib/constants";
import { and, db, eq, isNull, schema, sql } from "@/lib/db";
import { getFlag } from "@/lib/utils";
import { newId } from "@unkey/id";
import { auth, t } from "../../trpc";

export const createOverride = t.procedure
.use(auth)
.input(
Expand Down Expand Up @@ -59,10 +62,10 @@ export const createOverride = t.procedure
),
)
.then((res) => Number(res.at(0)?.count ?? 0));
const max =
typeof namespace.workspace.features.ratelimitOverrides === "number"
? namespace.workspace.features.ratelimitOverrides
: 5;
const max = getFlag(namespace.workspace, "ratelimitOverrides", {
devFallback: DEFAULT_RATELIMIT_OVERRIDES,
prodFallback: DEFAULT_RATELIMIT_OVERRIDES,
});
if (existing >= max) {
throw new TRPCError({
code: "FORBIDDEN",
Expand Down
77 changes: 77 additions & 0 deletions apps/dashboard/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

import type { Workspace } from "@/lib/db";

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Expand All @@ -22,3 +24,78 @@ export function debounce<T extends (...args: any[]) => any>(func: T, delay: numb

return debounced;
}

type WorkspaceFeatures = Pick<Workspace, "features" | "betaFeatures">;

type ConfigObject = WorkspaceFeatures["betaFeatures"] & WorkspaceFeatures["features"];

type FlagValue<T extends keyof ConfigObject> = NonNullable<ConfigObject[T]>;

/**
* Checks if a workspace has access to a specific feature or beta feature.
* In development environment, returns devFallback value.
* In production, returns the feature value if explicitly set, otherwise returns prodFallback.
*
* @param workspace - The workspace to check access for
* @param flagName - The name of the feature to check
* @param options - Configuration options
* @param options.devFallback - Value to return in development environment
* @param options.prodFallback - Value to return in production when feature is not set
* @returns The feature value (boolean | number | string) based on environment and settings
*
* @example
* ```typescript
* // Check if workspace has access to logs page
* if (!getFlag(workspace, "logsPage", {
* devFallback: true, // Allow in development
* prodFallback: false // Deny in production if not set
* })) {
* return notFound();
* }
*
* // Check feature with numeric value
* const userLimit = getFlag(workspace, "userLimit", {
* devFallback: 1000, // Higher limit in development
* prodFallback: 100 // Lower limit in production if not set
* });
*
* // Check feature with string value
* const tier = getFlag(workspace, "serviceTier", {
* devFallback: "premium", // Use premium in development
* prodFallback: "basic" // Use basic in production if not set
* });
* ```
*/
export function getFlag<TFlagName extends keyof ConfigObject>(
workspace: Partial<WorkspaceFeatures>,
flagName: TFlagName,
{
devFallback,
prodFallback,
}: {
devFallback: FlagValue<TFlagName>;
prodFallback: FlagValue<TFlagName>;
},
): FlagValue<TFlagName> {
if (process.env.NODE_ENV === "development") {
return devFallback;
}

if (!workspace) {
throw new Error(
"Cannot get feature flag: No workspace found in database. Please verify workspace exists in the database or create a new workspace record.",
);
}

const betaFeature = workspace.betaFeatures?.[flagName as keyof Workspace["betaFeatures"]];
if (betaFeature !== undefined) {
return betaFeature as FlagValue<TFlagName>;
}

const feature = workspace.features?.[flagName as keyof Workspace["features"]];
if (feature !== undefined) {
return feature as FlagValue<TFlagName>;
}

return prodFallback;
}
Loading