Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions supabase/functions/_backend/plugins/channel_self.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ async function assertChannelSelfAppOwnerPlanValid(
}

if (!appOwner.plan_valid) {
await setAppStatus(c, appId, 'cancelled')
await setAppStatus(c, appId, 'cancelled', { allow_device_custom_id: appOwner.allow_device_custom_id })
cloudlog({ requestId: c.get('requestId'), message: 'Cannot update, upgrade plan to continue to update', id: appId })
await sendStatsAndDevice(c, device, [{ action: 'needPlanUpgrade' }])

Expand All @@ -120,7 +120,7 @@ async function assertChannelSelfAppOwnerPlanValid(
return { response: c.json({ error: 'on_premise_app', message: 'On-premise app detected' }, 429) }
}

await setAppStatus(c, appId, 'cloud')
await setAppStatus(c, appId, 'cloud', { allow_device_custom_id: appOwner.allow_device_custom_id })
Comment on lines 100 to +123
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

appOwner.allow_device_custom_id is accessed here, but getAppOwnerPostgres() currently does not select/return that column (see utils/pg.ts). This should fail TypeScript and will make the cache payload/enforcement incorrect at runtime. Update getAppOwnerPostgres() to include allow_device_custom_id (ideally preserving the previous replica-safe fallback semantics).

Copilot uses AI. Check for mistakes.
return { appOwner }
}

Expand Down
38 changes: 13 additions & 25 deletions supabase/functions/_backend/plugins/stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,14 @@ import type { MiddlewareKeyVariables } from '../utils/hono.ts'
import type { Database } from '../utils/supabase.types.ts'
import type { AppStats, StatsActions } from '../utils/types.ts'
import { greaterOrEqual, parse } from '@std/semver'
import { eq } from 'drizzle-orm'
import { Hono } from 'hono/tiny'
import { z } from 'zod/mini'
import { getAppStatus, setAppStatus } from '../utils/appStatus.ts'
import { getAppStatusPayload, setAppStatus } from '../utils/appStatus.ts'
import { BRES, simpleError, simpleError200, simpleRateLimit } from '../utils/hono.ts'
import { cloudlog } from '../utils/logging.ts'
import { sendNotifOrgCached } from '../utils/notifications.ts'
import { closeClient, ensurePlaceholderVersions, getAppOwnerPostgres, getAppVersionPostgres, getDrizzleClient, getPgClient } from '../utils/pg.ts'
import { makeDevice, parsePluginBody } from '../utils/plugin_parser.ts'
import * as schema from '../utils/postgres_schema.ts'
import { createStatsMau, createStatsVersion, onPremStats, sendStatsAndDevice } from '../utils/stats.ts'
import { backgroundTask, deviceIdRegex, INVALID_STRING_APP_ID, INVALID_STRING_DEVICE_ID, isLimited, MISSING_STRING_APP_ID, MISSING_STRING_DEVICE_ID, MISSING_STRING_PLATFORM, MISSING_STRING_VERSION_NAME, MISSING_STRING_VERSION_OS, NON_STRING_APP_ID, NON_STRING_DEVICE_ID, NON_STRING_PLATFORM, NON_STRING_VERSION_NAME, NON_STRING_VERSION_OS, reverseDomainRegex } from '../utils/utils.ts'
import { ALLOWED_STATS_ACTIONS } from './stats_actions.ts'
Expand All @@ -21,18 +19,6 @@ z.config(z.locales.en())

const PLAN_ERROR = 'Cannot send stats, upgrade plan to continue to update'

async function allowDeviceCustomIdFromPg(drizzleClient: ReturnType<typeof getDrizzleClient>, app_id: string): Promise<boolean> {
const res = await drizzleClient
.select({
allow_device_custom_id: schema.apps.allow_device_custom_id,
})
.from(schema.apps)
.where(eq(schema.apps.app_id, app_id))
.limit(1)

return res[0]?.allow_device_custom_id ?? true
}

export interface BatchStatsResult {
status: 'ok' | 'error'
error?: string
Expand Down Expand Up @@ -88,23 +74,24 @@ async function post(c: Context, drizzleClient: ReturnType<typeof getDrizzleClien
// Normalize once and use consistently for gating + persistence.
// Whitespace-only values are treated as "not provided".
device.custom_id = requestedCustomId === '' ? undefined : requestedCustomId
const hasCustomId = device.custom_id !== undefined

const planActions: Array<'mau' | 'bandwidth'> = ['mau', 'bandwidth']
const cachedStatus = await getAppStatus(c, app_id)
const cached = await getAppStatusPayload(c, app_id)
const cachedStatus = cached?.status ?? null

if (cachedStatus === 'onprem') {
await onPremStats(c, app_id, action, device)
return { success: true, isOnprem: true }
}

const allowDeviceCustomId = device.custom_id === undefined ? 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 && device.custom_id !== undefined) {
const allowDeviceCustomId = hasCustomId ? (cached?.allow_device_custom_id ?? true) : true
if (!allowDeviceCustomId && hasCustomId) {
device.custom_id = undefined
statsActions.push({ action: 'customIdBlocked' })
}
await sendStatsAndDevice(c, device, statsActions)
return { success: false, error: 'need_plan_upgrade', message: PLAN_ERROR }
Expand All @@ -115,13 +102,14 @@ async function post(c: Context, drizzleClient: ReturnType<typeof getDrizzleClien
await onPremStats(c, app_id, action, device)
return { success: true, isOnprem: true }
}

const allowDeviceCustomId = hasCustomId ? appOwner.allow_device_custom_id : true
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 Select allow_device_custom_id before enforcing custom IDs

appOwner.allow_device_custom_id is read here, but getAppOwnerPostgres does not currently select or type that field (it only returns owner/plan/channel/metadata fields in utils/pg.ts), so this value is always undefined on the /stats DB path. When a request includes custom_id, allowDeviceCustomId becomes falsey and the handler drops custom_id (and emits customIdBlocked) even for apps that actually allow custom IDs, which regresses production stats/device persistence behavior for any app sending custom_id.

Useful? React with 👍 / 👎.

if (!appOwner.plan_valid) {
await setAppStatus(c, app_id, 'cancelled')
await setAppStatus(c, app_id, 'cancelled', { allow_device_custom_id: appOwner.allow_device_custom_id })
cloudlog({ requestId: c.get('requestId'), message: 'Cannot update, upgrade plan to continue to update', id: app_id })
const upgradeActions: StatsActions[] = [{ action: 'needPlanUpgrade' }]
if (!allowDeviceCustomId && device.custom_id !== undefined) {
if (!allowDeviceCustomId && hasCustomId) {
device.custom_id = undefined
Comment on lines +106 to 112
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

appOwner.allow_device_custom_id is used here, but getAppOwnerPostgres() currently does not select/return that column (see utils/pg.ts around getAppOwnerPostgres). This will be a TypeScript error and at runtime the value will be undefined, breaking custom_id enforcement and the cancelled-cache payload population. Update getAppOwnerPostgres() to include allow_device_custom_id (and keep the replica-safe/backward-compatible selection behavior you previously had via to_jsonb(...)->>'allow_device_custom_id').

Copilot uses AI. Check for mistakes.
upgradeActions.push({ action: 'customIdBlocked' })
}
await sendStatsAndDevice(c, device, upgradeActions)
// Send weekly notification about missing payment (not configurable - payment related)
Expand All @@ -132,9 +120,9 @@ async function post(c: Context, drizzleClient: ReturnType<typeof getDrizzleClien
}, appOwner.owner_org, app_id, '0 0 * * 1', appOwner.orgs.management_email, drizzleClient)) // Weekly on Monday
return { success: false, error: 'need_plan_upgrade', message: 'Cannot update, upgrade plan to continue to update' }
}
await setAppStatus(c, app_id, 'cloud')
await setAppStatus(c, app_id, 'cloud', { allow_device_custom_id: appOwner.allow_device_custom_id })
const statsActions: StatsActions[] = []
if (!allowDeviceCustomId && device.custom_id !== undefined) {
if (!allowDeviceCustomId && hasCustomId) {
device.custom_id = undefined
statsActions.push({ action: 'customIdBlocked' })
}
Expand Down
10 changes: 10 additions & 0 deletions supabase/functions/_backend/public/app/put.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Context } from 'hono'
import type { MiddlewareKeyVariables } from '../../utils/hono.ts'
import type { Database } from '../../utils/supabase.types.ts'
import { getAppStatusPayload, setAppStatus } from '../../utils/appStatus.ts'
import { quickError, simpleError } from '../../utils/hono.ts'
import { checkPermission } from '../../utils/rbac.ts'
import { createSignedImageUrl, normalizeImagePath } from '../../utils/storage.ts'
Expand Down Expand Up @@ -57,5 +58,14 @@ export async function put(c: Context<MiddlewareKeyVariables>, appId: string, bod
data.icon_url = signedIcon ?? ''
}

// Best-effort: if the plugin app-status cache already exists (cancelled fast-path),
// update the cached allow_device_custom_id value so enforcement is immediate.
if (body.allow_device_custom_id !== undefined) {
const cached = await getAppStatusPayload(c, appId)
if (cached?.status === 'cancelled') {
await setAppStatus(c, appId, cached.status, { allow_device_custom_id: body.allow_device_custom_id })
}
}

return c.json(data)
}
51 changes: 38 additions & 13 deletions supabase/functions/_backend/utils/appStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,33 +7,58 @@ const APP_STATUS_CACHE_TTL_SECONDS = 60

export type AppStatus = 'cloud' | 'onprem' | 'cancelled'

export interface AppStatusPayload {
status: AppStatus
// Optional metadata attached to the cached app status.
// Kept optional for backward compatibility with older cache entries.
allow_device_custom_id?: boolean
}

function buildAppStatusRequest(c: Context, appId: string) {
const helper = new CacheHelper(c)
if (!helper.available)
return null
return {
helper,
request: helper.buildRequest(APP_STATUS_CACHE_PATH, { app_id: appId }),
}
}

export async function getAppStatus(c: Context, appId: string): Promise<AppStatus | null> {
export async function getAppStatusPayload(c: Context, appId: string): Promise<AppStatusPayload | null> {
const cacheEntry = buildAppStatusRequest(c, appId)
if (!cacheEntry)
return null
const payload = await cacheEntry.helper.matchJson<{ status: AppStatus }>(cacheEntry.request)
const payload = await cacheEntry.helper.matchJson<AppStatusPayload>(cacheEntry.request)
if (!payload)
return null
// In local/CI environments, Stripe may be intentionally unconfigured. In that
// case, the "cancelled" cache fast-path should not block normal behavior.
if (payload.status === 'cancelled' && !isStripeConfigured(c))
return 'cloud'
return payload.status
return { ...payload, status: 'cloud' }
return payload
}

export async function getAppStatus(c: Context, appId: string): Promise<AppStatus | null> {
const payload = await getAppStatusPayload(c, appId)
return payload?.status ?? null
}

export function setAppStatus(c: Context, appId: string, status: AppStatus) {
return backgroundTask(c, async () => {
const cacheEntry = buildAppStatusRequest(c, appId)
if (!cacheEntry)
return
await cacheEntry.helper.putJson(cacheEntry.request, { status }, APP_STATUS_CACHE_TTL_SECONDS)
})
export async function setAppStatus(c: Context, appId: string, status: AppStatus, payload?: Omit<AppStatusPayload, 'status'>) {
const cacheEntry = buildAppStatusRequest(c, appId)
if (!cacheEntry)
return

const write = cacheEntry.helper.putJson(
cacheEntry.request,
{ status, ...payload },
APP_STATUS_CACHE_TTL_SECONDS,
)

// Cancelled/on-prem statuses are used as DB short-circuit fast-paths.
// Write them eagerly so the next request sees the cached state immediately.
if (status === 'cancelled' || status === 'onprem') {
await write
return
}

// Cloud status is best-effort: avoid adding latency on the hot path.
await backgroundTask(c, write)
}
6 changes: 5 additions & 1 deletion supabase/functions/_backend/utils/pg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,12 +527,15 @@ export async function getAppOwnerPostgres(
appId: string,
drizzleClient: ReturnType<typeof getDrizzleClient>,
actions: ('mau' | 'storage' | 'bandwidth')[] = [],
): Promise<{ owner_org: string, orgs: { created_by: string, id: string, management_email: string }, plan_valid: boolean, channel_device_count: number, manifest_bundle_count: number, expose_metadata: boolean } | null> {
): Promise<{ owner_org: string, orgs: { created_by: string, id: string, management_email: string }, plan_valid: boolean, channel_device_count: number, manifest_bundle_count: number, expose_metadata: boolean, allow_device_custom_id: boolean } | null> {
try {
if (actions.length === 0)
return null
const orgAlias = alias(schema.orgs, 'orgs')
const planExpression = buildPlanValidationExpression(actions, schema.apps.owner_org)
// Replica-safe optional column access:
// reading via to_jsonb keeps query parsing compatible during schema rollout.
const allowDeviceCustomIdExpression = sql<boolean>`COALESCE((to_jsonb(${schema.apps}) ->> 'allow_device_custom_id')::boolean, true)`

const appOwner = await drizzleClient
.select({
Expand All @@ -541,6 +544,7 @@ export async function getAppOwnerPostgres(
channel_device_count: schema.apps.channel_device_count,
manifest_bundle_count: schema.apps.manifest_bundle_count,
expose_metadata: schema.apps.expose_metadata,
allow_device_custom_id: allowDeviceCustomIdExpression,
orgs: {
created_by: orgAlias.created_by,
id: orgAlias.id,
Expand Down
4 changes: 2 additions & 2 deletions supabase/functions/_backend/utils/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export async function updateWithPG(
return onPremStats(c, app_id, 'get', device)
}
if (!appOwner.plan_valid) {
await setAppStatus(c, app_id, 'cancelled')
await setAppStatus(c, app_id, 'cancelled', { allow_device_custom_id: appOwner.allow_device_custom_id })
cloudlog({ requestId: c.get('requestId'), message: 'Cannot update, upgrade plan to continue to update', id: app_id })
await sendStatsAndDevice(c, device, [{ action: 'needPlanUpgrade' }])
// Send weekly notification about missing payment (not configurable - payment related)
Expand All @@ -96,7 +96,7 @@ export async function updateWithPG(
}, appOwner.owner_org, app_id, '0 0 * * 1', appOwner.orgs.management_email, drizzleClient)) // Weekly on Monday
return c.json({ error: 'on_premise_app', message: 'On-premise app detected' }, 429)
}
await setAppStatus(c, app_id, 'cloud')
await setAppStatus(c, app_id, 'cloud', { allow_device_custom_id: appOwner.allow_device_custom_id })
Comment on lines 88 to +99
Copy link

Copilot AI Feb 11, 2026

Choose a reason for hiding this comment

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

appOwner.allow_device_custom_id is referenced when writing the app-status cache payload, but getAppOwnerPostgres() does not currently include that field in its select/return shape. This will not type-check and will write allow_device_custom_id: undefined to cache. Extend getAppOwnerPostgres() to return the column (preferably in a replica-safe way if schema rollout lag is a concern).

Copilot uses AI. Check for mistakes.
const channelDeviceCount = appOwner.channel_device_count ?? 0
const manifestBundleCount = appOwner.manifest_bundle_count ?? 0
const bypassChannelOverrides = channelDeviceCount <= 0
Expand Down
Loading