Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
135 commits
Select commit Hold shift + click to select a range
89c5b61
fix(ai): resolve and validate trait filters centrally
izadoesdev Jul 4, 2026
b9d5abe
fix(dashboard): stop session guard from revoking sessions on transien…
izadoesdev Jul 4, 2026
ed9e2d1
fix(tracker): assert the real vitals payload shape in e2e test
izadoesdev Jul 4, 2026
7faac1c
chore(staging): merge main
izadoesdev Jul 4, 2026
1f177bd
feat(dashboard): lifetime value column on users page
izadoesdev Jul 4, 2026
cd7a4e1
feat(dashboard): find users by email via deterministic hash lookup
izadoesdev Jul 4, 2026
0088484
feat(basket): attribute recurring stripe revenue via invoice.paid wit…
izadoesdev Jul 4, 2026
b5644ab
feat(dashboard): list invoice.paid as required stripe webhook event
izadoesdev Jul 4, 2026
76ffe29
docs(integrations): document invoice.paid subscription attribution
izadoesdev Jul 4, 2026
68f8085
feat(dashboard): improve OpenAI Ads signup attribution
izadoesdev Jul 4, 2026
3eb3ddc
fix(basket): reuse matching payment intent row when invoice lacks pay…
izadoesdev Jul 4, 2026
5da4ae8
refactor(basket): collapse five stripe revenue inserts into one helper
izadoesdev Jul 4, 2026
5d6818b
fix(dashboard): net refund signs in ltv and profile revenue, exclude …
izadoesdev Jul 4, 2026
3fdd4e0
feat(ai): list_profile_traits tool surfaces trait distribution to agents
izadoesdev Jul 4, 2026
c898380
fix(ai): sanitize public query errors behind an allowlist
izadoesdev Jul 4, 2026
f39c18f
feat(evals): trait segmentation eval cases
izadoesdev Jul 4, 2026
8cc0125
fix(dashboard): drop redundant has_active_subscription trait
izadoesdev Jul 4, 2026
077eb2f
fix(basket): record attempted amount on failed invoices and carry att…
izadoesdev Jul 4, 2026
c98bdf8
fix(dashboard): ltv tooltip and email search pending state
izadoesdev Jul 4, 2026
779b86b
style(ai): drop em dashes from tool descriptions and payments docs
izadoesdev Jul 4, 2026
971b1bd
fix(services): rank trait distribution per key and stabilize email lo…
izadoesdev Jul 4, 2026
b9376f6
fix(ai): key query error messaging on error type not input shape
izadoesdev Jul 4, 2026
96fc362
fix(dashboard): distinguish email search errors from misses and harde…
izadoesdev Jul 4, 2026
faa539f
fix(basket): keep failed invoices at zero amount to preserve the reve…
izadoesdev Jul 4, 2026
c3a0be2
refactor(ai): gate query error telemetry on exported sanitized constant
izadoesdev Jul 4, 2026
6fc4230
fix(slack): abort active runs on shutdown and add run timeout
izadoesdev Jul 4, 2026
9993635
feat(db): add source, website, conversation, and metadata to feedback
izadoesdev Jul 4, 2026
1f4c903
feat(services): shared feedback submission with source-aware slack alert
izadoesdev Jul 4, 2026
9972240
feat(ai): submit_feedback tool for agent and slack with consent-aware…
izadoesdev Jul 4, 2026
31cadcf
feat(evals): feedback tool behavior eval cases
izadoesdev Jul 4, 2026
7cda26c
fix(db): index feedback website_id fkey
izadoesdev Jul 4, 2026
31059cd
refactor(ai): single source for submit_feedback prompt rules
izadoesdev Jul 4, 2026
96689f4
feat(dashboard): feedback-preview chat component with send and receip…
izadoesdev Jul 4, 2026
6339708
feat(ai): vague-report gate and routing for feedback requests
izadoesdev Jul 4, 2026
f0dca3e
feat(evals): vague complaint feedback eval case
izadoesdev Jul 4, 2026
7c9b0d0
feat(charts): server-side chart PNG rendering with dashboard theme
izadoesdev Jul 4, 2026
195885f
feat(slack): attach agent charts as PNG uploads with table fallback
izadoesdev Jul 4, 2026
b513ffb
feat(insights): add noise floors and exclude the partial current day …
izadoesdev Jul 7, 2026
3a624eb
feat(insights): redesign slack digest with block-kit cards, cap, and …
izadoesdev Jul 7, 2026
708e502
feat(insights): silence low-signal digests, escalate recurrences, and…
izadoesdev Jul 7, 2026
24b7284
feat(evals): add voice and impact-line insight eval cases
izadoesdev Jul 7, 2026
e97a886
feat(insights): keep still-open criticals visible as persistent remin…
izadoesdev Jul 7, 2026
0057544
fix(insights): make reflection schema strict-mode safe and force rewr…
izadoesdev Jul 7, 2026
a6b0acb
fix(insights): run reflection on a priced model and guard against cos…
izadoesdev Jul 7, 2026
0e19bfe
perf(ai): make investigate reliable with a deterministic sweep and gr…
izadoesdev Jul 7, 2026
88ed1f3
fix(ai): address investigate review — reachable fallback, failed-quer…
izadoesdev Jul 7, 2026
bca14ab
fix(rpc): commit the drizzle-orm dep the lockfile already expects
izadoesdev Jul 7, 2026
0b3115d
fix(insights): address digest review findings
izadoesdev Jul 7, 2026
cc5ddf8
fix(rpc): commit the drizzle-orm dep the lockfile already expects
izadoesdev Jul 7, 2026
cb6967d
Merge pull request #543 from databuddy-analytics/izadoesdev/insights-…
izadoesdev Jul 7, 2026
d6ca717
Merge pull request #544 from databuddy-analytics/izadoesdev/investiga…
izadoesdev Jul 7, 2026
f4b4c6e
refactor(rpc): extract link schemas with drizzle-zod and tighten link…
izadoesdev Jul 7, 2026
d909f3f
refactor(dashboard): streamline links UI components and use-links hook
izadoesdev Jul 7, 2026
df57d87
fix(og): reset the cached OG fonts promise when font loading fails
izadoesdev Jul 7, 2026
a89f7fe
fix(tracker): mask-pathname trims trailing empty segments and never r…
izadoesdev Jul 7, 2026
555a610
chore(skills): update databuddy-internal skill notes
izadoesdev Jul 7, 2026
fd465f9
chore(repo): stop tracking dump.rdb (Redis snapshot, already gitignored)
izadoesdev Jul 7, 2026
5a76ec8
fix(rpc): filter soft-deleted links in getLinkOrThrow
izadoesdev Jul 7, 2026
0a2288d
Merge pull request #545 from databuddy-analytics/izadoesdev/links-ref…
izadoesdev Jul 7, 2026
7fab18d
fix(observability): stop local dev from draining telemetry to the pro…
izadoesdev Jul 8, 2026
b3fb08d
fix(slack): add libgcc_s.so.1 to the distroless runtime so resvg loads
izadoesdev Jul 8, 2026
1c29ab2
Merge pull request #546 from databuddy-analytics/izadoesdev/gate-dev-…
izadoesdev Jul 8, 2026
8ac8c21
Merge pull request #547 from databuddy-analytics/izadoesdev/fix-slack…
izadoesdev Jul 8, 2026
3b3a4ab
fix(slack): ship the chart font assets into the distroless runtime
izadoesdev Jul 8, 2026
3ec85f4
fix(sdk): emit $flag_evaluated on cache hits, not only cache misses
izadoesdev Jul 8, 2026
035aeed
feat(rpc): paginated links endpoint with server-side search, sort, an…
izadoesdev Jul 8, 2026
ae9b127
feat(dashboard): virtualized paginated links list with folder filter
izadoesdev Jul 8, 2026
43bd8a6
feat(insights): dedicated insight detail page
izadoesdev Jul 8, 2026
7d9bdc1
feat(dashboard): open funnel creation via ?new=funnel deep link
izadoesdev Jul 8, 2026
635883e
docs(db): add ClickHouse schema reference SQL files
izadoesdev Jul 8, 2026
df2be14
feat(db): generate ClickHouse types from DDL and verify schema drift
izadoesdev Jul 9, 2026
0c42b73
refactor(db): apply ClickHouse schema from .sql files, drop inline DDL
izadoesdev Jul 9, 2026
0ed325e
chore(db): drop ErrorHourlyAggregate type for the undeployed error_ho…
izadoesdev Jul 9, 2026
3509656
feat(db): generate <Table>Insert types alongside <Table>Row
izadoesdev Jul 9, 2026
0ededd8
refactor(basket): insert events as EventsInsert, drop columns the tab…
izadoesdev Jul 9, 2026
7559c09
refactor(mapper): import events as EventsInsert, drop columns the tab…
izadoesdev Jul 9, 2026
ae404dc
refactor(db): type analytics field maps from generated Row types, ret…
izadoesdev Jul 9, 2026
bafeffc
refactor(basket): type error/vitals/outgoing-link inserts from genera…
izadoesdev Jul 9, 2026
f1a7e5c
refactor(db): delete 10 hand-written row interfaces superseded by gen…
izadoesdev Jul 9, 2026
5c610f4
refactor(basket): type blocked traffic from generated BlockedTrafficI…
izadoesdev Jul 9, 2026
c87c25d
fix(dashboard): count onboarding completion when users enter via expl…
izadoesdev Jul 9, 2026
a358d34
test(db): verify profile_id against the .sql schema, not the retired …
izadoesdev Jul 9, 2026
231b3d9
test(basket): align buildTrackEvent assertions with the trimmed Event…
izadoesdev Jul 9, 2026
922653c
test(ai): preload dummy DATABASE_URL/REDIS_URL so db-client init does…
izadoesdev Jul 9, 2026
a3a2aac
feat(insights): rate-limited getById endpoint and optimistic dismiss …
izadoesdev Jul 9, 2026
94aa4d6
fix(dashboard): don't auto-open new-funnel dialog on demo routes
izadoesdev Jul 9, 2026
e67ecc5
feat(db): drift-lock agent/table registries to generated TABLE_COLUMNS
izadoesdev Jul 10, 2026
b351bc0
fix(ai): drop phantom outgoing_links.path from schema prompt, drift-t…
izadoesdev Jul 10, 2026
0a52ec0
fix(dashboard): drop phantom events.load_time from query-builder sche…
izadoesdev Jul 10, 2026
293cb3a
chore(api): delete unused duplicate ClickHouse tables type map
izadoesdev Jul 10, 2026
3b4855e
fix(email): clarify transactional messages and delivery failures
izadoesdev Jul 11, 2026
3460383
fix(dashboard): clarify billing auth and recovery states
izadoesdev Jul 11, 2026
4d24e13
fix(api): return actionable errors and request identifiers
izadoesdev Jul 11, 2026
ea7083d
fix(links): preserve redirect destinations exactly
izadoesdev Jul 11, 2026
7d8c426
fix(slack): clarify setup and interrupted responses
izadoesdev Jul 11, 2026
9918e3f
fix(status): distinguish stale and unknown health
izadoesdev Jul 11, 2026
729c8dc
fix(sdk): expose delivery and flag failures
izadoesdev Jul 11, 2026
b853c52
docs(product): align public copy with current behavior
izadoesdev Jul 11, 2026
492ca04
chore(skills): record communication guardrails
izadoesdev Jul 11, 2026
1c2c6f9
fix(alerts): preserve preferences and clarify failures
izadoesdev Jul 11, 2026
9a998d1
fix(sdk): isolate flag state across identities
izadoesdev Jul 11, 2026
5872e8e
fix(tracker): honor opt-out before storing or sending
izadoesdev Jul 11, 2026
f60f31e
test(api): cover ambiguous billing organizations
izadoesdev Jul 11, 2026
6e8dd30
fix(tracker): abort delivery when privacy state changes
izadoesdev Jul 11, 2026
795c618
fix(alerts): keep unresolved deliveries retryable
izadoesdev Jul 11, 2026
386a858
fix(sdk): isolate every flag evaluation context
izadoesdev Jul 11, 2026
3bceb3f
feat(tracker): capture page path on custom events and hash routes
izadoesdev Jul 11, 2026
a0bb00a
feat(basket): persist page path on custom events
izadoesdev Jul 11, 2026
d34e824
feat(shared): add insights domain types and helpers
izadoesdev Jul 11, 2026
cbfb23d
feat(redis): add insights job queue
izadoesdev Jul 11, 2026
1e92b0c
feat(db): add insights schema and SQL validation updates
izadoesdev Jul 11, 2026
bc05e53
feat(ai): rework insights generation, agent tools, and MCP wiring
izadoesdev Jul 11, 2026
6ba6452
feat(rpc): add insight generation routers and scheduling
izadoesdev Jul 11, 2026
f8d35d4
feat(evals): update insights and slack-thread eval cases
izadoesdev Jul 11, 2026
f5f9714
feat(insights): overhaul detection, investigation, and delivery pipeline
izadoesdev Jul 11, 2026
957906f
fix(ui): update dropdown-menu component
izadoesdev Jul 11, 2026
f59ebaf
feat(dashboard): rebrand insights to Findings and refresh feed UI
izadoesdev Jul 11, 2026
d74769c
docs(marketing): refresh marketing pages and product copy
izadoesdev Jul 11, 2026
342d0f5
docs(agents): update databuddy-internal skill
izadoesdev Jul 11, 2026
1abc702
chore(deps): sync lockfile
izadoesdev Jul 11, 2026
52f1dd8
fix(docs): align tracker size claim with 11 KB bundle
izadoesdev Jul 11, 2026
a357b7e
fix(docs): correct remaining tracker size claims to 11 KB gzip
izadoesdev Jul 11, 2026
0ecca50
fix(rpc): close insight existence oracle in getById and related
izadoesdev Jul 11, 2026
fb0a4e1
fix(insights): guard timezone fallback in funnel detection
izadoesdev Jul 11, 2026
e59e96e
fix(dashboard): harden insight freshness, change formatting, and opti…
izadoesdev Jul 11, 2026
b027c0d
refactor(rpc): extract enforceRateLimit helper for insight reads
izadoesdev Jul 11, 2026
d00a038
fix(rpc): only swallow auth denials when reading insights
izadoesdev Jul 11, 2026
815dd9a
fix(db): harden ClickHouse DDL parser and credential encoding
izadoesdev Jul 11, 2026
3c0c34d
fix(ai): validate request before filter-field checks
izadoesdev Jul 11, 2026
2fa7288
fix(slack): surface charts dropped over the upload cap
izadoesdev Jul 11, 2026
134abf7
fix(charts): sort distribution slices and clamp legend width
izadoesdev Jul 11, 2026
9124812
chore(ci): guard schema codegen hook and widen turbo generate inputs
izadoesdev Jul 11, 2026
717a04d
docs(api): correct search_memory scope description
izadoesdev Jul 11, 2026
acec544
fix(dashboard): validate auth callbacks and harden error and UI edge …
izadoesdev Jul 11, 2026
e8ba85d
fix(slack): drain runs added during shutdown wait
izadoesdev Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
8 changes: 7 additions & 1 deletion .agents/skills/databuddy-internal/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno
- Shared agent integrations should call `@databuddy/ai/agent` (`askDatabuddyAgent` / `streamDatabuddyAgent`) instead of importing internal MCP run/history helpers directly.
- First-party ads attribution work should start by preserving UTMs into registration and signup events only; do not add RPC plumbing, conversion destinations, env hooks, tables, workers, or UI until explicitly needed.
- Insights generation logic belongs in `apps/insights` and should reuse `@databuddy/ai`; `apps/api` should only read insight data or queue runs, not own prompts, model calls, tool loops, validation, or persistence orchestration.
- Automated insight digests have one organization-wide schedule (`off`, `daily`, or `weekly`) and one organization-wide delivery set; website selection is only for manual runs. Do not reintroduce per-website overrides, hourly/custom cadence, or cron input.
- Insight run items are execution metadata, not rendered insight content; previews should use run status/counts or query real insights, never infer titles or bodies from run items.
- Insight Slack delivery must resolve each channel binding to its active same-organization integration; never choose an arbitrary organization bot token.
- Agent ClickHouse SQL must use the canonical analytics.events schema: `client_id`, `time`, `path`, `event_name`, and pageviews as `event_name = 'screen_view'`; never `website_id`, `created_at`, `page_path`, `event_type`, or `pageview`.
- Slack agent evals live in `packages/evals`: use `bun run eval --surface slack` for the whole Slack surface. `--tag slack` is only a tiny smoke subset, and `cost_fallback` in agent telemetry is pricing-catalog fallback, not proof the model request fell back.
- Slack agent expected stops such as exhausted Databunny credits should throw `DatabuddyAgentUserError` from `@databuddy/ai/agent/errors`; Slack surfaces those messages directly and reserves the generic reconnect copy for real infrastructure failures.
Expand All @@ -53,6 +56,7 @@ Keep additions **minimal**: one bullet, a new `rg` hint, or a routing note—eno
- Public status pages render from `apps/status`; `apps/dashboard` owns status-page management/config UI only. When cleaning public status UX, update shared `@databuddy/ui/uptime` pieces or `apps/status` wrappers instead of redesigning dashboard-only route remnants.
- `packages/db`: Drizzle Postgres schema, client, and ClickHouse helpers
- `packages/rpc`: shared oRPC router, procedures, auth-aware server context
- `packages/rpc` must declare `drizzle-orm: "catalog:"` before importing `drizzle-orm/*` helpers such as `drizzle-orm/zod`; otherwise TypeScript can resolve a different Drizzle instance than `@databuddy/db` and reject table-derived schemas.
- `packages/auth`: Better Auth setup, permissions, organization access
- `packages/env`: per-app env schemas
- `packages/shared`: shared types, flags, analytics schemas, utilities
Expand Down Expand Up @@ -109,7 +113,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin
- 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 feed (`use-insights-feed`) collapses persisted history by `insightSignalDedupeKey` in `apps/dashboard/lib/insight-signal-key.ts` so the list is one row per signal (latest wins); reads must not invoke AI generation.
- 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.
- Website analytics filters are two-way synced between Jotai and the `filters` URL param in `app/(main)/websites/[id]/layout.tsx`; guard URL-driven atom writes from echoing stale atom state back into `nuqs`, or adding a filter can lock the page during form submit.
Expand Down Expand Up @@ -139,6 +143,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin

## Billing (Autumn)

- Transactional billing email identity has three separate concepts: Autumn customer/billing owner, organization, and actual `to` recipient. Only personalize from the actual recipient record; if it is unavailable, omit the greeting rather than using the owner name. Keep `agent_credits` as an internal feature ID, but describe it to customers as Databunny usage or an AI analysis allowance and explain what it enables.
- `autumn-js` v1.2.2+ — import `autumnHandler` from `autumn-js/fetch` (NOT `autumn-js/elysia`, that export was removed in v1.0)
- For Elysia, mount with `.mount(autumnHandler(...))` — NOT `.use()`
- `identify` callback receives `(request: Request)` directly, not `({ request })`
Expand Down Expand Up @@ -168,6 +173,7 @@ Read [codebase-map.md](./references/codebase-map.md) when you need deeper routin

- Published SDK logic: `packages/sdk/src`
- Browser tracker bundle: `packages/tracker/src`
- When a retryable batch failure restores events to an in-memory queue, it must also restore an automatic retry timer with capped backoff; requeueing alone silently stalls delivery.
- Public SDK/tracker visitor ID privacy is only `anonymizeVisitorIds` (`true`/omitted = anonymized, `false` = raw IDs, `"auto"` = raw only in Databuddy's conservative country allowlist).
- Keep visitor ID privacy internals small and direct; avoid exported helper stacks or storage/hashing vocabulary for this option.
- If the user reports missing analytics events, inspect both the producer side and `apps/basket`
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ jobs:
name: Check Types
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 15
env:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The CLICKHOUSE_READONLY_URL secret (which includes ClickHouse cluster credentials) is defined at the job-level env, so it is injected into every step in the typecheck job — including bun install, docs postinstall, check-types, and ch:check — even though only the final ch:verify step connects to the live cluster. Scoping the secret to only the step that needs it reduces blast radius if any command or dependency accidentally logs environment variables.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At .github/workflows/ci.yml, line 46:

<comment>The `CLICKHOUSE_READONLY_URL` secret (which includes ClickHouse cluster credentials) is defined at the job-level `env`, so it is injected into every step in the `typecheck` job — including `bun install`, docs postinstall, `check-types`, and `ch:check` — even though only the final `ch:verify` step connects to the live cluster. Scoping the secret to only the step that needs it reduces blast radius if any command or dependency accidentally logs environment variables.</comment>

<file context>
@@ -43,6 +43,8 @@ jobs:
     name: Check Types
     runs-on: blacksmith-4vcpu-ubuntu-2404
     timeout-minutes: 15
+    env:
+      CLICKHOUSE_READONLY_URL: ${{ secrets.CLICKHOUSE_READONLY_URL }}
     steps:
</file context>

CLICKHOUSE_READONLY_URL: ${{ secrets.CLICKHOUSE_READONLY_URL }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
Expand All @@ -56,6 +58,11 @@ jobs:
- run: bun install --frozen-lockfile --ignore-scripts
- run: bun run --cwd apps/docs postinstall
- run: bun run check-types
- name: ClickHouse types match DDL
run: bun run --cwd packages/db ch:check
- name: ClickHouse schema matches cluster
if: env.CLICKHOUSE_READONLY_URL != ''
run: bun run --cwd packages/db ch:verify

test:
name: Test
Expand Down
45 changes: 38 additions & 7 deletions apps/api/src/http/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createError } from "evlog";
import { t, ValidationError } from "elysia";
import { afterEach, describe, expect, it } from "vitest";
import { handleAppError } from "./errors";

Expand All @@ -16,6 +17,7 @@ describe("handleAppError", () => {
it("masks structured 5xx details in production", async () => {
process.env.NODE_ENV = "production";
const response = handleAppError({
requestId: "req_test_5xx",
error: createError({
code: "api.SECRET_FAILURE",
message: "Database password leaked into error",
Expand All @@ -27,16 +29,19 @@ describe("handleAppError", () => {
});

expect(response.status).toBe(500);
expect(await readPayload(response)).toEqual({
success: false,
error: "An internal server error occurred",
code: "api.SECRET_FAILURE",
});
expect(await readPayload(response)).toEqual({
success: false,
error: "An internal server error occurred",
code: "api.SECRET_FAILURE",
requestId: "req_test_5xx",
});
expect(response.headers.get("X-Request-ID")).toBe("req_test_5xx");
});

it("keeps structured 4xx details visible in production", async () => {
process.env.NODE_ENV = "production";
const response = handleAppError({
requestId: "req_test_4xx",
error: createError({
code: "api.BAD_INPUT",
message: "Invalid filter",
Expand All @@ -47,12 +52,38 @@ describe("handleAppError", () => {
});

expect(response.status).toBe(400);
expect(await readPayload(response)).toEqual({
expect(await readPayload(response)).toEqual({
success: false,
error: "Invalid filter",
code: "api.BAD_INPUT",
why: "The filter operator is unsupported.",
fix: "Use one of the documented operators.",
fix: "Use one of the documented operators.",
requestId: "req_test_4xx",
});
});

it("returns safe field details for request validation errors", async () => {
process.env.NODE_ENV = "production";
const response = handleAppError({
code: "VALIDATION",
requestId: "req_test_validation",
error: new ValidationError(
"body",
t.Object({ name: t.String({ minLength: 1 }) }),
{}
),
});
const payload = await readPayload(response);

expect(response.status).toBe(422);
expect(payload).toMatchObject({
success: false,
error: "Invalid request",
code: "VALIDATION",
requestId: "req_test_validation",
});
expect(payload.details).toEqual([
expect.objectContaining({ field: "body.name" }),
]);
});
});
56 changes: 54 additions & 2 deletions apps/api/src/http/errors.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { config } from "@databuddy/env/app";
import { ValidationError } from "elysia";
import { EvlogError, parseError } from "evlog";
import { getRequestId } from "./request-id";

interface AppErrorContext {
code?: string | number;
error: unknown;
request?: Request;
requestId?: string;
}

const HTTP_STATUS_BY_ERROR_CODE: Record<string, number> = {
AUTH_REQUIRED: 401,
BAD_REQUEST: 400,
CONFLICT: 409,
FEATURE_UNAVAILABLE: 403,
FEATURE_UNAVAILABLE: 402,
FORBIDDEN: 403,
INTERNAL_SERVER_ERROR: 500,
INVALID_COOKIE_SIGNATURE: 400,
Expand All @@ -25,8 +29,15 @@ const HTTP_STATUS_BY_ERROR_CODE: Record<string, number> = {
};

const PROTECTED_RESOURCE_METADATA_URL = `${config.urls.api}/.well-known/oauth-protected-resource`;
const LEADING_SLASH_PATTERN = /^\//;

export function handleAppError({ error, code }: AppErrorContext) {
export function handleAppError({
error,
code,
request,
requestId,
}: AppErrorContext) {
const responseRequestId = requestId ?? getRequestId(request);
const parsed = parseError(error);
const statusCode = getStatusCode({
code,
Expand All @@ -48,8 +59,10 @@ export function handleAppError({ error, code }: AppErrorContext) {
isClientError,
statusCode,
});
const validationDetails = getValidationDetails(error);
const headers: Record<string, string> = {
"Content-Type": "application/json",
"X-Request-ID": responseRequestId,
};
if (statusCode === 401) {
headers["WWW-Authenticate"] =
Expand All @@ -61,16 +74,55 @@ export function handleAppError({ error, code }: AppErrorContext) {
success: false,
error: safeClientError,
code: errorCode,
requestId: responseRequestId,
...(hasValue(parsed.why) && exposeStructured ? { why: parsed.why } : {}),
...(hasValue(parsed.fix) && exposeStructured ? { fix: parsed.fix } : {}),
...(hasValue(parsed.link) && exposeStructured
? { link: parsed.link }
: {}),
...(validationDetails.length > 0 ? { details: validationDetails } : {}),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: Raw Elysia validation messages are exposed unconditionally in production responses, bypassing the existing exposeStructured sanitization gate that protects why, fix, and link.

In production, exposeStructured is false for ValidationError (it is not an EvlogError), so the top-level error field safely falls back to "Invalid request". However, the details array containing up to 20 raw issue.summary / issue.message strings is still included. Those messages can leak schema field names, custom validation logic, header/cookie expectations, or reflected input text that the getSafeErrorMessage policy is meant to suppress.

Consider gating details behind the same exposeStructured check, or introducing an explicit allowlist/gate for which validation messages are safe to expose publicly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/http/errors.ts, line 83:

<comment>Raw Elysia validation messages are exposed unconditionally in production responses, bypassing the existing `exposeStructured` sanitization gate that protects `why`, `fix`, and `link`.

In production, `exposeStructured` is `false` for `ValidationError` (it is not an `EvlogError`), so the top-level `error` field safely falls back to "Invalid request". However, the `details` array containing up to 20 raw `issue.summary` / `issue.message` strings is still included. Those messages can leak schema field names, custom validation logic, header/cookie expectations, or reflected input text that the `getSafeErrorMessage` policy is meant to suppress.

Consider gating `details` behind the same `exposeStructured` check, or introducing an explicit allowlist/gate for which validation messages are safe to expose publicly.</comment>

<file context>
@@ -61,16 +74,55 @@ export function handleAppError({ error, code }: AppErrorContext) {
 			...(hasValue(parsed.link) && exposeStructured
 				? { link: parsed.link }
 				: {}),
+			...(validationDetails.length > 0 ? { details: validationDetails } : {}),
 		}),
 		{ status: statusCode, headers }
</file context>
Suggested change
...(validationDetails.length > 0 ? { details: validationDetails } : {}),
...(validationDetails.length > 0 && exposeStructured ? { details: validationDetails } : {}),

}),
{ status: statusCode, headers }
);
}

interface ValidationDetail {
field: string;
message: string;
}

function getValidationDetails(error: unknown): ValidationDetail[] {
if (!(error instanceof ValidationError) || error.type === "response") {
return [];
}

const details: ValidationDetail[] = [];
const seenFields = new Set<string>();
for (const issue of error.all) {
const path = issue.path
.replace(LEADING_SLASH_PATTERN, "")
.split("/")
.filter(Boolean)
.join(".");
const field = path ? `${error.type}.${path}` : error.type;
if (seenFields.has(field)) {
continue;
}
seenFields.add(field);
details.push({
field,
message:
typeof issue.summary === "string" && issue.summary
? issue.summary
: issue.message,
});
if (details.length === 20) {
break;
}
}
return details;
}

function getErrorCode({
explicitCode,
parsedCode,
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/http/request-id.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { getRequestId } from "./request-id";

describe("getRequestId", () => {
it("returns a stable generated ID for the same request", () => {
const request = new Request("https://api.example.com/test");
const requestId = getRequestId(request);

expect(requestId).toMatch(/^req_[a-f0-9]{16}$/);
expect(getRequestId(request)).toBe(requestId);
});

it("preserves a safe caller-supplied ID", () => {
const request = new Request("https://api.example.com/test", {
headers: { "x-request-id": "trace_partner-123" },
});

expect(getRequestId(request)).toBe("trace_partner-123");
});

it("replaces unsafe caller-supplied IDs", () => {
const request = new Request("https://api.example.com/test", {
headers: { "x-request-id": "not safe for logs" },
});

expect(getRequestId(request)).toMatch(/^req_[a-f0-9]{16}$/);
});
});
25 changes: 25 additions & 0 deletions apps/api/src/http/request-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
const requestIds = new WeakMap<Request, string>();

function createRequestId(): string {
return `req_${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`;
}

export function getRequestId(request?: Request): string {
if (!request) {
return createRequestId();
}

const existing = requestIds.get(request);
if (existing) {
return existing;
}

const supplied = request.headers.get("x-request-id")?.trim();
const requestId =
supplied && REQUEST_ID_PATTERN.test(supplied)
? supplied
: createRequestId();
requestIds.set(request, requestId);
return requestId;
}
11 changes: 11 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { registerProcessErrorHandlers } from "@/bootstrap/process-errors";
import { registerShutdownHooks, warmPostgresPool } from "@/bootstrap/shutdown";
import { isAllowedApiOrigin } from "@/http/cors";
import { handleAppError } from "@/http/errors";
import { getRequestId } from "@/http/request-id";
import { AUTUMN_API_PREFIX } from "@/lib/autumn-mount";
import { enrichApiWideEvent } from "@/lib/evlog-api";
import { enrichRequestAuthWideEvent } from "@/middleware/auth-wide-event";
Expand Down Expand Up @@ -81,10 +82,20 @@ const app = new Elysia({ precompile: true })
enrich: enrichApiWideEvent,
})
)
.onBeforeHandle(({ request, set }) => {
set.headers["X-Request-ID"] = getRequestId(request);
})
.onBeforeHandle(({ request }) => enrichRequestAuthWideEvent(request))
.use(
cors({
credentials: true,
exposeHeaders: [
"X-Request-ID",
"Retry-After",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
],
origin: isAllowedApiOrigin,
})
)
Expand Down
Loading
Loading