diff --git a/.agents/skills/databuddy-internal/SKILL.md b/.agents/skills/databuddy-internal/SKILL.md index 58b3762d5..24325a66e 100644 --- a/.agents/skills/databuddy-internal/SKILL.md +++ b/.agents/skills/databuddy-internal/SKILL.md @@ -105,6 +105,9 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin - When fixing broken dashboard links to moved sections, update the real docs/search/navigation links and section anchors directly; do not add compatibility redirect pages unless explicitly requested. - Custom events UI is shared in `apps/dashboard/components/events/custom-events`; keep many-series legends outside the Recharts plot, use compact controls for property-summary event selection, and avoid separate event-count chip/list sections. - Goals and Funnels are sibling conversion surfaces; keep Goals list-first and visually aligned with `app/(main)/websites/[id]/funnels` instead of adding separate summary-card chrome. +- Funnel rows keep the action menu outside the main toggle button; put row padding on the sibling `Button`, not only on `List.Row`, so the visible row surface is clickable without nesting buttons. +- Demo website navigation must be public-safe and route-backed; hide sensitive, configuration-heavy, or unavailable website features such as Agent, Feature Flags, Revenue, Users, Realtime, Anomalies, and website Settings instead of inheriting the full website nav. Goals and Funnels may be public demo surfaces, but keep them read-only. +- Dashboard definitions for feature flags and target groups are admin surfaces; do not expose even sanitized rows to demo-tier/public website access. - Insights merged feed (`use-insights-feed`) collapses history + AI by `insightSignalDedupeKey` in `apps/dashboard/lib/insight-signal-key.ts` so the list is one row per signal (latest wins). - Insights page (`app/(main)/insights`) should stay focused on the brief + signal queue; do not add generic global analytics KPI cards or top pages/referrers/countries tables there. - Theme: `apps/dashboard/app/globals.css`. **`--border` is intentionally subtle**; do not crank it darker for “contrast” unless **iza** asks—prefer text tokens or layout for readability. diff --git a/apps/api/src/integration/cache-auth-bypass.test.ts b/apps/api/src/integration/cache-auth-bypass.test.ts index 95e174e01..a2027dc2e 100644 --- a/apps/api/src/integration/cache-auth-bypass.test.ts +++ b/apps/api/src/integration/cache-auth-bypass.test.ts @@ -106,7 +106,7 @@ describe("cache-bypass auth: target-groups.list", () => { ); }); - iit("demo caller gets sanitized rules even when authed cache exists", async () => { + iit("demo caller cannot read target groups even when authed cache exists", async () => { const { user, org, site } = await setupOwnedSite({ isPublic: true }); await seedTargetGroup(site.id, user.id); @@ -116,12 +116,10 @@ describe("cache-bypass auth: target-groups.list", () => { )({ websiteId: site.id }); expect((authed[0] as { rules: unknown[] }).rules).toHaveLength(1); - const demo = await call( - appRouter.targetGroups.list, - context() - )({ websiteId: site.id }); - expect(demo).toHaveLength(1); - expect((demo[0] as { rules: unknown[] }).rules).toEqual([]); + await expectCode( + call(appRouter.targetGroups.list, context())({ websiteId: site.id }), + "UNAUTHORIZED" + ); }); iit("cross-org API key cannot read a website it does not own", async () => { @@ -176,6 +174,15 @@ describe("cache-bypass auth: flags.list", () => { "FORBIDDEN" ); }); + + iit("demo caller cannot read public-site flag definitions", async () => { + const { site } = await setupOwnedSite({ isPublic: true }); + + await expectCode( + call(appRouter.flags.list, context())({ websiteId: site.id }), + "UNAUTHORIZED" + ); + }); }); describe("cache-bypass auth: annotations.list", () => { diff --git a/apps/dashboard/app/(main)/websites/[id]/funnels/_components/funnel-item.tsx b/apps/dashboard/app/(main)/websites/[id]/funnels/_components/funnel-item.tsx index 4d360d58a..391f74840 100644 --- a/apps/dashboard/app/(main)/websites/[id]/funnels/_components/funnel-item.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/funnels/_components/funnel-item.tsx @@ -40,6 +40,7 @@ interface FunnelItemProps { onDelete: (funnelId: string) => void; onEdit: (funnel: FunnelItemData) => void; onToggle: (funnelId: string) => void; + readOnly?: boolean; } function MiniFunnelPreview({ @@ -94,6 +95,7 @@ export function FunnelItem({ onToggle, onEdit, onDelete, + readOnly = false, className, children, }: FunnelItemProps) { @@ -104,10 +106,14 @@ export function FunnelItem({ return (
- - - - - - - onEdit(funnel)} - > - - Edit - - - onDelete(funnel.id)} - variant="destructive" + {!readOnly && ( + + + - - Delete - - - - + + + + onEdit(funnel)} + > + + Edit + + + onDelete(funnel.id)} + variant="destructive" + > + + Delete + + + + + )} {isExpanded ? ( diff --git a/apps/dashboard/app/(main)/websites/[id]/funnels/_components/funnels-list.tsx b/apps/dashboard/app/(main)/websites/[id]/funnels/_components/funnels-list.tsx index 008f2dfd7..371712e14 100644 --- a/apps/dashboard/app/(main)/websites/[id]/funnels/_components/funnels-list.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/funnels/_components/funnels-list.tsx @@ -13,6 +13,7 @@ interface FunnelsListProps { onDeleteFunnel: (funnelId: string) => void; onEditFunnel: (funnel: FunnelItemData) => void; onToggleFunnel: (funnelId: string) => void; + readOnly?: boolean; } export function FunnelsList({ @@ -23,6 +24,7 @@ export function FunnelsList({ onToggleFunnel, onEditFunnel, onDeleteFunnel, + readOnly = false, children, }: FunnelsListProps) { return ( @@ -38,6 +40,7 @@ export function FunnelsList({ onDelete={onDeleteFunnel} onEdit={onEditFunnel} onToggle={onToggleFunnel} + readOnly={readOnly} > {children?.(funnel)} diff --git a/apps/dashboard/app/(main)/websites/[id]/funnels/page.tsx b/apps/dashboard/app/(main)/websites/[id]/funnels/page.tsx index 991d83446..7b2c11727 100644 --- a/apps/dashboard/app/(main)/websites/[id]/funnels/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/funnels/page.tsx @@ -13,7 +13,7 @@ import type { CreateFunnelData } from "@/types/funnels"; import { cn } from "@/lib/utils"; import { GATED_FEATURES } from "@databuddy/shared/types/features"; import dynamic from "next/dynamic"; -import { useParams } from "next/navigation"; +import { useParams, usePathname } from "next/navigation"; import { useState } from "react"; import { TopBar } from "@/components/layout/top-bar"; import { @@ -48,6 +48,8 @@ function FunnelsListSkeleton() { export default function FunnelsPage() { const { id } = useParams(); const websiteId = id as string; + const pathname = usePathname(); + const isDemoRoute = pathname.startsWith("/demo/"); const { formattedDateRangeState, dateRange } = useDateFilters(); const [expandedId, setExpandedId] = useState(null); @@ -148,19 +150,23 @@ export default function FunnelsPage() { className={cn("size-4 shrink-0", isFetching && "animate-spin")} /> - + {!isDemoRoute && ( + + )}
setEditing("new"), - }, + action: isDemoRoute + ? undefined + : { + label: "Create a funnel", + onClick: () => setEditing("new"), + }, description: "Define a multi-step journey to see where users drop off.", icon: , @@ -189,6 +195,7 @@ export default function FunnelsPage() { setExpandedId(expandedId === funnelId ? null : funnelId); setSelectedReferrer("all"); }} + readOnly={isDemoRoute} > {(funnel) => { if (expandedId !== funnel.id) { @@ -220,7 +227,7 @@ export default function FunnelsPage() {
- {editing !== null && ( + {!isDemoRoute && editing !== null && ( )} - {!!deletingId && ( + {!isDemoRoute && !!deletingId && ( void; onEdit: (goal: Goal) => void; + readOnly?: boolean; } function GoalProgress({ rate }: { rate: number }) { @@ -74,6 +75,7 @@ export function GoalItem({ isLoadingAnalytics, onEdit, onDelete, + readOnly = false, }: GoalItemProps) { const analyticsData = analytics?.ok ? analytics.data : null; const analyticsError = analytics && !analytics.ok ? analytics.error : null; @@ -149,32 +151,37 @@ export function GoalItem({ )} - - - - - - - onEdit(goal)}> - - Edit - - - onDelete(goal.id)} - variant="destructive" + {!readOnly && ( + + + - - Delete - - - - + + + + onEdit(goal)}> + + Edit + + + onDelete(goal.id)} + variant="destructive" + > + + Delete + + + + + )} ); } diff --git a/apps/dashboard/app/(main)/websites/[id]/goals/_components/goals-list.tsx b/apps/dashboard/app/(main)/websites/[id]/goals/_components/goals-list.tsx index e0efe17cd..4eb917e69 100644 --- a/apps/dashboard/app/(main)/websites/[id]/goals/_components/goals-list.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/goals/_components/goals-list.tsx @@ -10,6 +10,7 @@ interface GoalsListProps { goals: Goal[]; onDeleteGoal: (goalId: string) => void; onEditGoal: (goal: Goal) => void; + readOnly?: boolean; } const EMPTY_GOAL_ANALYTICS: GoalAnalyticsRecord = {}; @@ -20,6 +21,7 @@ export function GoalsList({ onDeleteGoal, goalAnalytics = EMPTY_GOAL_ANALYTICS, analyticsLoading = false, + readOnly = false, }: GoalsListProps) { return ( @@ -34,6 +36,7 @@ export function GoalsList({ key={goal.id} onDelete={onDeleteGoal} onEdit={onEditGoal} + readOnly={readOnly} /> ); })} diff --git a/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx b/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx index 354cf21db..7654dea46 100644 --- a/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/goals/page.tsx @@ -1,7 +1,7 @@ "use client"; import { GATED_FEATURES } from "@databuddy/shared/types/features"; -import { useParams } from "next/navigation"; +import { useParams, usePathname } from "next/navigation"; import { useMemo, useState } from "react"; import { FeatureGate } from "@/components/feature-gate"; import { List } from "@/components/ui/composables/list"; @@ -58,6 +58,8 @@ function toGoalFilters(filters: DynamicQueryFilter[]): GoalFilter[] { export default function GoalsPage() { const { id } = useParams(); const websiteId = id as string; + const pathname = usePathname(); + const isDemoRoute = pathname.startsWith("/demo/"); const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingGoal, setEditingGoal] = useState(null); const [deletingGoalId, setDeletingGoalId] = useState(null); @@ -156,28 +158,32 @@ export default function GoalsPage() { className={cn("size-4 shrink-0", isFetching && "animate-spin")} /> - + {!isDemoRoute && ( + + )}
{ - setEditingGoal(null); - setIsDialogOpen(true); - }, - }, + action: isDemoRoute + ? undefined + : { + label: "Create a goal", + onClick: () => { + setEditingGoal(null); + setIsDialogOpen(true); + }, + }, description: "Track single-step conversions like signups, purchases, or activation events.", icon: , @@ -204,12 +210,13 @@ export default function GoalsPage() { setEditingGoal(goal); setIsDialogOpen(true); }} + readOnly={isDemoRoute} /> )}
- {isDialogOpen && ( + {!isDemoRoute && isDialogOpen && ( )} - {deletingGoalId && ( + {!isDemoRoute && deletingGoalId && ( null; @@ -72,3 +74,42 @@ describe("page navigation active matching", () => { expect(getActivePageNavigationTabId(tabs, "/events-archive")).toBeNull(); }); }); + +describe("demo website navigation", () => { + const demoRoot = new URL("../../../app/demo/[id]/", import.meta.url); + const websiteItems = websiteNavigation.flatMap((group) => group.items); + + it("hides demo-unsafe or unavailable website surfaces", () => { + const hiddenHrefs = new Set([ + "/realtime", + "/anomalies", + "/users", + "/flags", + "/revenue", + "/agent", + "/settings/general", + "/settings/security", + "/settings/transfer", + "/settings/export", + "/settings/tracking", + ]); + + for (const href of hiddenHrefs) { + const item = websiteItems.find((candidate) => candidate.href === href); + expect(item?.hideFromDemo, href).toBe(true); + } + }); + + it("only shows website-level demo nav items with real demo pages", () => { + const visibleDemoItems = websiteItems.filter( + (item) => item.rootLevel === false && !item.hideFromDemo + ); + + for (const item of visibleDemoItems) { + const pagePath = item.href === "" ? "page.tsx" : `${item.href.slice(1)}/page.tsx`; + expect(existsSync(new URL(pagePath, demoRoot)), item.href || "/").toBe( + true + ); + } + }); +}); diff --git a/apps/dashboard/components/layout/navigation/navigation-config.tsx b/apps/dashboard/components/layout/navigation/navigation-config.tsx index 2001378a6..c09a7607c 100644 --- a/apps/dashboard/components/layout/navigation/navigation-config.tsx +++ b/apps/dashboard/components/layout/navigation/navigation-config.tsx @@ -122,6 +122,7 @@ export const websiteNavigation: NavigationGroup[] = [ rootLevel: false, flag: "realtime", alpha: true, + hideFromDemo: true, }), createNavItem("Audience", UsersThreeIcon, "/audience", { rootLevel: false, @@ -147,6 +148,7 @@ export const websiteNavigation: NavigationGroup[] = [ rootLevel: false, alpha: true, flag: "anomalies", + hideFromDemo: true, }), createNavItem("Pulse", PulseIcon, "/pulse", { rootLevel: false, @@ -179,6 +181,7 @@ export const websiteNavigation: NavigationGroup[] = [ createNavItem("Users", IdentificationBadgeIcon, "/users", { rootLevel: false, gatedFeature: GATED_FEATURES.USERS, + hideFromDemo: true, }), createNavItem("Funnels", FunnelIcon, "/funnels", { rootLevel: false, @@ -192,11 +195,13 @@ export const websiteNavigation: NavigationGroup[] = [ alpha: true, rootLevel: false, gatedFeature: GATED_FEATURES.FEATURE_FLAGS, + hideFromDemo: true, }), createNavItem("Revenue", CurrencyDollarIcon, "/revenue", { alpha: true, rootLevel: false, flag: "revenue", + hideFromDemo: true, }), ], }, @@ -206,6 +211,7 @@ export const websiteNavigation: NavigationGroup[] = [ createNavItem("Databunny", RobotIcon, "/agent", { alpha: true, rootLevel: false, + hideFromDemo: true, }), ], }, @@ -215,18 +221,23 @@ export const websiteNavigation: NavigationGroup[] = [ items: [ createNavItem("General", GearIcon, "/settings/general", { rootLevel: false, + hideFromDemo: true, }), createNavItem("Security", LockIcon, "/settings/security", { rootLevel: false, + hideFromDemo: true, }), createNavItem("Transfer", ArrowSquareOutIcon, "/settings/transfer", { rootLevel: false, + hideFromDemo: true, }), createNavItem("Data Export", FileArrowDownIcon, "/settings/export", { rootLevel: false, + hideFromDemo: true, }), createNavItem("Setup", CodeIcon, "/settings/tracking", { rootLevel: false, + hideFromDemo: true, searchTags: [ "tracking setup", "install script", diff --git a/apps/dashboard/components/ui/command-search.tsx b/apps/dashboard/components/ui/command-search.tsx index 76162d88f..5700336b9 100644 --- a/apps/dashboard/components/ui/command-search.tsx +++ b/apps/dashboard/components/ui/command-search.tsx @@ -176,6 +176,7 @@ function toSearchItems( function groupsToSearchGroups( groups: NavigationGroup[], pathPrefix = "", + isDemoPath = false, access?: { isBillingLoading: boolean; isFeatureEnabled: (feature: GatedFeatureId) => boolean; @@ -190,9 +191,20 @@ function groupsToSearchGroups( const items: SearchItem[] = []; for (const item of group.items) { - if (!item.hideFromDemo) { - items.push(...toSearchItems(item, pathPrefix, access)); + if (item.production === false && process.env.NODE_ENV === "production") { + continue; } + if (item.hideFromDemo && isDemoPath) { + continue; + } + if (item.showOnlyOnDemo && !isDemoPath) { + continue; + } + items.push(...toSearchItems(item, pathPrefix, access)); + } + + if (items.length === 0) { + continue; } searchGroups.push({ @@ -353,66 +365,68 @@ export function CommandSearchProvider({ children }: { children: ReactNode }) { ? `${isDemoPath ? "/demo" : "/websites"}/${currentWebsiteId}` : ""; - result.push({ - category: "Actions", - items: [ - { - id: "action:create-api-key", - name: "Create API Key", - subtitle: activeOrganization - ? `Create a key for ${activeOrganization.name}` - : "Create a workspace API key", - icon: KeyIcon, - action: openCreateApiKey, - disabled: !(organizationId && !isSwitchingOrganization), - searchTags: [ - "new api key", - "api token", - "access token", - "node sdk", - "server sdk", - "automation key", - ], - }, - { - id: "action:create-link", - name: "Create Short Link", - subtitle: "Open the new link sheet", - path: "/links?command=create-link", - icon: LinkIcon, - searchTags: ["new link", "short link", "tracking link"], - }, - { - id: "action:create-link-folder", - name: "Create Link Folder", - subtitle: "Organize links in a folder", - path: "/links?command=create-folder", - icon: LinkIcon, - searchTags: ["new folder", "link folder"], - }, - { - id: "action:create-monitor", - name: "Create Monitor", - subtitle: "Add an uptime monitor", - path: "/monitors?command=create-monitor", - icon: HeartbeatIcon, - searchTags: ["new monitor", "uptime", "availability"], - }, - { - id: "action:create-status-page", - name: "Create Status Page", - subtitle: "Set up a public status page", - path: "/monitors/status-pages?command=create-status-page", - icon: OpenExternalIcon, - searchTags: ["new status page", "incident page", "uptime page"], - }, - ], - }); + if (!isDemoPath) { + result.push({ + category: "Actions", + items: [ + { + id: "action:create-api-key", + name: "Create API Key", + subtitle: activeOrganization + ? `Create a key for ${activeOrganization.name}` + : "Create a workspace API key", + icon: KeyIcon, + action: openCreateApiKey, + disabled: !(organizationId && !isSwitchingOrganization), + searchTags: [ + "new api key", + "api token", + "access token", + "node sdk", + "server sdk", + "automation key", + ], + }, + { + id: "action:create-link", + name: "Create Short Link", + subtitle: "Open the new link sheet", + path: "/links?command=create-link", + icon: LinkIcon, + searchTags: ["new link", "short link", "tracking link"], + }, + { + id: "action:create-link-folder", + name: "Create Link Folder", + subtitle: "Organize links in a folder", + path: "/links?command=create-folder", + icon: LinkIcon, + searchTags: ["new folder", "link folder"], + }, + { + id: "action:create-monitor", + name: "Create Monitor", + subtitle: "Add an uptime monitor", + path: "/monitors?command=create-monitor", + icon: HeartbeatIcon, + searchTags: ["new monitor", "uptime", "availability"], + }, + { + id: "action:create-status-page", + name: "Create Status Page", + subtitle: "Set up a public status page", + path: "/monitors/status-pages?command=create-status-page", + icon: OpenExternalIcon, + searchTags: ["new status page", "incident page", "uptime page"], + }, + ], + }); - result.push(...groupsToSearchGroups(mainNavigation)); - result.push(...groupsToSearchGroups(settingsNavigation)); + result.push(...groupsToSearchGroups(mainNavigation)); + result.push(...groupsToSearchGroups(settingsNavigation)); + } - if (websites.length > 0) { + if (!isDemoPath && websites.length > 0) { result.push({ category: "Websites", items: websites.map((w) => ({ @@ -424,7 +438,7 @@ export function CommandSearchProvider({ children }: { children: ReactNode }) { }); } - if (apiKeys && apiKeys.length > 0) { + if (!isDemoPath && apiKeys && apiKeys.length > 0) { result.push({ category: "API Keys", items: (apiKeys as ApiKeyListItem[]).map((apiKey) => ({ @@ -451,7 +465,7 @@ export function CommandSearchProvider({ children }: { children: ReactNode }) { if (currentWebsiteId) { result.push( - ...groupsToSearchGroups(websiteNavigation, websitePrefix, { + ...groupsToSearchGroups(websiteNavigation, websitePrefix, isDemoPath, { isBillingLoading, isFeatureEnabled, }) diff --git a/packages/rpc/src/routers/flags.ts b/packages/rpc/src/routers/flags.ts index 6c09bc263..6b023034e 100644 --- a/packages/rpc/src/routers/flags.ts +++ b/packages/rpc/src/routers/flags.ts @@ -89,6 +89,14 @@ function authorizeFlagRead( }); } +function requireAuthedFlagRead(workspace: Workspace) { + if (workspace.tier === "demo") { + throw rpcError.unauthorized( + "Feature flag definitions require authenticated workspace access" + ); + } +} + const listFlagsSchema = z .object({ ...flagScopeFields, @@ -224,32 +232,6 @@ const checkCircularDependency = async ( } }; -interface FlagWithTargetGroups { - createdBy?: unknown; - rules?: unknown; - targetGroups?: Array<{ - rules?: unknown; - createdBy?: unknown; - [key: string]: unknown; - }>; - userId?: unknown; - [key: string]: unknown; -} - -function sanitizeFlagForDemo(flag: T): T { - return { - ...flag, - rules: Array.isArray(flag.rules) ? [] : flag.rules, - createdBy: "", - userId: null, - targetGroups: flag.targetGroups?.map((group) => ({ - ...group, - rules: Array.isArray(group.rules) ? [] : group.rules, - createdBy: "", - })), - }; -} - interface FlagRelation { flagsToTargetGroups: Array<{ targetGroup: { deletedAt: Date | null; [key: string]: unknown } | null; @@ -264,11 +246,6 @@ function flattenTargetGroups(flag: T) { return { ...rest, targetGroups }; } -function projectForViewer(flag: T, sanitize: boolean) { - const mapped = flattenTargetGroups(flag); - return sanitize ? sanitizeFlagForDemo(mapped) : mapped; -} - function buildFlagChangeSnapshot(flag: { defaultValue: boolean; dependencies?: string[] | null; @@ -323,16 +300,15 @@ export const flagsRouter = { .output(z.array(flagOutputSchema)) .handler(async ({ context, input }) => { const workspace = await authorizeFlagRead(context, input); + requireAuthedFlagRead(workspace); const scope = getScope(input.websiteId, input.organizationId); - const sanitize = workspace.tier === "demo"; return flagsCache.withCache({ key: scopedCacheKey( "list", workspace, scope, - `status:${input.status || "all"}`, - `sanitize:${sanitize}` + `status:${input.status || "all"}` ), ttl: CACHE_DURATION, tables: ["flags", "flags_to_target_groups", "target_groups"], @@ -360,7 +336,7 @@ export const flagsRouter = { with: { flagsToTargetGroups: { with: { targetGroup: true } } }, }); - return flagsList.map((flag) => projectForViewer(flag, sanitize)); + return flagsList.map(flattenTargetGroups); }, }); }), @@ -378,17 +354,11 @@ export const flagsRouter = { .output(flagOutputSchema) .handler(async ({ context, input }) => { const workspace = await authorizeFlagRead(context, input); + requireAuthedFlagRead(workspace); const scope = getScope(input.websiteId, input.organizationId); - const sanitize = workspace.tier === "demo"; return flagsCache.withCache({ - key: scopedCacheKey( - "byId", - workspace, - scope, - `id:${input.id}`, - `sanitize:${sanitize}` - ), + key: scopedCacheKey("byId", workspace, scope, `id:${input.id}`), ttl: CACHE_DURATION, tables: ["flags", "flags_to_target_groups", "target_groups"], queryFn: async () => { @@ -414,7 +384,7 @@ export const flagsRouter = { if (!flag) { throw rpcError.notFound("Flag", input.id); } - return projectForViewer(flag, sanitize); + return flattenTargetGroups(flag); }, }); }), @@ -432,17 +402,11 @@ export const flagsRouter = { .output(flagOutputSchema) .handler(async ({ context, input }) => { const workspace = await authorizeFlagRead(context, input); + requireAuthedFlagRead(workspace); const scope = getScope(input.websiteId, input.organizationId); - const sanitize = workspace.tier === "demo"; return flagsCache.withCache({ - key: scopedCacheKey( - "byKey", - workspace, - scope, - `key:${input.key}`, - `sanitize:${sanitize}` - ), + key: scopedCacheKey("byKey", workspace, scope, `key:${input.key}`), ttl: CACHE_DURATION, tables: ["flags", "flags_to_target_groups", "target_groups"], queryFn: async () => { @@ -469,7 +433,7 @@ export const flagsRouter = { if (!flag) { throw rpcError.notFound("Flag"); } - return projectForViewer(flag, sanitize); + return flattenTargetGroups(flag); }, }); }), diff --git a/packages/rpc/src/routers/funnels.ts b/packages/rpc/src/routers/funnels.ts index 927ff13b4..0d8b95429 100644 --- a/packages/rpc/src/routers/funnels.ts +++ b/packages/rpc/src/routers/funnels.ts @@ -195,18 +195,19 @@ export const funnelsRouter = { }) .input(z.object({ websiteId: z.string() })) .output(z.array(funnelListOutputSchema)) - .handler(({ context, input }) => - cache.withCache({ + .handler(async ({ context, input }) => { + await withPublicWorkspace(context, { + websiteId: input.websiteId, + permissions: ["read"], + }); + + return cache.withCache({ key: `list:${input.websiteId}`, disabled: true, // TODO: Remove this once we have a way to invalidate the cache ttl: CACHE_TTL, tables: ["funnelDefinitions"], - queryFn: async () => { - await withPublicWorkspace(context, { - websiteId: input.websiteId, - permissions: ["read"], - }); - return context.db + queryFn: () => + context.db .select({ id: funnelDefinitions.id, name: funnelDefinitions.name, @@ -226,10 +227,9 @@ export const funnelsRouter = { sql`jsonb_array_length(${funnelDefinitions.steps}) > 1` ) ) - .orderBy(desc(funnelDefinitions.createdAt)); - }, - }) - ), + .orderBy(desc(funnelDefinitions.createdAt)), + }); + }), getById: protectedProcedure .route({ diff --git a/packages/rpc/src/routers/target-groups.ts b/packages/rpc/src/routers/target-groups.ts index c8bd110c1..9dba5d223 100644 --- a/packages/rpc/src/routers/target-groups.ts +++ b/packages/rpc/src/routers/target-groups.ts @@ -60,18 +60,12 @@ const targetGroupOutputSchema = z.record(z.string(), z.unknown()); const successOutputSchema = z.object({ success: z.literal(true) }); -interface TargetGroupWithRules { - createdBy?: unknown; - rules?: unknown; - [key: string]: unknown; -} - -function sanitizeGroupForDemo(group: T): T { - return { - ...group, - rules: Array.isArray(group.rules) ? [] : group.rules, - createdBy: "", - }; +function requireAuthedTargetGroupRead(workspace: { tier: "authed" | "demo" }) { + if (workspace.tier === "demo") { + throw rpcError.unauthorized( + "Target group definitions require authenticated workspace access" + ); + } } export const targetGroupsRouter = { @@ -92,15 +86,10 @@ export const targetGroupsRouter = { permissions: ["read"], }); - const sanitize = workspace.tier === "demo"; + requireAuthedTargetGroupRead(workspace); return targetGroupsCache.withCache({ - key: scopedCacheKey( - "list", - workspace, - `website:${input.websiteId}`, - `sanitize:${sanitize}` - ), + key: scopedCacheKey("list", workspace, `website:${input.websiteId}`), ttl: CACHE_DURATION, tables: ["target_groups"], queryFn: async () => { @@ -115,7 +104,7 @@ export const targetGroupsRouter = { ) .orderBy(desc(targetGroups.createdAt)); - return sanitize ? groupsList.map(sanitizeGroupForDemo) : groupsList; + return groupsList; }, }); }), @@ -134,15 +123,14 @@ export const targetGroupsRouter = { .use(withWebsiteRead) .handler(async ({ context, input }) => { const { workspace } = context; - const sanitize = workspace.tier === "demo"; + requireAuthedTargetGroupRead(workspace); return await targetGroupsCache.withCache({ key: scopedCacheKey( "byId", workspace, `website:${input.websiteId}`, - `id:${input.id}`, - `sanitize:${sanitize}` + `id:${input.id}` ), ttl: CACHE_DURATION, tables: ["target_groups"], @@ -157,7 +145,7 @@ export const targetGroupsRouter = { if (!row) { throw rpcError.notFound("Target group", input.id); } - return sanitize ? sanitizeGroupForDemo(row) : row; + return row; }, }); }),