diff --git a/apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx b/apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx index b22d545cc7..5397d8fb3f 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/settings/page.tsx @@ -34,6 +34,7 @@ export default async function SettingsPage(props: Props) { }, }, }); + if (!workspace || workspace.tenantId !== tenantId) { return redirect("/new"); } diff --git a/apps/dashboard/app/(app)/apis/[apiId]/settings/update-ip-whitelist.tsx b/apps/dashboard/app/(app)/apis/[apiId]/settings/update-ip-whitelist.tsx index df8224e196..2ed05aac5c 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/settings/update-ip-whitelist.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/settings/update-ip-whitelist.tsx @@ -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"; @@ -29,9 +29,7 @@ const formSchema = z.object({ }); type Props = { - workspace: { - features: Workspace["features"]; - }; + workspace: Workspace; api: { id: string; workspaceId: string; @@ -40,9 +38,12 @@ type Props = { }; }; -export const UpdateIpWhitelist: React.FC = ({ 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>({ resolver: zodResolver(formSchema), diff --git a/apps/dashboard/app/(app)/audit/[bucket]/page.tsx b/apps/dashboard/app/(app)/audit/[bucket]/page.tsx index e865a0c64b..7eecb17cff 100644 --- a/apps/dashboard/app/(app)/audit/[bucket]/page.tsx +++ b/apps/dashboard/app/(app)/audit/[bucket]/page.tsx @@ -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"; @@ -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; @@ -33,7 +40,9 @@ type Props = { }; }; -type AuditLogWithTargets = SelectAuditLog & { targets: Array }; +type AuditLogWithTargets = SelectAuditLog & { + targets: Array; +}; /** * Parse searchParam string arrays @@ -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]; diff --git a/apps/dashboard/app/(app)/identities/layout.tsx b/apps/dashboard/app/(app)/identities/layout.tsx index 165b1b041f..9e2fcedb57 100644 --- a/apps/dashboard/app/(app)/identities/layout.tsx +++ b/apps/dashboard/app/(app)/identities/layout.tsx @@ -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"; @@ -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 })) { children = ( ); diff --git a/apps/dashboard/app/(app)/logs/page.tsx b/apps/dashboard/app/(app)/logs/page.tsx index 2d9582f7de..b295364040 100644 --- a/apps/dashboard/app/(app)/logs/page.tsx +++ b/apps/dashboard/app/(app)/logs/page.tsx @@ -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"; @@ -28,7 +29,7 @@ export default async function Page({ return
Workspace with tenantId: {tenantId} not found
; } - if (!workspace.betaFeatures.logsPage) { + if (getFlag(workspace, "logsPage", { devFallback: false, prodFallback: true })) { return notFound(); } diff --git a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/page.tsx b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/page.tsx index 521dcfdd70..43686d2279 100644 --- a/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/page.tsx +++ b/apps/dashboard/app/(app)/ratelimits/[namespaceId]/overrides/page.tsx @@ -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"; @@ -55,7 +57,13 @@ export default async function OverridePage(props: Props) { actions={[ {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{" "} , ]} /> diff --git a/apps/dashboard/app/(app)/workspace-navigations.tsx b/apps/dashboard/app/(app)/workspace-navigations.tsx index 2c489ccee5..56869a0f52 100644 --- a/apps/dashboard/app/(app)/workspace-navigations.tsx +++ b/apps/dashboard/app/(app)/workspace-navigations.tsx @@ -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; @@ -44,7 +44,7 @@ const DiscordIcon = () => ( ); -const Tag: React.FC<{ label: string; className?: string }> = ({ label, className }) => ( +const Tag = ({ label, className }: { label: string; className?: string }) => (
= ({ label, className ); export const createWorkspaceNavigation = ( - workspace: Pick & - Pick & { llmGateways: { id: string }[] }, + workspace: Workspace & { llmGateways: { id: string }[] }, segments: string[], ) => { return [ @@ -91,14 +90,20 @@ export const createWorkspaceNavigation = ( href: "/monitors/verifications", label: "Monitors", active: segments.at(0) === "verifications", - hidden: !workspace.features.webhooks, + hidden: getFlag(workspace, "webhooks", { + 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, @@ -106,7 +111,10 @@ export const createWorkspaceNavigation = ( label: "Success", active: segments.at(0) === "success", tag: , - hidden: !workspace.features.successPage, + hidden: getFlag(workspace, "successPage", { + devFallback: false, + prodFallback: true, + }), }, { icon: DatabaseZap, @@ -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, diff --git a/apps/dashboard/lib/constants.ts b/apps/dashboard/lib/constants.ts new file mode 100644 index 0000000000..61a52284b6 --- /dev/null +++ b/apps/dashboard/lib/constants.ts @@ -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; diff --git a/apps/dashboard/lib/trpc/routers/api/updateIpWhitelist.ts b/apps/dashboard/lib/trpc/routers/api/updateIpWhitelist.ts index f86ec639ca..e0e69ef823 100644 --- a/apps/dashboard/lib/trpc/routers/api/updateIpWhitelist.ts +++ b/apps/dashboard/lib/trpc/routers/api/updateIpWhitelist.ts @@ -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 @@ -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: diff --git a/apps/dashboard/lib/trpc/routers/ratelimit/createOverride.ts b/apps/dashboard/lib/trpc/routers/ratelimit/createOverride.ts index 839424c874..b246671f67 100644 --- a/apps/dashboard/lib/trpc/routers/ratelimit/createOverride.ts +++ b/apps/dashboard/lib/trpc/routers/ratelimit/createOverride.ts @@ -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( @@ -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", diff --git a/apps/dashboard/lib/utils.ts b/apps/dashboard/lib/utils.ts index b2433a4a2d..5af95d796d 100644 --- a/apps/dashboard/lib/utils.ts +++ b/apps/dashboard/lib/utils.ts @@ -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)); } @@ -22,3 +24,78 @@ export function debounce any>(func: T, delay: numb return debounced; } + +type WorkspaceFeatures = Pick; + +type ConfigObject = WorkspaceFeatures["betaFeatures"] & WorkspaceFeatures["features"]; + +type FlagValue = NonNullable; + +/** + * 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( + workspace: Partial, + flagName: TFlagName, + { + devFallback, + prodFallback, + }: { + devFallback: FlagValue; + prodFallback: FlagValue; + }, +): FlagValue { + 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; + } + + const feature = workspace.features?.[flagName as keyof Workspace["features"]]; + if (feature !== undefined) { + return feature as FlagValue; + } + + return prodFallback; +}