Skip to content

Gate device custom_id from /stats#1615

Merged
riderx merged 4 commits into
mainfrom
riderx/stats-customid-guard
Feb 10, 2026
Merged

Gate device custom_id from /stats#1615
riderx merged 4 commits into
mainfrom
riderx/stats-customid-guard

Conversation

@riderx
Copy link
Copy Markdown
Member

@riderx riderx commented Feb 10, 2026

Summary (AI generated)

  • Add apps.allow_device_custom_id (default true) to gate device-supplied custom_id persistence from unauthenticated /stats.
  • When disabled and a client sends custom_id, strip it and emit customIdBlocked stat action.
  • Add migration + types + test coverage.

Motivation (AI generated)

Prevent persistent poisoning of devices.custom_id via service-role upsert from public stats ingestion while keeping current behavior by default.

Business Impact (AI generated)

Reduces customer-visible data integrity risk from unauthenticated telemetry without breaking existing clients.

Test plan (AI generated)

  • bun run lint:backend && bun run lint
  • bun test:backend

Screenshots (AI generated)

  • N/A (backend only)

Checklist (AI generated)

  • My code follows the code style of this project and passes
    bun run lint:backend && bun run lint.
  • My change requires a change to the documentation.
  • I have updated the documentation
    accordingly.
  • My change has adequate E2E test coverage.
  • I have tested my code manually, and I have provided steps how to reproduce
    my tests

Generated with AI

Copilot AI review requested due to automatic review settings February 10, 2026 14:07
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 10, 2026

Warning

Rate limit exceeded

@riderx has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 8 minutes and 44 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch riderx/stats-customid-guard

Tip

We've launched Issue Planner and it is currently in beta. Please try it out and share your feedback on Discord!


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 and usage tips.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds an app-level switch to prevent unauthenticated /stats telemetry from persisting device-supplied custom_id, while keeping current behavior by default.

Changes:

  • Add apps.allow_device_custom_id (default true) and a new stats_action enum value customIdBlocked.
  • Update /stats ingestion to optionally strip custom_id and emit a customIdBlocked stat when blocked.
  • Regenerate Supabase types and add test coverage for the blocked behavior.

Reviewed changes

Copilot reviewed 10 out of 11 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/stats.test.ts Adds a test ensuring custom_id is not persisted and customIdBlocked is emitted when the app disables it.
supabase/migrations/20260210132811_stats_customid_guard.sql Introduces apps.allow_device_custom_id and adds customIdBlocked to stats_action.
supabase/functions/deno.lock Adds/updates Deno lockfile for functions dependencies.
supabase/functions/_backend/utils/supabase.types.ts Updates backend-generated DB types for the new column and enum value.
supabase/functions/_backend/utils/supabase.ts Adjusts device upsert behavior to only persist custom_id when explicitly provided.
supabase/functions/_backend/utils/postgres_schema.ts Updates Drizzle schema for the new apps.allow_device_custom_id column.
supabase/functions/_backend/utils/pg.ts Extends getAppOwnerPostgres to return allow_device_custom_id with replica-safe access.
supabase/functions/_backend/public/app/put.ts Allows updating allow_device_custom_id via the app settings update endpoint.
supabase/functions/_backend/public/app/index.ts Extends PUT body typing to include allow_device_custom_id.
supabase/functions/_backend/plugins/stats.ts Implements gating logic in /stats, emits customIdBlocked, and routes to primary DB when needed.
src/types/supabase.types.ts Updates frontend-generated DB types for the new column and enum value.
Comments suppressed due to low confidence (1)

supabase/functions/_backend/plugins/stats.ts:111

  • allowDeviceCustomIdFromPg() is invoked inside post(), so in batch mode it can execute once per event (and even in single mode it adds an extra apps read). Since all events in a batch are forced to share the same app_id, and getAppOwnerPostgres() already reads from apps (now including allow_device_custom_id), this introduces avoidable extra DB roundtrips. Consider fetching allow_device_custom_id once per request (or reuse appOwner.allow_device_custom_id when appOwner is available), and only fall back to a dedicated query in the cached cancelled branch where getAppOwnerPostgres() isn’t called.
  const allowDeviceCustomId = requestedCustomId === '' ? true : await allowDeviceCustomIdFromPg(drizzleClient, app_id)

  if (cachedStatus === 'cancelled') {
    const statsActions: StatsActions[] = [{ action: 'needPlanUpgrade' }]
    // Keep behavior backward compatible (default allow=true), but allow owners to
    // disable custom_id persistence from unauthenticated /stats traffic.
    if (!allowDeviceCustomId && requestedCustomId !== '') {
      device.custom_id = undefined
      statsActions.push({ action: 'customIdBlocked' })
    }
    await sendStatsAndDevice(c, device, statsActions)
    return { success: false, error: 'need_plan_upgrade', message: PLAN_ERROR }
  }
  const appOwner = await getAppOwnerPostgres(c, app_id, drizzleClient as ReturnType<typeof getDrizzleClient>, planActions)
  if (!appOwner) {

async function post(c: Context, drizzleClient: ReturnType<typeof getDrizzleClient>, body: AppStats): Promise<PostResult> {
const device = makeDevice(body)
const { app_id, action, version_name, old_version_name, plugin_version } = body
const requestedCustomId = typeof body.custom_id === 'string' ? body.custom_id.trim() : ''
Copy link

Copilot AI Feb 10, 2026

Choose a reason for hiding this comment

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

requestedCustomId is computed with .trim(), but device.custom_id is left untrimmed. This means a whitespace-only custom_id (e.g. " ") is treated as "not provided" for gating (so the app flag isn’t checked) while still being persisted to devices.custom_id via sendStatsAndDevice/trackDevicesSB. Consider normalizing device.custom_id to the trimmed value (or undefined when trim() results in empty) before calling sendStatsAndDevice, and use that normalized value consistently for the allow check.

Suggested change
const requestedCustomId = typeof body.custom_id === 'string' ? body.custom_id.trim() : ''
const rawCustomId = typeof body.custom_id === 'string' ? body.custom_id : ''
const requestedCustomId = rawCustomId.trim()
device.custom_id = requestedCustomId === '' ? undefined : requestedCustomId

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 43d032a76b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

await setAppStatus(c, app_id, 'cloud')
const statsActions: StatsActions[] = []
if (!allowDeviceCustomId && requestedCustomId !== '') {
device.custom_id = undefined
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve custom_id when blocking device-supplied values

When allow_device_custom_id is false, this branch sets device.custom_id = undefined and still sends the device through sendStatsAndDevice. In workerd production paths, trackDevicesCF normalizes undefined to '' before writing Analytics Engine blobs, and readDevicesCF uses argMax(blob5, timestamp), so blocked /stats events still mutate custom_id by clearing it. That means the new guard does not actually make custom_id immutable for Cloudflare-backed traffic.

Useful? React with 👍 / 👎.

return { success: true, isOnprem: true }
}

const allowDeviceCustomId = requestedCustomId === '' ? true : await allowDeviceCustomIdFromPg(drizzleClient, app_id)
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 Badge Reject whitespace custom_id when app disables custom_id writes

The gate treats a trimmed custom_id of '' as “not provided”, so requests like custom_id: ' ' skip the blocking path even when allow_device_custom_id is false. Because the raw value is then passed through to persistence (and trackDevicesSB only treats exact '' as empty), a client can still write whitespace custom_id values via unauthenticated /stats, bypassing the intended protection.

Useful? React with 👍 / 👎.

@riderx
Copy link
Copy Markdown
Member Author

riderx commented Feb 10, 2026

Addressed review comments:

  • Normalized custom_id once in /stats (trim + empty => undefined) and use the normalized value consistently for allow-check + persistence.
  • Cloudflare Analytics Engine device reads now use argMaxIf(blob5, timestamp, blob5 != '') so events that omit/strip custom_id (including blocked /stats) no longer clear previously-set values.
  • Added backend tests to cover trimming persistence and blocking of trimmed custom_id when apps.allow_device_custom_id=false.

@riderx
Copy link
Copy Markdown
Member Author

riderx commented Feb 10, 2026

CI fix: bun typecheck was failing because Database["public"]["Enums"]["stats_action"] union types in supabase.functions/_backend/utils/supabase.types.ts and src/types/supabase.types.ts did not include customIdBlocked (only Constants did).

Added | "customIdBlocked" to both unions; bun typecheck now passes locally.

@sonarqubecloud
Copy link
Copy Markdown

@riderx riderx merged commit 5a4828b into main Feb 10, 2026
14 checks passed
@riderx riderx deleted the riderx/stats-customid-guard branch February 10, 2026 14:39
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.

2 participants