Skip to content

feat: user identity (identify API, profiles, identity-aware analytics)#535

Merged
izadoesdev merged 22 commits into
stagingfrom
izadoesdev/user-identity
Jul 3, 2026
Merged

feat: user identity (identify API, profiles, identity-aware analytics)#535
izadoesdev merged 22 commits into
stagingfrom
izadoesdev/user-identity

Conversation

@izadoesdev

@izadoesdev izadoesdev commented Jul 3, 2026

Copy link
Copy Markdown
Member

What

End-to-end user identity for Databuddy: link tracked activity to your own user IDs via identify(), see real names and emails in the dashboard, and attribute revenue to identified users.

How it works

  • Every visitor keeps an anonymous device ID. identify(profileId, traits) links it to a user ID from your system, persisted in localStorage and attached to every subsequent event as profile_id.
  • Identity is forward-only and denormalized at ingest — no retroactive mutations. Pre-login activity in the same session stitches at query time via the session.
  • profile_id values are customer-supplied and stored verbatim (never salted), unlike anonymous IDs.
  • Profiles and device aliases live in Postgres (profiles, profile_aliases); traits are flat scalar key/values (50 keys / 2KB, null deletes a key; email/username/name are promoted to profile columns).

Surfaces

  • Tracker: identify / setTraits / clearProfile / getProfileId, deduped to one request per session
  • SDK: SSR-safe wrappers (web/react) and a Node identify() for server-side linking via API key (track:events scope, website-level enforcement)
  • Basket: /identify route (website-origin or API-key auth, rate-limited, bot-checked); profileId accepted on all ingestion paths; Stripe/Paddle webhooks extract databuddy_profile_id for revenue attribution
  • Dashboard: users list and detail render display names/emails, "Identified" filter preset; the dashboard also dogfoods identify() for signed-in users
  • AI agent: identity-aware counting patterns and column access
  • Devtools: profile row in the identity panel
  • Docs: new "Identify Users" guide

Drift prevention

Identity SQL expressions are centralized in packages/db/clickhouse/identity.ts and consumed by query builders and the agent prompt. Drift tests bind the schema, migrations, agent column allowlist, and drizzle config together, so adding a profile_id table or changing the expressions fails tests rather than silently diverging.

Migrations

All schema changes are additive and idempotent (ADD COLUMN IF NOT EXISTS for ClickHouse, new tables for Postgres). Old events simply default to an empty profile_id; queue consumers and retained messages are unaffected.

Testing

  • Unit/integration: validation (81), basket (463), rpc (331), db drift tests, ai builder suite (2.8k assertions), sdk node client
  • Tracker e2e (Playwright): identity persistence, payload attachment, per-session dedupe, logout, reload survival
  • New RPC integration tests cover cross-org access denial and alias resolution

Summary by cubic

Adds end-to-end user identity: link activity to your own user IDs with identify(), store profiles, and make analytics and revenue attribution identity-aware across the app. Now with stricter validation, more robust identify dedupe, and optional at‑rest encryption for profile names/emails.

  • New Features

    • SDK & Tracker: @databuddy/sdk v2.5.0 adds identify, setTraits, clearProfile, getProfileId (SSR-safe); browser tracker attaches profileId to all events (including page_exit) and dedupes identify per session after a successful call; enforces 128‑char profileId, ignores empty trait objects, and routes clear() through clearProfile(); Node client adds Databuddy.identify() and rejects whitespace-only profileId.
    • Ingestion & Revenue: new /identify route (website-origin or API-key auth, scoped, bot-checked, rate-limited); all ingestion paths accept profileId; Stripe/Paddle webhooks read and sanitize databuddy_profile_id for revenue attribution; track/identify schemas share profileId validation and cap trait size; identify payload rejects empty anonymousId.
    • Storage & Queries: Postgres profiles and profile_aliases; ClickHouse profile_id on events, custom_events, revenue with canonical visitor-key expressions; identity-aware builders collapse users across devices, allow profile_id filters, resolve span‑only activity via device sets, and pick the latest non-empty identity; display names and emails stored encrypted at rest with AES‑GCM when DATABUDDY_ENCRYPTION_KEY is set (deterministic HMAC email hash for lookups).
    • Dashboard & Devtools: users list and detail show names/emails with an Identified preset; profile lookups batch beyond 100 IDs; dashboard identifies signed-in users; DevTools panel displays the current profile.
    • RPC & Docs: new profiles.getByIds router (returns decrypted names/emails when encryption is enabled); new “Identify Users” guide; API Health Check docs updated to match the real response.
  • Migration

    • Update to @databuddy/sdk v2.5.0.
    • Call identify(userId, traits) on login/signup and clearProfile() on logout; optional server usage via Databuddy.identify({ profileId, anonymousId, websiteId, traits }) (profile IDs are trimmed and must be non-empty).
    • For Stripe/Paddle, include databuddy_profile_id (and existing tracking IDs) in checkout metadata.
    • If identifying via API key, ensure track:events website scope and include websiteId in the request.
    • Optional: set DATABUDDY_ENCRYPTION_KEY to encrypt profile display names/emails at rest; without it, fields are stored plaintext and Basket logs a boot warning.
    • No breaking changes; database migrations are additive.

Written for commit ee11e64. Summary will update on new commits.

Review in cubic

izadoesdev added 15 commits July 3, 2026 16:51
profiles + profile_aliases Postgres tables, profile_id columns on
events/custom_events/revenue with idempotent migrations, canonical
visitor-key expressions in clickhouse/identity.ts, agent allowlist
entries, and drift tests binding schema, migrations, and allowlist.
profileId (1-128 chars) on analytics and custom-event schemas, traits
capped at 50 scalar keys / 2KB serialized with null-deletes-key.
splitTraits (email/username/name promotion), no-op-skipping profile
upsert with jsonb trait merge, and race-safe alias upsert.
profileId flows through all ingestion paths into profile_id (never
salted), /identify supports website-origin and API-key auth with
track:events website scope, rate limits, bot checks, and evlog; Stripe
and Paddle webhooks extract profile ids for revenue attribution.
identify/setTraits/clearProfile/getProfileId on the tracker and
window globals, did_profile persistence, profileId on every payload,
one identify request per session unless traits change.
SSR-safe identify wrappers with pre-load getProfileId fallback,
node client identify() with API-key auth and websiteId fallback,
profileId on custom event inputs.
getByIds (batched, max 100) and get-with-aliases behind website read
permissions; integration coverage for member access, cross-org
FORBIDDEN, alias resolution, and unknown profiles.
profile builders group by the canonical visitor key so identified
users collapse across devices, expose profile_id, resolve activity in
tables without the column via device sets, allow profile_id filters,
and teach the agent prompt identity counting patterns.
users list shows display names and emails via batched profile lookup,
detail header enriched with identity, Identified preset filter.
profile row with copy action in the identity section and profile
storage keys in the inspector.
concepts, browser/script/node examples, trait semantics and limits,
revenue attribution metadata keys, API reference.
identity.ts was missing from the drizzle-kit schema list so db:push
skipped the new tables; also drops the stale admin.ts entry. Adds a
drift test asserting every pgTable file is registered and every
registered file exists.
identify API across core, react, and node entrypoints.
the prompt module's import-time consistency check throws when an
AGENT_TABLE_COLUMNS entry is missing from the table docs, which
crashed the api at boot after revenue gained identity columns.
IdentifyUser provider calls identify/clearProfile from the auth
session, and Stripe checkout metadata carries databuddy_profile_id
for revenue attribution.
@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
databuddy-status Ready Ready Preview, Comment Jul 3, 2026 3:55pm
documentation Ready Ready Preview, Comment Jul 3, 2026 3:55pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
dashboard Skipped Skipped Jul 3, 2026 3:55pm

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 666c84f7-2d2b-46fb-89c3-a94737217c94

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch izadoesdev/user-identity

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@unkey-deploy

unkey-deploy Bot commented Jul 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Unkey Deploy

Name Status Preview Inspect Updated (UTC)
api (preview) Ready Visit Preview Inspect Jul 3, 2026 3:54pm

@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds end-to-end user identity to Databuddy: a browser tracker identify() API, a Node.js SDK counterpart, a basket /identify ingest route, Postgres profiles/profile_aliases tables, ClickHouse profile_id columns with bloom-filter indexes, and a dashboard UI for viewing and filtering identified users. The implementation is well-structured with centralized SQL expressions, drift tests, and per-session deduplication.

  • Tracker & SDK: identify(profileId, traits) persists to localStorage, deduplicates per session-storage flag, and attaches profile_id to every subsequent event and batch call.
  • Basket /identify route: Dual-auth (browser-origin or API-key with track:events scope), rate-limited, bot-checked; upserts profiles and profile_aliases in Postgres with JSONB merge/delete semantics.
  • Revenue attribution: Stripe and Paddle webhook handlers extract databuddy_profile_id from payment metadata and forward it to ClickHouse revenue rows — but unlike the tracker path, the value is not sanitized or length-capped before storage.

Confidence Score: 3/5

The core identity pipeline is solid, but the Stripe and Paddle webhook handlers store profile IDs from payment metadata without the same normalization used everywhere else, which can silently break the revenue attribution that is a headline feature of this PR.

The webhook handlers bypass the sanitizeString/USER_ID_MAX_LENGTH normalization applied to every other profile_id write path. A customer with a profile ID longer than 128 characters, or one with leading/trailing whitespace, will have revenue events indexed under a different profile_id than their analytics events. The ClickHouse IN-clause matching relies on exact string equality, so the mismatch silently drops revenue attribution. The rest of the PR is well-considered and low-risk.

apps/basket/src/routes/webhooks/stripe.ts and apps/basket/src/routes/webhooks/paddle.ts — specifically the extractAnalyticsMetadata functions that read databuddy_profile_id / profile_id from webhook payloads.

Important Files Changed

Filename Overview
apps/basket/src/routes/identify.ts New /identify ingest route with dual-auth (browser-origin and API-key), bot-check, and rate limiting; logic is sound and tested.
apps/basket/src/routes/webhooks/stripe.ts Adds profile_id to Stripe webhook revenue events, but databuddy_profile_id from Stripe metadata is stored verbatim — no sanitizeString, no USER_ID_MAX_LENGTH truncation — breaking identity stitching when the value differs from what the SDK sends.
apps/basket/src/routes/webhooks/paddle.ts Same unsanitized profile_id passthrough as Stripe; custom_data.profile_id is stored verbatim without length or character normalization.
packages/services/src/identity.ts upsertProfile and upsertAlias look correct; JSONB merge/delete semantics with setWhere guard are well-implemented.
packages/db/src/drizzle/schema/identity.ts profiles and profile_aliases Drizzle schemas are clean with appropriate indexes; composite PK and cascade delete are correct.
packages/db/src/clickhouse/schema.ts Additive profile_id columns and bloom-filter indexes added to events, custom_events, and revenue tables; idempotent migration queries included.
packages/validation/src/schemas/identity.ts traitsSchema validation is correct; anonymousId field missing min(1) — allows empty string through schema but handled by falsy check downstream.
packages/tracker/src/core/tracker.ts identify/setTraits/clearProfile/getProfileId methods are well-implemented with per-session deduplication, localStorage persistence, and empty-ID guard.
packages/ai/src/query/builders/profiles.ts Profile list and detail SQL CTEs updated to use EVENTS_VISITOR_KEY/CUSTOM_EVENTS_VISITOR_KEY and visitorMatch; visitor_ids CTE bridges tables lacking profile_id cleanly.
apps/dashboard/components/providers/identify-user.tsx IdentifyUser component correctly gates on isPending to avoid premature clearProfile during auth load; session/user handling looks correct.
packages/rpc/src/routers/profiles.ts getByIds and get handlers both enforce withWorkspace authorization with websiteId scoping; no cross-website data leakage.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Browser as Browser SDK
    participant Tracker as Tracker (localStorage)
    participant Basket as Basket /identify
    participant Postgres as Postgres (profiles / aliases)
    participant ClickHouse as ClickHouse (events / revenue)
    participant Stripe as Stripe Webhook

    Browser->>Tracker: identify(profileId, traits)
    Tracker->>Tracker: sanitizeString(profileId), persist to localStorage, dedup via sessionStorage
    Tracker->>Basket: "POST /identify {profileId, anonymousId, traits}"
    Basket->>Basket: validate schema, bot-check / rate-limit
    Basket->>Postgres: upsertProfile(websiteId, profileId, traits)
    Basket->>Postgres: upsertAlias(websiteId, anonymousId, profileId)
    Basket-->>Browser: "{status: success}"

    Browser->>Basket: "POST /track {profileId, ...events}"
    Basket->>Basket: sanitizeString(profileId)
    Basket->>ClickHouse: insert events with profile_id

    Stripe->>Basket: "webhook {databuddy_profile_id} - no sanitize applied"
    Basket->>ClickHouse: insert revenue with profile_id (raw)

    Note over ClickHouse: Query uses if(profile_id != '', profile_id, anonymous_id)
    Note over ClickHouse: joins analytics to revenue via exact profile_id match
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Browser as Browser SDK
    participant Tracker as Tracker (localStorage)
    participant Basket as Basket /identify
    participant Postgres as Postgres (profiles / aliases)
    participant ClickHouse as ClickHouse (events / revenue)
    participant Stripe as Stripe Webhook

    Browser->>Tracker: identify(profileId, traits)
    Tracker->>Tracker: sanitizeString(profileId), persist to localStorage, dedup via sessionStorage
    Tracker->>Basket: "POST /identify {profileId, anonymousId, traits}"
    Basket->>Basket: validate schema, bot-check / rate-limit
    Basket->>Postgres: upsertProfile(websiteId, profileId, traits)
    Basket->>Postgres: upsertAlias(websiteId, anonymousId, profileId)
    Basket-->>Browser: "{status: success}"

    Browser->>Basket: "POST /track {profileId, ...events}"
    Basket->>Basket: sanitizeString(profileId)
    Basket->>ClickHouse: insert events with profile_id

    Stripe->>Basket: "webhook {databuddy_profile_id} - no sanitize applied"
    Basket->>ClickHouse: insert revenue with profile_id (raw)

    Note over ClickHouse: Query uses if(profile_id != '', profile_id, anonymous_id)
    Note over ClickHouse: joins analytics to revenue via exact profile_id match
Loading

Reviews (1): Last reviewed commit: "feat(dashboard): identify signed-in user..." | Re-trigger Greptile

Comment thread apps/basket/src/routes/webhooks/stripe.ts
Comment thread apps/basket/src/routes/webhooks/paddle.ts
Comment thread packages/validation/src/schemas/identity.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

23 issues found across 65 files

Confidence score: 2/5

  • In packages/tracker/src/core/tracker.ts (identify()), the dedupe marker is written even when /identify fails, so retries can be blocked for the rest of the session and identity may never be attached to later events — move did_profile_sent to the success path and add a regression test before merging.
  • In packages/tracker/tests/identity.spec.ts, waitForRequest can match CORS OPTIONS instead of the POST /identify, making assertions timing-dependent and potentially masking real regressions — restrict these waits/assertions to POST requests to stabilize coverage.
  • profileId normalization is inconsistent across packages/sdk/src/node/index.ts, packages/tracker/src/core/tracker.ts, apps/basket/src/routes/webhooks/stripe.ts, and apps/basket/src/routes/webhooks/paddle.ts, allowing whitespace-only, overlong, or unsanitized IDs that can pollute identity data and break attribution consistency — apply the same sanitizeString(..., USER_ID_MAX_LENGTH)/non-empty validation on all ingest paths.
  • There are backend integrity/guardrail gaps in packages/db/src/drizzle/schema/identity.ts (no composite FK for aliases) and apps/basket/src/routes/identify.ts (inactive websites can still be mutated via API key), which can leave orphaned aliases and allow updates for disabled sites — add the composite FK and enforce website active status in denyApiKeyIdentify before merge.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/sdk/src/core/types.ts">

<violation number="1" location="packages/sdk/src/core/types.ts:153">
P3: IDE/docs metadata becomes misleading here: the tracker-level JSDoc now sits above `ProfileTraits`, so consumers can see a wrong description for this new type. Keeping that comment directly on `DatabuddyTracker` would prevent drift and keep API docs accurate.</violation>
</file>

<file name="packages/db/src/drizzle/schema/identity.ts">

<violation number="1" location="packages/db/src/drizzle/schema/identity.ts:17">
P2: Alias rows can become orphaned because `profile_aliases.profileId` is not constrained to an existing row in `profiles` for the same website. Adding a composite FK (`website_id`, `profile_id`) would keep alias resolution data consistent.</violation>
</file>

<file name="packages/db/src/clickhouse/schema.ts">

<violation number="1" location="packages/db/src/clickhouse/schema.ts:805">
P3: Profile-id filters on historical data may miss the new skipping index because these migrations only register the index metadata. Adding a follow-up `MATERIALIZE INDEX` migration (optionally partitioned) would make the index effective for existing data too.</violation>
</file>

<file name="apps/basket/src/routes/identify.ts">

<violation number="1" location="apps/basket/src/routes/identify.ts:41">
P2: API-key `/identify` requests can update profiles for inactive websites, so disabled sites may continue receiving identity mutations. This happens because `denyApiKeyIdentify` checks existence/scope/org but not `website.status`; consider rejecting non-`ACTIVE` websites in this authorization gate.</violation>
</file>

<file name="packages/ai/src/query/builders/profiles.ts">

<violation number="1" location="packages/ai/src/query/builders/profiles.ts:176">
P2: `profile_detail` is still keyed by `anonymous_id`, but this change broadens the lookup to `profile_id` matches too. That can merge an unrelated identified profile into the device detail view when the strings collide, so the query should stay on the anonymous id here unless the route starts accepting profile ids explicitly.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Client as Browser
    participant Tracker as Tracker JS
    participant Basket as Basket API
    participant PG as Postgres
    participant CH as ClickHouse
    participant Dashboard as Dashboard UI
    participant RPC as RPC Profiles
    participant Payment as Stripe/Paddle

    Note over Client,Basket: NEW: identify flow

    Client->>Tracker: identify(userId, traits)
    Tracker->>Tracker: store profileId in localStorage
    Tracker->>Tracker: check session dedupe (sessionStorage)
    alt First call this session OR traits provided
        Tracker->>Basket: POST /identify {profileId, anonymousId, traits}
        Basket->>Basket: resolveIdentifyTarget()
        alt API-key auth
            Basket->>Basket: denyApiKeyIdentify()
            alt Missing websiteId
                Basket-->>Tracker: 400 MISSING_WEBSITE_ID
            else Missing scope (track:events)
                Basket-->>Tracker: 401 MISSING_SCOPE
            else Website not found or scope mismatch
                Basket-->>Tracker: 400/401 error
            else Success
                Basket->>Basket: set rate limit principal (apikey)
            end
        else Website-origin auth (clientId)
            Basket->>Basket: validateRequest
            Basket->>Basket: checkForBot
            alt Bot detected
                Basket-->>Tracker: 403 bot response
            else Success
                Basket->>Basket: set rate limit principal (clientId+ip)
            end
        end
        Basket->>Basket: ratelimit(principal, 60/min or 600/min)
        alt Rate limited
            Basket-->>Tracker: 429 IDENTIFY_RATE_LIMITED
        else Allowed
            Basket->>Basket: splitTraits(traits) → promote email/username/name
            Basket->>PG: upsertProfile(websiteId, profileId, split)
            alt anonymousId present
                Basket->>PG: upsertAlias(websiteId, anonymousId, profileId)
            end
            Basket-->>Tracker: 200 {status: success}
            Tracker->>Tracker: store session dedupe flag
        end
    else Deduped (same ID, no traits)
        Tracker-->>Client: no network request
    end

    Note over Client,CH: Subsequent events carry profileId

    Client->>Tracker: track(eventName, properties)
    Tracker->>Tracker: attach profileId to payload
    Tracker->>Basket: POST /track {..., profileId}
    Basket->>Basket: buildTrackEvent() → include profile_id
    Basket->>CH: INSERT INTO analytics.events (profile_id, ...)
    alt Custom event
        Tracker->>Basket: POST /events {..., profileId}
        Basket->>CH: INSERT INTO analytics.custom_events (profile_id, ...)
    end

    Note over Dashboard,RPC: Identity-aware dashboard

    Dashboard->>Dashboard: IdentifyUser component (dogfoods SDK)
    Dashboard->>RPC: profiles.getByIds(websiteId, profileIds[])
    RPC->>PG: SELECT from profiles WHERE websiteId AND profileId IN (...)
    PG-->>RPC: profile rows with displayName, email, traits
    RPC-->>Dashboard: array of profiles
    Dashboard->>Dashboard: display real names/emails, "Identified" preset

    Note over Payment,CH: Revenue attribution via profile_id

    Payment->>Basket: webhook (stripe/paddle) with databuddy_profile_id in metadata
    Basket->>Basket: extractAnalyticsMetadata → profile_id
    Basket->>CH: INSERT INTO analytics.revenue (profile_id, ...)
Loading

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread packages/tracker/tests/identity.spec.ts Outdated
Comment thread packages/tracker/src/core/tracker.ts Outdated
Comment thread apps/basket/src/routes/webhooks/stripe.ts Outdated
Comment thread packages/tracker/tests/identity.spec.ts Outdated
Comment thread packages/sdk/src/node/index.ts
Comment thread packages/sdk/src/core/types.ts
Comment thread packages/sdk/tests/node-client.test.ts Outdated
Comment thread apps/basket/src/routes/track-event-schema.ts Outdated
Comment thread apps/basket/src/routes/track-event-schema.ts Outdated
Comment thread packages/db/src/clickhouse/schema.ts
dedupe marker is now written only after a successful identify so
transient failures retry within the session; page_exit beacons carry
profileId; identify enforces the documented 128-char cap; empty trait
objects no longer bypass dedupe; clear() reuses clearProfile(); e2e
waits are method-filtered and deterministic.
stripe/paddle profile ids now pass through sanitizeString with the
128-char cap like every other ingest path; track schema reuses
profileIdSchema; identify payload rejects empty anonymousId and
allows trait values up to the property limit.
visitor_ids includes the requested id so span-only activity is not
dropped; profile_detail resolves the most recent non-empty identity;
agent guidance notes the nullable fallback for custom_events/revenue.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

1 issue found across 12 files (changes from recent commits).

Shadow auto-approve: would not auto-approve because issues were found.

Re-trigger cubic

Comment thread packages/ai/src/query/builders/profiles.ts
AES-256-GCM via DATABUDDY_ENCRYPTION_KEY with a deterministic HMAC
email hash for equality lookups; rpc decrypts on read so the API
contract is unchanged; custom traits stay plaintext for filtering;
keyless deployments fall back to plaintext with a boot warning.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

3 issues found across 11 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/basket/src/index.ts">

<violation number="1" location="apps/basket/src/index.ts:40">
P2: Boot-time security warnings about unencrypted PII storage should not be subject to log sampling. The 50% warn sampling rate means operators who forget to set `DATABUDDY_ENCRYPTION_KEY` will miss this warning half the time. Consider using a bypass mechanism — for example `log.warn({ message, __noSample: true })` if the logger supports it, or logging at a non-sampled level, or adding a `keep` rule for `message: "DATABUDDY_ENCRYPTION_KEY*"` so this specific warning is always delivered.</violation>
</file>

<file name="packages/services/src/identity.ts">

<violation number="1" location="packages/services/src/identity.ts:17">
P2: Profile display names/emails that are plain text but begin with `v1:` can be returned as `null`, because `revealPii` currently treats prefix-only matches as encrypted payloads. Consider validating full encrypted payload shape before attempting decrypt/failing closed.</violation>
</file>

<file name="apps/docs/content/docs/sdk/identify-users.mdx">

<violation number="1" location="apps/docs/content/docs/sdk/identify-users.mdx:69">
P2: The documentation states that display names and emails "are encrypted at rest (AES-256-GCM)" as a blanket fact, but encryption is actually **optional** — it only activates when `DATABUDDY_ENCRYPTION_KEY` is configured. Without that key, `protectPii()` returns the value in plaintext, and Basket logs a startup warning about unencrypted storage. Readers who do not set the encryption key will incorrectly believe their users' personal data is encrypted. Add a conditional note, for example: "If `DATABUDDY_ENCRYPTION_KEY` is configured, display names and emails are encrypted at rest (AES-256-GCM). Without it, they are stored in plaintext."</violation>
</file>

Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread apps/basket/src/index.ts
},
});

if (!process.env.DATABUDDY_ENCRYPTION_KEY) {

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: Boot-time security warnings about unencrypted PII storage should not be subject to log sampling. The 50% warn sampling rate means operators who forget to set DATABUDDY_ENCRYPTION_KEY will miss this warning half the time. Consider using a bypass mechanism — for example log.warn({ message, __noSample: true }) if the logger supports it, or logging at a non-sampled level, or adding a keep rule for message: "DATABUDDY_ENCRYPTION_KEY*" so this specific warning is always delivered.

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

<comment>Boot-time security warnings about unencrypted PII storage should not be subject to log sampling. The 50% warn sampling rate means operators who forget to set `DATABUDDY_ENCRYPTION_KEY` will miss this warning half the time. Consider using a bypass mechanism — for example `log.warn({ message, __noSample: true })` if the logger supports it, or logging at a non-sampled level, or adding a `keep` rule for `message: "DATABUDDY_ENCRYPTION_KEY*"` so this specific warning is always delivered.</comment>

<file context>
@@ -37,6 +37,13 @@ initLogger({
 	},
 });
 
+if (!process.env.DATABUDDY_ENCRYPTION_KEY) {
+	log.warn({
+		message:
</file context>

}

export function revealPii(value: string | null): string | null {
if (!(value && value.startsWith(ENCRYPTED_PREFIX))) {

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: Profile display names/emails that are plain text but begin with v1: can be returned as null, because revealPii currently treats prefix-only matches as encrypted payloads. Consider validating full encrypted payload shape before attempting decrypt/failing closed.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/services/src/identity.ts, line 17:

<comment>Profile display names/emails that are plain text but begin with `v1:` can be returned as `null`, because `revealPii` currently treats prefix-only matches as encrypted payloads. Consider validating full encrypted payload shape before attempting decrypt/failing closed.</comment>

<file context>
@@ -1,4 +1,40 @@
+}
+
+export function revealPii(value: string | null): string | null {
+	if (!(value && value.startsWith(ENCRYPTED_PREFIX))) {
+		return value;
+	}
</file context>

| `email` | Shown under the user's name; lowercased |
| anything else | Stored on the profile and shown in the user detail view |

Display names and emails are encrypted at rest (AES-256-GCM). Custom traits are stored as regular values so you can filter and segment by them.

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 documentation states that display names and emails "are encrypted at rest (AES-256-GCM)" as a blanket fact, but encryption is actually optional — it only activates when DATABUDDY_ENCRYPTION_KEY is configured. Without that key, protectPii() returns the value in plaintext, and Basket logs a startup warning about unencrypted storage. Readers who do not set the encryption key will incorrectly believe their users' personal data is encrypted. Add a conditional note, for example: "If DATABUDDY_ENCRYPTION_KEY is configured, display names and emails are encrypted at rest (AES-256-GCM). Without it, they are stored in plaintext."

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/docs/content/docs/sdk/identify-users.mdx, line 69:

<comment>The documentation states that display names and emails "are encrypted at rest (AES-256-GCM)" as a blanket fact, but encryption is actually **optional** — it only activates when `DATABUDDY_ENCRYPTION_KEY` is configured. Without that key, `protectPii()` returns the value in plaintext, and Basket logs a startup warning about unencrypted storage. Readers who do not set the encryption key will incorrectly believe their users' personal data is encrypted. Add a conditional note, for example: "If `DATABUDDY_ENCRYPTION_KEY` is configured, display names and emails are encrypted at rest (AES-256-GCM). Without it, they are stored in plaintext."</comment>

<file context>
@@ -66,6 +66,8 @@ Traits are flat key/value metadata about the user. Values must be strings, numbe
 | `email` | Shown under the user's name; lowercased |
 | anything else | Stored on the profile and shown in the user detail view |
 
+Display names and emails are encrypted at rest (AES-256-GCM). Custom traits are stored as regular values so you can filter and segment by them.
+
 Setting a trait to `null` removes it:
</file context>
Suggested change
Display names and emails are encrypted at rest (AES-256-GCM). Custom traits are stored as regular values so you can filter and segment by them.
Display names and emails are encrypted at rest (AES-256-GCM) when `DATABUDDY_ENCRYPTION_KEY` is configured. Without it, they are stored in plaintext. Custom traits are stored as regular values so you can filter and segment by them.

@izadoesdev
izadoesdev merged commit 524deb7 into staging Jul 3, 2026
18 checks passed
@izadoesdev
izadoesdev deleted the izadoesdev/user-identity branch July 3, 2026 16:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant