feat: user identity (identify API, profiles, identity-aware analytics)#535
Conversation
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.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
The latest updates on your projects. Learn more about Unkey Deploy
|
Greptile SummaryThis PR adds end-to-end user identity to Databuddy: a browser tracker
Confidence Score: 3/5The 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
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
%%{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
Reviews (1): Last reviewed commit: "feat(dashboard): identify signed-in user..." | Re-trigger Greptile |
There was a problem hiding this comment.
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/identifyfails, so retries can be blocked for the rest of the session and identity may never be attached to later events — movedid_profile_sentto the success path and add a regression test before merging. - In
packages/tracker/tests/identity.spec.ts,waitForRequestcan match CORSOPTIONSinstead of thePOST /identify, making assertions timing-dependent and potentially masking real regressions — restrict these waits/assertions toPOSTrequests to stabilize coverage. profileIdnormalization is inconsistent acrosspackages/sdk/src/node/index.ts,packages/tracker/src/core/tracker.ts,apps/basket/src/routes/webhooks/stripe.ts, andapps/basket/src/routes/webhooks/paddle.ts, allowing whitespace-only, overlong, or unsanitized IDs that can pollute identity data and break attribution consistency — apply the samesanitizeString(..., 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) andapps/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 indenyApiKeyIdentifybefore 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, ...)
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
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.
There was a problem hiding this comment.
1 issue found across 12 files (changes from recent commits).
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
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.
There was a problem hiding this comment.
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
| }, | ||
| }); | ||
|
|
||
| if (!process.env.DATABUDDY_ENCRYPTION_KEY) { |
There was a problem hiding this comment.
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))) { |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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>
| 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. |
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
identify(profileId, traits)links it to a user ID from your system, persisted in localStorage and attached to every subsequent event asprofile_id.profile_idvalues are customer-supplied and stored verbatim (never salted), unlike anonymous IDs.profiles,profile_aliases); traits are flat scalar key/values (50 keys / 2KB,nulldeletes a key;email/username/nameare promoted to profile columns).Surfaces
identify/setTraits/clearProfile/getProfileId, deduped to one request per sessionidentify()for server-side linking via API key (track:eventsscope, website-level enforcement)/identifyroute (website-origin or API-key auth, rate-limited, bot-checked);profileIdaccepted on all ingestion paths; Stripe/Paddle webhooks extractdatabuddy_profile_idfor revenue attributionidentify()for signed-in usersDrift prevention
Identity SQL expressions are centralized in
packages/db/clickhouse/identity.tsand consumed by query builders and the agent prompt. Drift tests bind the schema, migrations, agent column allowlist, and drizzle config together, so adding aprofile_idtable or changing the expressions fails tests rather than silently diverging.Migrations
All schema changes are additive and idempotent (
ADD COLUMN IF NOT EXISTSfor ClickHouse, new tables for Postgres). Old events simply default to an emptyprofile_id; queue consumers and retained messages are unaffected.Testing
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
@databuddy/sdkv2.5.0 addsidentify,setTraits,clearProfile,getProfileId(SSR-safe); browser tracker attachesprofileIdto all events (includingpage_exit) and dedupes identify per session after a successful call; enforces 128‑charprofileId, ignores empty trait objects, and routesclear()throughclearProfile(); Node client addsDatabuddy.identify()and rejects whitespace-onlyprofileId./identifyroute (website-origin or API-key auth, scoped, bot-checked, rate-limited); all ingestion paths acceptprofileId; Stripe/Paddle webhooks read and sanitizedatabuddy_profile_idfor revenue attribution; track/identify schemas shareprofileIdvalidation and cap trait size; identify payload rejects emptyanonymousId.profilesandprofile_aliases; ClickHouseprofile_idonevents,custom_events,revenuewith canonical visitor-key expressions; identity-aware builders collapse users across devices, allowprofile_idfilters, 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 whenDATABUDDY_ENCRYPTION_KEYis set (deterministic HMAC email hash for lookups).profiles.getByIdsrouter (returns decrypted names/emails when encryption is enabled); new “Identify Users” guide; API Health Check docs updated to match the real response.Migration
@databuddy/sdkv2.5.0.identify(userId, traits)on login/signup andclearProfile()on logout; optional server usage viaDatabuddy.identify({ profileId, anonymousId, websiteId, traits })(profile IDs are trimmed and must be non-empty).databuddy_profile_id(and existing tracking IDs) in checkout metadata.track:eventswebsite scope and includewebsiteIdin the request.DATABUDDY_ENCRYPTION_KEYto encrypt profile display names/emails at rest; without it, fields are stored plaintext and Basket logs a boot warning.Written for commit ee11e64. Summary will update on new commits.