From 0c6ae6d5d0e371d6c04566cb98527db42a6872a2 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 25 Feb 2026 01:26:01 +0000 Subject: [PATCH 1/2] fix(backend): enforce atomic demo app creation limits --- .../functions/_backend/public/app/demo.ts | 219 ++++++++++++++++-- ...0260225000100_atomic_demo_app_creation.sql | 162 +++++++++++++ 2 files changed, 356 insertions(+), 25 deletions(-) create mode 100644 supabase/migrations/20260225000100_atomic_demo_app_creation.sql diff --git a/supabase/functions/_backend/public/app/demo.ts b/supabase/functions/_backend/public/app/demo.ts index 7c494290c2..5b0558e409 100644 --- a/supabase/functions/_backend/public/app/demo.ts +++ b/supabase/functions/_backend/public/app/demo.ts @@ -1,9 +1,11 @@ import type { Context } from 'hono' import type { AuthInfo, MiddlewareKeyVariables } from '../../utils/hono.ts' import type { Database } from '../../utils/supabase.types.ts' -import { simpleError } from '../../utils/hono.ts' +import { DEMO_APP_PREFIX } from '../../utils/demo.ts' +import { quickError, simpleError, simpleRateLimit } from '../../utils/hono.ts' import { cloudlog } from '../../utils/logging.ts' -import { hasOrgRight, supabaseAdmin, updateOrCreateChannel } from '../../utils/supabase.ts' +import { hasOrgRight, isAllowedActionOrg, isPayingOrg, supabaseAdmin, updateOrCreateChannel } from '../../utils/supabase.ts' +import { getEnv } from '../../utils/utils.ts' /** Request body for creating a demo app */ export interface CreateDemoApp { @@ -32,6 +34,32 @@ interface DemoManifestEntry { file_size: number } +const DEFAULT_DEMO_APP_MAX_ACTIVE_PER_ORG = 3 +const DEFAULT_DEMO_APP_MAX_PER_USER_24H = 1 +const DEFAULT_DEMO_APP_MAX_PER_ORG_24H = 3 +const DEFAULT_DEMO_APP_MAX_PER_USER_1H = 1 +const DEFAULT_DEMO_APP_MAX_PER_ORG_1H = 1 +const DEFAULT_DEMO_APP_MAX_PER_USER_24H_FREE = 1 +const DEFAULT_DEMO_APP_MAX_PER_ORG_24H_FREE = 1 +const DEFAULT_DEMO_APP_ACTIVE_WINDOW_DAYS = 14 + +interface DemoRateLimits { + userPerHour: number + orgPerHour: number + userPer24h: number + orgPer24h: number +} + +interface DemoAppCreationDecision { + created: boolean + reason?: string + limit?: number + count?: number + retry_after_seconds?: number + window_seconds?: number + app?: Database['public']['Tables']['apps']['Row'] +} + /** * Generate demo native packages (Capacitor plugins) * @param versionName - Version name to base the package versions on @@ -215,6 +243,167 @@ function generateDeviceId(): string { return crypto.randomUUID() } +function getPositiveEnvInt(c: Context, key: string, defaultValue: number): number { + const rawValue = getEnv(c, key) + if (!rawValue) { + return defaultValue + } + + const parsed = Number.parseInt(rawValue, 10) + if (Number.isNaN(parsed) || parsed <= 0) { + return defaultValue + } + + return parsed +} + +async function getDemoRateLimits(c: Context, ownerOrg: string): Promise { + const payingOrg = await isPayingOrg(c, ownerOrg) + + const userPerHourLimit = getPositiveEnvInt(c, 'RATE_LIMIT_DEMO_APP_PER_USER_1H', DEFAULT_DEMO_APP_MAX_PER_USER_1H) + const orgPerHourLimit = getPositiveEnvInt(c, 'RATE_LIMIT_DEMO_APP_PER_ORG_1H', DEFAULT_DEMO_APP_MAX_PER_ORG_1H) + + // Keep free-tier stricter by default unless explicitly overridden. + const userPer24hLimit = getPositiveEnvInt( + c, + 'RATE_LIMIT_DEMO_APP_PER_USER_24H', + payingOrg + ? DEFAULT_DEMO_APP_MAX_PER_USER_24H + : DEFAULT_DEMO_APP_MAX_PER_USER_24H_FREE, + ) + const orgPer24hLimit = getPositiveEnvInt( + c, + 'RATE_LIMIT_DEMO_APP_PER_ORG_24H', + payingOrg + ? DEFAULT_DEMO_APP_MAX_PER_ORG_24H + : DEFAULT_DEMO_APP_MAX_PER_ORG_24H_FREE, + ) + + return { + userPerHour: userPerHourLimit, + orgPerHour: orgPerHourLimit, + userPer24h: userPer24hLimit, + orgPer24h: orgPer24hLimit, + } +} + +async function assertDemoAppCreationLimits( + c: Context, + supabase: ReturnType, + ownerOrg: string, + userId: string, + appId: string, + appDefaults: { + retention: number + defaultUploadChannel: string + lastVersion: string + name: string + iconUrl: string + }, +): Promise { + const canUseMore = await isAllowedActionOrg(c, ownerOrg) + if (!canUseMore) { + throw quickError(402, 'need_plan_upgrade', 'Cannot create demo app, upgrade plan to continue', { owner_org: ownerOrg }) + } + + const activeWindowDays = getPositiveEnvInt(c, 'DEMO_APP_ACTIVE_WINDOW_DAYS', DEFAULT_DEMO_APP_ACTIVE_WINDOW_DAYS) + const maxActiveDemoApps = getPositiveEnvInt(c, 'DEMO_APP_MAX_ACTIVE_PER_ORG', DEFAULT_DEMO_APP_MAX_ACTIVE_PER_ORG) + const limits = await getDemoRateLimits(c, ownerOrg) + + const rpcArgs = { + p_owner_org: ownerOrg, + p_user_id: userId, + p_app_id: appId, + p_name: appDefaults.name, + p_icon_url: appDefaults.iconUrl, + p_retention: appDefaults.retention, + p_default_upload_channel: appDefaults.defaultUploadChannel, + p_last_version: appDefaults.lastVersion, + p_active_window_days: activeWindowDays, + p_user_per_hour: limits.userPerHour, + p_org_per_hour: limits.orgPerHour, + p_user_per_24h: limits.userPer24h, + p_org_per_24h: limits.orgPer24h, + p_max_active_per_org: maxActiveDemoApps, + } + + const { data, error } = await supabase + .rpc('create_demo_app_with_limits', rpcArgs) + .single() + + if (error) { + cloudlog({ requestId: c.get('requestId'), message: 'Error reserving demo app slot', error, owner_org: ownerOrg }) + throw simpleError('cannot_create_demo_app', 'Cannot create demo app', { supabaseError: error }) + } + + const decision = data as DemoAppCreationDecision | null + if (!decision?.created) { + if (decision?.reason === 'demo_app_quota_exceeded') { + throw quickError( + 429, + 'demo_app_quota_exceeded', + 'Demo app quota reached for this organization', + { + owner_org: ownerOrg, + active_demo_apps: decision.count, + max_active_demo_apps: decision.limit, + }, + ) + } + + if (decision?.reason === 'demo_app_user_rate_limit_exceeded') { + simpleRateLimit({ + reason: decision.reason, + owner_org: ownerOrg, + user_id: userId, + retryAfterSeconds: decision.retry_after_seconds, + window_seconds: decision.window_seconds, + limit: decision.limit, + count: decision.count, + }) + } + + if (decision?.reason === 'demo_app_org_rate_limit_exceeded') { + simpleRateLimit({ + reason: decision.reason, + owner_org: ownerOrg, + retryAfterSeconds: decision.retry_after_seconds, + window_seconds: decision.window_seconds, + limit: decision.limit, + count: decision.count, + }) + } + + throw simpleError('demo_app_limit_enforcement_failed', 'Demo app limit enforcement failed', { + owner_org: ownerOrg, + reason: decision?.reason, + }) + } + + if (!decision.app) { + throw simpleError('cannot_create_demo_app', 'Cannot create demo app', { owner_org: ownerOrg }) + } + + return decision.app +} + +/** Ensure quota checks are enforced atomically at write time. */ +async function createDemoAppRecord( + c: Context, + supabase: ReturnType, + ownerOrg: string, + userId: string, + appId: string, +) { + return assertDemoAppCreationLimits(c, supabase, ownerOrg, userId, appId, { + retention: 2592000, + defaultUploadChannel: 'production', + lastVersion: '1.2.0', + name: 'Demo App', + iconUrl: '', + }) +} + /** * Creates a demo app for non-technical users during onboarding. * Demo apps are identified by the 'com.capdemo.' prefix in their app_id @@ -248,9 +437,11 @@ export async function createDemoApp(c: Context, body: Cr throw simpleError('cannot_access_organization', 'You can\'t access this organization', { org_id: body.owner_org }) } + const supabase = supabaseAdmin(c) // Generate a unique demo app_id with com.capdemo. prefix const shortId = crypto.randomUUID().slice(0, 8) - const appId = `com.capdemo.${shortId}.app` + const appId = `${DEMO_APP_PREFIX}${shortId}.app` + const appData = await createDemoAppRecord(c, supabase, body.owner_org, auth.userId, appId) cloudlog({ requestId, message: 'Creating demo app with demo data', appId, owner_org: body.owner_org }) @@ -258,30 +449,8 @@ export async function createDemoApp(c: Context, body: Cr // channels, devices, daily_mau, daily_bandwidth, daily_storage, daily_version, build_requests, // manifest, deploy_history) where RLS policies may not grant direct user insert access. // Authorization is enforced at endpoint level via hasOrgRight check above. - const supabase = supabaseAdmin(c) // Create the demo app - const appInsert: Database['public']['Tables']['apps']['Insert'] = { - owner_org: body.owner_org, - app_id: appId, - icon_url: '', - name: 'Demo App', - retention: 2592000, - default_upload_channel: 'production', - last_version: '1.2.0', - } - - const { data: appData, error: appError } = await supabase - .from('apps') - .insert(appInsert) - .select() - .single() - - if (appError) { - cloudlog({ requestId, message: 'Error creating demo app', error: appError }) - throw simpleError('cannot_create_demo_app', 'Cannot create demo app', { supabaseError: appError }) - } - cloudlog({ requestId, message: 'Demo app created', appData }) // Demo versions to create - simulates app development lifecycle diff --git a/supabase/migrations/20260225000100_atomic_demo_app_creation.sql b/supabase/migrations/20260225000100_atomic_demo_app_creation.sql new file mode 100644 index 0000000000..bcadb536c9 --- /dev/null +++ b/supabase/migrations/20260225000100_atomic_demo_app_creation.sql @@ -0,0 +1,162 @@ +-- Atomically enforce demo app quota limits and insert the demo app row. +-- This avoids check-then-act race conditions when multiple users create demo apps +-- concurrently in the same organization. +CREATE OR REPLACE FUNCTION public.create_demo_app_with_limits( + p_owner_org uuid, + p_user_id uuid, + p_app_id text, + p_name text, + p_icon_url text, + p_retention bigint, + p_default_upload_channel text, + p_last_version text, + p_active_window_days integer, + p_user_per_hour integer, + p_org_per_hour integer, + p_user_per_24h integer, + p_org_per_24h integer, + p_max_active_per_org integer +) RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_active_window_start timestamptz := now() - make_interval(days => p_active_window_days); + v_hour_window_start timestamptz := now() - interval '1 hour'; + v_24h_window_start timestamptz := now() - interval '24 hours'; + v_created_app public.apps; + v_active_demo_apps bigint; + v_user_demo_apps_1h bigint; + v_org_demo_apps_1h bigint; + v_user_demo_apps_24h bigint; + v_org_demo_apps_24h bigint; +BEGIN + IF p_app_id IS NULL OR LEFT(p_app_id, LENGTH('com.capdemo.')) <> 'com.capdemo.' THEN + RETURN jsonb_build_object( + 'created', false, + 'reason', 'invalid_demo_app_id' + ); + END IF; + + -- Serialize demo app creation decisions per organization to avoid races. + PERFORM pg_advisory_xact_lock(hashtext(p_owner_org::text)); + + -- Active-demo-app cap (recent demo apps for this org). + SELECT COUNT(*) INTO v_active_demo_apps + FROM public.apps + WHERE owner_org = p_owner_org + AND app_id LIKE 'com.capdemo.%' + AND created_at >= v_active_window_start; + + IF v_active_demo_apps >= p_max_active_per_org THEN + RETURN jsonb_build_object( + 'created', false, + 'reason', 'demo_app_quota_exceeded', + 'count', v_active_demo_apps, + 'limit', p_max_active_per_org + ); + END IF; + + -- Per-user limit in the last hour. + SELECT COUNT(*) INTO v_user_demo_apps_1h + FROM public.apps + WHERE owner_org = p_owner_org + AND user_id = p_user_id + AND app_id LIKE 'com.capdemo.%' + AND created_at >= v_hour_window_start; + + IF v_user_demo_apps_1h >= p_user_per_hour THEN + RETURN jsonb_build_object( + 'created', false, + 'reason', 'demo_app_user_rate_limit_exceeded', + 'count', v_user_demo_apps_1h, + 'limit', p_user_per_hour, + 'window_seconds', 3600, + 'retry_after_seconds', 60 * 60 + ); + END IF; + + -- Per-org limit in the last hour. + SELECT COUNT(*) INTO v_org_demo_apps_1h + FROM public.apps + WHERE owner_org = p_owner_org + AND app_id LIKE 'com.capdemo.%' + AND created_at >= v_hour_window_start; + + IF v_org_demo_apps_1h >= p_org_per_hour THEN + RETURN jsonb_build_object( + 'created', false, + 'reason', 'demo_app_org_rate_limit_exceeded', + 'count', v_org_demo_apps_1h, + 'limit', p_org_per_hour, + 'window_seconds', 3600, + 'retry_after_seconds', 60 * 60 + ); + END IF; + + -- Per-user limit in the last 24h. + SELECT COUNT(*) INTO v_user_demo_apps_24h + FROM public.apps + WHERE owner_org = p_owner_org + AND user_id = p_user_id + AND app_id LIKE 'com.capdemo.%' + AND created_at >= v_24h_window_start; + + IF v_user_demo_apps_24h >= p_user_per_24h THEN + RETURN jsonb_build_object( + 'created', false, + 'reason', 'demo_app_user_rate_limit_exceeded', + 'count', v_user_demo_apps_24h, + 'limit', p_user_per_24h, + 'window_seconds', 86400, + 'retry_after_seconds', 24 * 60 * 60 + ); + END IF; + + -- Per-org limit in the last 24h. + SELECT COUNT(*) INTO v_org_demo_apps_24h + FROM public.apps + WHERE owner_org = p_owner_org + AND app_id LIKE 'com.capdemo.%' + AND created_at >= v_24h_window_start; + + IF v_org_demo_apps_24h >= p_org_per_24h THEN + RETURN jsonb_build_object( + 'created', false, + 'reason', 'demo_app_org_rate_limit_exceeded', + 'count', v_org_demo_apps_24h, + 'limit', p_org_per_24h, + 'window_seconds', 86400, + 'retry_after_seconds', 24 * 60 * 60 + ); + END IF; + + INSERT INTO public.apps ( + owner_org, + app_id, + user_id, + icon_url, + name, + retention, + default_upload_channel, + last_version + ) + VALUES ( + p_owner_org, + p_app_id, + p_user_id, + p_icon_url, + p_name, + p_retention, + p_default_upload_channel, + p_last_version + ) + RETURNING * INTO v_created_app; + + RETURN jsonb_build_object( + 'created', true, + 'app', to_jsonb(v_created_app) + ); +END +$$; From 470b7ce149e4424a86ec2213ea7f7d6e7f6ccd31 Mon Sep 17 00:00:00 2001 From: Martin Donadieu Date: Wed, 25 Feb 2026 01:43:21 +0000 Subject: [PATCH 2/2] fix(backend): relax typing for demo app RPC call --- supabase/functions/_backend/public/app/demo.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/supabase/functions/_backend/public/app/demo.ts b/supabase/functions/_backend/public/app/demo.ts index 5b0558e409..bba8f4f3b3 100644 --- a/supabase/functions/_backend/public/app/demo.ts +++ b/supabase/functions/_backend/public/app/demo.ts @@ -328,7 +328,7 @@ async function assertDemoAppCreationLimits( } const { data, error } = await supabase - .rpc('create_demo_app_with_limits', rpcArgs) + .rpc('create_demo_app_with_limits' as any, rpcArgs) .single() if (error) {