Skip to content

Commit dd2fa1a

Browse files
committed
fix(api): allow public analytics builders
1 parent 0323dab commit dd2fa1a

7 files changed

Lines changed: 197 additions & 44 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin
106106
- Flags list rows (`app/(main)/websites/[id]/flags/_components/flags-list.tsx`) are clickable containers with nested controls; mark nested controls with `data-row-interactive="true"` and have the row ignore those targets instead of relying on broad cell-level `stopPropagation`.
107107
- Never put interactive controls inside another `<button>` on dashboard rows. If a row has actions/menus, make the main row content a sibling `Button` and keep action buttons as separate siblings; do not use a `div` with click/key handlers as a fake button.
108108
- For data loading and mutations, inspect `apps/dashboard/lib/orpc.ts` and the corresponding hooks/components
109+
- Public/demo analytics data still flows through `apps/api/src/routes/query.ts`; public website access is controlled by per-query-builder `publicAccess`, not only oRPC metadata.
109110
- Many changes require matching edits in `packages/rpc`
110111

111112
### API and RPC work
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from "vitest";
2+
import { isPublicQueryAccess } from "./public-query-access";
3+
4+
describe("isPublicQueryAccess", () => {
5+
it("allows only query types explicitly marked public-readable", () => {
6+
expect(
7+
isPublicQueryAccess([
8+
"summary_metrics",
9+
"top_pages",
10+
"custom_events_summary",
11+
"recent_errors",
12+
"vitals_overview",
13+
])
14+
).toBe(true);
15+
});
16+
17+
it("denies revenue, unknown, and empty public query requests", () => {
18+
expect(isPublicQueryAccess(["revenue_overview"])).toBe(false);
19+
expect(isPublicQueryAccess(["summary_metrics", "revenue_overview"])).toBe(
20+
false
21+
);
22+
expect(isPublicQueryAccess(["missing_query_type"])).toBe(false);
23+
expect(isPublicQueryAccess([])).toBe(false);
24+
});
25+
});
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { QueryBuilders } from "@databuddy/ai/query/builders";
2+
3+
export function isPublicQueryAccess(queryTypes: string[]): boolean {
4+
return (
5+
queryTypes.length > 0 &&
6+
queryTypes.every((type) => QueryBuilders[type]?.publicAccess === true)
7+
);
8+
}

apps/api/src/routes/query.ts

Lines changed: 7 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import type { Filter, QueryRequest } from "@databuddy/ai/query/types";
3030
import { Elysia, t } from "elysia";
3131
import { getAccessibleWebsites } from "../lib/accessible-websites";
3232
import { resolveDatePreset } from "../lib/date-presets";
33+
import { isPublicQueryAccess } from "../lib/public-query-access";
3334
import { mergeWideEvent } from "../lib/tracing";
3435
import { getCachedWebsiteDomain, getWebsiteDomain } from "../lib/website-utils";
3536
import {
@@ -411,45 +412,15 @@ async function getOrganizationWebsiteIds(
411412
return websites.map((website) => website.id);
412413
}
413414

414-
// Keep this in sync with the public/demo overview dashboard query set.
415-
const PUBLIC_OVERVIEW_QUERY_TYPES = new Set([
416-
"summary_metrics",
417-
"today_metrics",
418-
"active_stats",
419-
"events_by_date",
420-
"traffic_sources",
421-
"top_pages",
422-
"entry_pages",
423-
"exit_pages",
424-
"page_time_analysis",
425-
"top_referrers",
426-
"utm_sources",
427-
"utm_mediums",
428-
"utm_campaigns",
429-
"country",
430-
"region",
431-
"city",
432-
"device_types",
433-
"browser_name",
434-
"browsers",
435-
"os_name",
436-
"operating_systems",
437-
"outbound_links",
438-
"outbound_domains",
439-
"realtime_pages",
440-
"realtime_referrers",
441-
"realtime_countries",
442-
"realtime_cities",
443-
"realtime_sessions",
444-
"realtime_velocity",
445-
]);
446-
447415
const FEATURE_GATED_QUERY_TYPES: Record<string, GatedFeatureId> = {
448416
recent_errors: GATED_FEATURES.ERROR_TRACKING,
449417
error_types: GATED_FEATURES.ERROR_TRACKING,
450418
errors_by_page: GATED_FEATURES.ERROR_TRACKING,
451419
error_summary: GATED_FEATURES.ERROR_TRACKING,
452420
error_chart_data: GATED_FEATURES.ERROR_TRACKING,
421+
error_trends: GATED_FEATURES.ERROR_TRACKING,
422+
error_frequency: GATED_FEATURES.ERROR_TRACKING,
423+
errors_by_type: GATED_FEATURES.ERROR_TRACKING,
453424
};
454425

455426
async function enforceFeatureGatesForQueryTypes(
@@ -491,13 +462,6 @@ function extractQueryTypes(
491462
);
492463
}
493464

494-
function isOverviewOnlyAccess(queryTypes: string[]): boolean {
495-
return (
496-
queryTypes.length > 0 &&
497-
queryTypes.every((t) => PUBLIC_OVERVIEW_QUERY_TYPES.has(t))
498-
);
499-
}
500-
501465
async function verifyWebsiteAccess(
502466
ctx: AuthContext,
503467
websiteId: string,
@@ -515,8 +479,8 @@ async function verifyWebsiteAccess(
515479
return false;
516480
}
517481

518-
if (website.isPublic && isOverviewOnlyAccess(queryTypes)) {
519-
mergeWideEvent({ access_result: "public_overview" });
482+
if (website.isPublic && isPublicQueryAccess(queryTypes)) {
483+
mergeWideEvent({ access_result: "public_query" });
520484
return true;
521485
}
522486

@@ -1100,6 +1064,7 @@ export const query = new Elysia({ prefix: "/v1/query" })
11001064
allowedFilters: cfg.allowedFilters ?? DEFAULT_ALLOWED_FILTERS,
11011065
customizable: cfg.customizable,
11021066
defaultLimit: cfg.limit,
1067+
publicAccess: cfg.publicAccess === true,
11031068
...(includeMeta && { meta: cfg.meta }),
11041069
},
11051070
])

packages/ai/src/query/builders/index.ts

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ import { SummaryBuilders } from "./summary";
1414
import { TrafficBuilders } from "./traffic";
1515
import { UptimeBuilders } from "./uptime";
1616
import { VitalsBuilders } from "./vitals";
17+
import type { SimpleQueryConfig } from "../types";
1718

18-
export const QueryBuilders = {
19+
const BASE_QUERY_BUILDERS = {
1920
...SummaryBuilders,
2021
...PagesBuilders,
2122
...TrafficBuilders,
@@ -33,6 +34,74 @@ export const QueryBuilders = {
3334
...UptimeBuilders,
3435
...RevenueBuilders,
3536
...RealtimeBuilders,
36-
};
37+
} satisfies Record<string, SimpleQueryConfig>;
38+
39+
export const PUBLIC_QUERY_TYPES = new Set<string>([
40+
// Overview dashboard
41+
"summary_metrics",
42+
"today_metrics",
43+
"active_stats",
44+
"events_by_date",
45+
"top_pages",
46+
"entry_pages",
47+
"exit_pages",
48+
"page_time_analysis",
49+
"traffic_sources",
50+
"top_referrers",
51+
"utm_sources",
52+
"utm_mediums",
53+
"utm_campaigns",
54+
"device_types",
55+
"browser_name",
56+
"browsers",
57+
"os_name",
58+
"operating_systems",
59+
"outbound_links",
60+
"outbound_domains",
61+
"country",
62+
"region",
63+
"city",
64+
65+
// Public product analytics tabs
66+
"custom_events",
67+
"custom_event_properties",
68+
"custom_events_by_path",
69+
"custom_events_trends",
70+
"custom_events_trends_by_event",
71+
"custom_events_summary",
72+
"custom_events_property_cardinality",
73+
"custom_events_recent",
74+
"custom_events_property_classification",
75+
"custom_events_property_top_values",
76+
"custom_events_property_distribution",
77+
"custom_events_discovery",
78+
79+
// Public error diagnostics
80+
"recent_errors",
81+
"error_types",
82+
"error_trends",
83+
"errors_by_page",
84+
"error_frequency",
85+
"error_summary",
86+
"error_chart_data",
87+
"errors_by_type",
88+
89+
// Public web-vitals diagnostics
90+
"vitals_overview",
91+
"vitals_time_series",
92+
"vitals_by_page",
93+
"vitals_by_country",
94+
"vitals_by_browser",
95+
"vitals_by_region",
96+
"vitals_by_city",
97+
"performance_overview",
98+
] as const);
99+
100+
export const QueryBuilders = Object.fromEntries(
101+
Object.entries(BASE_QUERY_BUILDERS).map(([type, config]) => [
102+
type,
103+
PUBLIC_QUERY_TYPES.has(type) ? { ...config, publicAccess: true } : config,
104+
])
105+
) as typeof BASE_QUERY_BUILDERS;
37106

38107
export type QueryType = keyof typeof QueryBuilders;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { describe, expect, it } from "vitest";
2+
import { PUBLIC_QUERY_TYPES, QueryBuilders } from "./index";
3+
4+
const PUBLIC_OVERVIEW_QUERY_TYPES = [
5+
"summary_metrics",
6+
"today_metrics",
7+
"events_by_date",
8+
"top_pages",
9+
"entry_pages",
10+
"exit_pages",
11+
"page_time_analysis",
12+
"traffic_sources",
13+
"top_referrers",
14+
"utm_sources",
15+
"utm_mediums",
16+
"utm_campaigns",
17+
"device_types",
18+
"browsers",
19+
"operating_systems",
20+
"outbound_links",
21+
"outbound_domains",
22+
"country",
23+
] as const;
24+
25+
const PUBLIC_EVENTS_QUERY_TYPES = [
26+
"custom_events",
27+
"custom_events_summary",
28+
"custom_events_trends",
29+
"custom_events_trends_by_event",
30+
"custom_events_property_classification",
31+
"custom_events_property_distribution",
32+
"custom_events_property_top_values",
33+
"custom_events_recent",
34+
] as const;
35+
36+
const PUBLIC_ERROR_QUERY_TYPES = [
37+
"recent_errors",
38+
"error_types",
39+
"errors_by_page",
40+
"error_summary",
41+
"error_chart_data",
42+
] as const;
43+
44+
const PUBLIC_VITALS_QUERY_TYPES = [
45+
"vitals_overview",
46+
"vitals_time_series",
47+
"vitals_by_page",
48+
"vitals_by_country",
49+
"vitals_by_browser",
50+
"vitals_by_region",
51+
"vitals_by_city",
52+
] as const;
53+
54+
describe("query builder publicAccess", () => {
55+
it("keeps the public query registry in sync with real builders", () => {
56+
for (const type of PUBLIC_QUERY_TYPES) {
57+
expect(QueryBuilders[type], type).toBeDefined();
58+
}
59+
});
60+
61+
it("marks public dashboard query families as public-readable", () => {
62+
const publicTypes = [
63+
...PUBLIC_OVERVIEW_QUERY_TYPES,
64+
...PUBLIC_EVENTS_QUERY_TYPES,
65+
...PUBLIC_ERROR_QUERY_TYPES,
66+
...PUBLIC_VITALS_QUERY_TYPES,
67+
];
68+
69+
for (const type of publicTypes) {
70+
expect(QueryBuilders[type]?.publicAccess, type).toBe(true);
71+
}
72+
});
73+
74+
it("keeps revenue builders private even for public websites", () => {
75+
const revenueTypes = Object.keys(QueryBuilders).filter((type) =>
76+
type.startsWith("revenue_") || type === "recent_transactions"
77+
);
78+
79+
expect(revenueTypes.length).toBeGreaterThan(0);
80+
for (const type of revenueTypes) {
81+
expect(QueryBuilders[type]?.publicAccess, type).not.toBe(true);
82+
}
83+
});
84+
});

packages/ai/src/query/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,7 @@ export interface SimpleQueryConfig {
146146
noCache?: boolean;
147147
orderBy?: string;
148148
plugins?: QueryPlugins;
149+
publicAccess?: boolean;
149150
requiredFilters?: string[];
150151
skipDateFilter?: boolean;
151152
table?: string;

0 commit comments

Comments
 (0)