Skip to content

Commit 1e42f7d

Browse files
authored
Merge pull request #508 from databuddy-analytics/staging
Release staging to main
2 parents 0a73d53 + d8ab8bf commit 1e42f7d

25 files changed

Lines changed: 776 additions & 286 deletions

File tree

.agents/skills/databuddy-internal/SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin
7979
- When running `bun install --lockfile-only`, preserve lockfile sync for pre-existing `package.json` changes instead of reverting them as unrelated.
8080
- Task runner: `turbo`
8181
- Formatting/linting: `bun run format`, `bun run lint`
82+
- Use neutral branch names, commit messages, and PR copy; do not include tool-attribution prefixes or generated-by language.
8283
- Lefthook's `no-secrets` guard intentionally ignores the exact `.env.example` template; real `.env`, `.env.*`, key, and credential files should still be blocked.
8384
- Root dev orchestration: `bun run dev`
8485
- Dashboard + API together: `bun run dev:dashboard`
@@ -105,6 +106,9 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin
105106
- 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.
106107
- 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.
107108
- 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.
109+
- 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.
110+
- 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.
111+
- Dashboard definitions for feature flags and target groups are admin surfaces; do not expose even sanitized rows to demo-tier/public website access.
108112
- 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).
109113
- 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.
110114
- 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.

apps/api/src/integration/cache-auth-bypass.test.ts

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import "@databuddy/test/env";
22

3-
import { targetGroups } from "@databuddy/db/schema";
3+
import { flags, targetGroups } from "@databuddy/db/schema";
44
import { appRouter, type Context } from "@databuddy/rpc";
55
import {
66
addToOrganization,
@@ -50,6 +50,22 @@ async function seedTargetGroup(websiteId: string, createdBy: string) {
5050
});
5151
}
5252

53+
async function seedFlag(websiteId: string, createdBy: string) {
54+
await db()
55+
.insert(flags)
56+
.values({
57+
id: randomUUIDv7(),
58+
websiteId,
59+
key: "secret-rollout",
60+
name: "Secret Rollout",
61+
description: "Internal rollout definition",
62+
defaultValue: true,
63+
status: "active",
64+
rules: [{ field: "country", operator: "equals", value: "US" }],
65+
createdBy,
66+
});
67+
}
68+
5369
const annotationsParams = (websiteId: string) =>
5470
({
5571
websiteId,
@@ -106,7 +122,7 @@ describe("cache-bypass auth: target-groups.list", () => {
106122
);
107123
});
108124

109-
iit("demo caller gets sanitized rules even when authed cache exists", async () => {
125+
iit("demo caller cannot read target groups even when authed cache exists", async () => {
110126
const { user, org, site } = await setupOwnedSite({ isPublic: true });
111127
await seedTargetGroup(site.id, user.id);
112128

@@ -116,12 +132,10 @@ describe("cache-bypass auth: target-groups.list", () => {
116132
)({ websiteId: site.id });
117133
expect((authed[0] as { rules: unknown[] }).rules).toHaveLength(1);
118134

119-
const demo = await call(
120-
appRouter.targetGroups.list,
121-
context()
122-
)({ websiteId: site.id });
123-
expect(demo).toHaveLength(1);
124-
expect((demo[0] as { rules: unknown[] }).rules).toEqual([]);
135+
await expectCode(
136+
call(appRouter.targetGroups.list, context())({ websiteId: site.id }),
137+
"UNAUTHORIZED"
138+
);
125139
});
126140

127141
iit("cross-org API key cannot read a website it does not own", async () => {
@@ -147,6 +161,7 @@ describe("cache-bypass auth: target-groups.list", () => {
147161
describe("cache-bypass auth: flags.list", () => {
148162
iit("anon caller cannot read flags after authed prime", async () => {
149163
const { user, org, site } = await setupOwnedSite();
164+
await seedFlag(site.id, user.id);
150165

151166
await call(
152167
appRouter.flags.list,
@@ -162,6 +177,7 @@ describe("cache-bypass auth: flags.list", () => {
162177
iit("cross-org user is rejected after authed prime", async () => {
163178
const a = await setupOwnedSite();
164179
const b = await setupOwnedSite();
180+
await seedFlag(a.site.id, a.user.id);
165181

166182
await call(
167183
appRouter.flags.list,
@@ -176,6 +192,41 @@ describe("cache-bypass auth: flags.list", () => {
176192
"FORBIDDEN"
177193
);
178194
});
195+
196+
iit("demo caller cannot read public-site flag definitions", async () => {
197+
const { user, org, site } = await setupOwnedSite({ isPublic: true });
198+
await seedFlag(site.id, user.id);
199+
200+
const authed = await call(
201+
appRouter.flags.list,
202+
userContext(user, org.id)
203+
)({ websiteId: site.id });
204+
expect(authed).toHaveLength(1);
205+
206+
await expectCode(
207+
call(appRouter.flags.list, context())({ websiteId: site.id }),
208+
"UNAUTHORIZED"
209+
);
210+
});
211+
212+
iit("demo caller cannot read public-site flag definitions by key", async () => {
213+
const { user, org, site } = await setupOwnedSite({ isPublic: true });
214+
await seedFlag(site.id, user.id);
215+
216+
const authed = await call(
217+
appRouter.flags.getByKey,
218+
userContext(user, org.id)
219+
)({ websiteId: site.id, key: "secret-rollout" });
220+
expect(authed).toMatchObject({ key: "secret-rollout" });
221+
222+
await expectCode(
223+
call(appRouter.flags.getByKey, context())({
224+
websiteId: site.id,
225+
key: "secret-rollout",
226+
}),
227+
"UNAUTHORIZED"
228+
);
229+
});
179230
});
180231

181232
describe("cache-bypass auth: annotations.list", () => {

apps/dashboard/app/(main)/websites/[id]/funnels/_components/funnel-item.tsx

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ interface FunnelItemProps {
4040
onDelete: (funnelId: string) => void;
4141
onEdit: (funnel: FunnelItemData) => void;
4242
onToggle: (funnelId: string) => void;
43+
readOnly?: boolean;
4344
}
4445

4546
function MiniFunnelPreview({
@@ -94,6 +95,7 @@ export function FunnelItem({
9495
onToggle,
9596
onEdit,
9697
onDelete,
98+
readOnly = false,
9799
className,
98100
children,
99101
}: FunnelItemProps) {
@@ -104,10 +106,14 @@ export function FunnelItem({
104106
return (
105107
<div className={cn("w-full", className)}>
106108
<List.Row
107-
className={cn(isExpanded && "bg-accent/30", isLast && "border-b-0")}
109+
className={cn(
110+
"gap-0 px-0 py-0",
111+
isExpanded && "bg-accent/30",
112+
isLast && "border-b-0"
113+
)}
108114
>
109115
<Button
110-
className="min-w-0 flex-1 justify-start gap-4 rounded-none bg-transparent p-0 text-left font-normal text-foreground hover:bg-transparent active:scale-100"
116+
className="min-h-15 min-w-0 flex-1 justify-start gap-4 rounded-none bg-transparent px-4 py-3 text-left font-normal text-foreground hover:bg-transparent active:scale-100"
111117
onClick={() => onToggle(funnel.id)}
112118
variant="ghost"
113119
>
@@ -188,38 +194,40 @@ export function FunnelItem({
188194
</span>
189195
</Button>
190196

191-
<List.Cell action>
192-
<DropdownMenu>
193-
<DropdownMenu.Trigger
194-
aria-label="Funnel actions"
195-
className="inline-flex size-8 items-center justify-center gap-1.5 rounded-md bg-transparent p-0 font-medium text-muted-foreground opacity-50 transition-all duration-(--duration-quick) ease-(--ease-smooth) hover:bg-interactive-hover hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:opacity-100"
196-
data-dropdown-trigger
197-
>
198-
<DotsThreeIcon className="size-5" weight="bold" />
199-
</DropdownMenu.Trigger>
200-
<DropdownMenu.Content align="end" className="w-40">
201-
<DropdownMenu.Item
202-
className="gap-2"
203-
onClick={() => onEdit(funnel)}
204-
>
205-
<PencilSimpleIcon className="size-4" weight="duotone" />
206-
Edit
207-
</DropdownMenu.Item>
208-
<DropdownMenu.Separator />
209-
<DropdownMenu.Item
210-
className="gap-2 text-destructive focus:text-destructive"
211-
onClick={() => onDelete(funnel.id)}
212-
variant="destructive"
197+
{!readOnly && (
198+
<List.Cell action className="flex items-center pr-4">
199+
<DropdownMenu>
200+
<DropdownMenu.Trigger
201+
aria-label="Funnel actions"
202+
className="inline-flex size-8 items-center justify-center gap-1.5 rounded-md bg-transparent p-0 font-medium text-muted-foreground opacity-50 transition-all duration-(--duration-quick) ease-(--ease-smooth) hover:bg-interactive-hover hover:text-foreground hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60 disabled:pointer-events-none disabled:opacity-50 data-[state=open]:opacity-100"
203+
data-dropdown-trigger
213204
>
214-
<TrashIcon
215-
className="size-4 fill-destructive"
216-
weight="duotone"
217-
/>
218-
Delete
219-
</DropdownMenu.Item>
220-
</DropdownMenu.Content>
221-
</DropdownMenu>
222-
</List.Cell>
205+
<DotsThreeIcon className="size-5" weight="bold" />
206+
</DropdownMenu.Trigger>
207+
<DropdownMenu.Content align="end" className="w-40">
208+
<DropdownMenu.Item
209+
className="gap-2"
210+
onClick={() => onEdit(funnel)}
211+
>
212+
<PencilSimpleIcon className="size-4" weight="duotone" />
213+
Edit
214+
</DropdownMenu.Item>
215+
<DropdownMenu.Separator />
216+
<DropdownMenu.Item
217+
className="gap-2 text-destructive focus:text-destructive"
218+
onClick={() => onDelete(funnel.id)}
219+
variant="destructive"
220+
>
221+
<TrashIcon
222+
className="size-4 fill-destructive"
223+
weight="duotone"
224+
/>
225+
Delete
226+
</DropdownMenu.Item>
227+
</DropdownMenu.Content>
228+
</DropdownMenu>
229+
</List.Cell>
230+
)}
223231
</List.Row>
224232

225233
{isExpanded ? (

apps/dashboard/app/(main)/websites/[id]/funnels/_components/funnels-list.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface FunnelsListProps {
1313
onDeleteFunnel: (funnelId: string) => void;
1414
onEditFunnel: (funnel: FunnelItemData) => void;
1515
onToggleFunnel: (funnelId: string) => void;
16+
readOnly?: boolean;
1617
}
1718

1819
export function FunnelsList({
@@ -23,6 +24,7 @@ export function FunnelsList({
2324
onToggleFunnel,
2425
onEditFunnel,
2526
onDeleteFunnel,
27+
readOnly = false,
2628
children,
2729
}: FunnelsListProps) {
2830
return (
@@ -38,6 +40,7 @@ export function FunnelsList({
3840
onDelete={onDeleteFunnel}
3941
onEdit={onEditFunnel}
4042
onToggle={onToggleFunnel}
43+
readOnly={readOnly}
4144
>
4245
{children?.(funnel)}
4346
</FunnelItem>

apps/dashboard/app/(main)/websites/[id]/funnels/page.tsx

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { CreateFunnelData } from "@/types/funnels";
1313
import { cn } from "@/lib/utils";
1414
import { GATED_FEATURES } from "@databuddy/shared/types/features";
1515
import dynamic from "next/dynamic";
16-
import { useParams } from "next/navigation";
16+
import { useParams, usePathname } from "next/navigation";
1717
import { useState } from "react";
1818
import { TopBar } from "@/components/layout/top-bar";
1919
import {
@@ -48,6 +48,8 @@ function FunnelsListSkeleton() {
4848
export default function FunnelsPage() {
4949
const { id } = useParams();
5050
const websiteId = id as string;
51+
const pathname = usePathname();
52+
const isDemoRoute = pathname.startsWith("/demo/");
5153
const { formattedDateRangeState, dateRange } = useDateFilters();
5254

5355
const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -148,19 +150,23 @@ export default function FunnelsPage() {
148150
className={cn("size-4 shrink-0", isFetching && "animate-spin")}
149151
/>
150152
</Button>
151-
<Button onClick={() => setEditing("new")} size="sm">
152-
<PlusIcon className="size-4 shrink-0" />
153-
Create Funnel
154-
</Button>
153+
{!isDemoRoute && (
154+
<Button onClick={() => setEditing("new")} size="sm">
155+
<PlusIcon className="size-4 shrink-0" />
156+
Create Funnel
157+
</Button>
158+
)}
155159
</TopBar.Actions>
156160

157161
<div className="min-h-0 flex-1 overflow-y-auto overscroll-none">
158162
<List.Content
159163
emptyProps={{
160-
action: {
161-
label: "Create a funnel",
162-
onClick: () => setEditing("new"),
163-
},
164+
action: isDemoRoute
165+
? undefined
166+
: {
167+
label: "Create a funnel",
168+
onClick: () => setEditing("new"),
169+
},
164170
description:
165171
"Define a multi-step journey to see where users drop off.",
166172
icon: <FunnelIcon className="size-6" weight="duotone" />,
@@ -189,6 +195,7 @@ export default function FunnelsPage() {
189195
setExpandedId(expandedId === funnelId ? null : funnelId);
190196
setSelectedReferrer("all");
191197
}}
198+
readOnly={isDemoRoute}
192199
>
193200
{(funnel) => {
194201
if (expandedId !== funnel.id) {
@@ -220,7 +227,7 @@ export default function FunnelsPage() {
220227
</List.Content>
221228
</div>
222229

223-
{editing !== null && (
230+
{!isDemoRoute && editing !== null && (
224231
<EditFunnelDialog
225232
autocompleteData={autocomplete.data}
226233
funnel={
@@ -241,7 +248,7 @@ export default function FunnelsPage() {
241248
/>
242249
)}
243250

244-
{!!deletingId && (
251+
{!isDemoRoute && !!deletingId && (
245252
<DeleteDialog
246253
confirmLabel="Delete Funnel"
247254
isOpen={!!deletingId}

0 commit comments

Comments
 (0)