From 256b2ec96ac8f88a16ed9c0404a4e157781dbeb1 Mon Sep 17 00:00:00 2001 From: Marco de Jongh Date: Sun, 8 Feb 2026 19:37:41 +1100 Subject: [PATCH] Harden Aurora sync against malicious/corrupt data Treat Aurora API responses as untrusted input with runtime validation: - Fix P0: draft_climbs can no longer overwrite published climbs. All fields in onConflictDoUpdate are gated on isDraft=true via SQL CASE expressions. - Fix P0: isDraft transition corrected to only allow true->false (publishing), never false->true (unpublishing). Previous code had this backwards. - Fix P1: climb_stats numeric fields validated with sanitizeNumber() and clamped to reasonable bounds. Invalid values preserve existing DB values. - Fix P1: Sync timestamps validated - rejects unparseable/pre-2016, clamps future timestamps. table_name validated against known allowlists. - Fix P2: Per-table record cap of 10,000 prevents memory exhaustion. - Fix P2: Beta link URLs validated as http/https, invalid thumbnails nulled. - Fix P2: String fields truncated to reasonable limits across all sync paths. Co-Authored-By: Claude Opus 4.6 --- .../aurora-sync/src/sync/sync-validation.ts | 164 ++++++++++++++++++ packages/aurora-sync/src/sync/user-sync.ts | 144 ++++++++++----- .../app/lib/data-sync/aurora/shared-sync.ts | 139 ++++++++++----- .../lib/data-sync/aurora/sync-validation.ts | 161 +++++++++++++++++ .../web/app/lib/data-sync/aurora/user-sync.ts | 135 +++++++++----- 5 files changed, 607 insertions(+), 136 deletions(-) create mode 100644 packages/aurora-sync/src/sync/sync-validation.ts create mode 100644 packages/web/app/lib/data-sync/aurora/sync-validation.ts diff --git a/packages/aurora-sync/src/sync/sync-validation.ts b/packages/aurora-sync/src/sync/sync-validation.ts new file mode 100644 index 000000000..b413ad826 --- /dev/null +++ b/packages/aurora-sync/src/sync/sync-validation.ts @@ -0,0 +1,164 @@ +/** + * Validation utilities for Aurora sync data. + * + * Aurora API responses are untrusted input - all values must be validated + * before writing to our database. + * + * NOTE: This is a copy of packages/web/app/lib/data-sync/aurora/sync-validation.ts + * Will be consolidated into a shared package in a follow-up. + */ + +// --- Bounds constants --- + +export const CLIMB_STATS_BOUNDS = { + displayDifficulty: { min: 0, max: 50 }, + benchmarkDifficulty: { min: 0, max: 50 }, + ascensionistCount: { min: 0, max: 10_000_000 }, + difficultyAverage: { min: 0, max: 50 }, + qualityAverage: { min: 0, max: 5 }, +} as const; + +export const STRING_LIMITS = { + name: 500, + description: 10_000, + comment: 5_000, + username: 255, + frames: 500_000, + url: 2_048, + color: 20, + serialNumber: 255, + tableName: 100, +} as const; + +export const MAX_RECORDS_PER_TABLE = 10_000; + +// Earliest reasonable timestamp for Aurora data (Aurora Climbing founded ~2016) +const MIN_SYNC_YEAR = 2016; + +// --- Validation functions --- + +/** + * Validates and clamps a numeric value within bounds. + * Returns fallback (default null) for NaN, Infinity, or out-of-range values. + */ +export function sanitizeNumber( + value: unknown, + min: number, + max: number, + fallback: number | null = null, +): number | null { + if (value == null) return fallback; + const num = Number(value); + if (!Number.isFinite(num)) return fallback; + if (num < min || num > max) return fallback; + return num; +} + +/** + * Validates a sync timestamp string. + * - Rejects unparseable timestamps + * - Rejects timestamps before MIN_SYNC_YEAR + * - Clamps future timestamps to current time + 24h + * Returns the validated timestamp string, or null if invalid. + */ +export function validateSyncTimestamp(timestamp: string): string | null { + if (!timestamp || typeof timestamp !== 'string') return null; + + const parsed = Date.parse(timestamp); + if (isNaN(parsed)) { + console.warn(`[sync-validation] Rejecting unparseable timestamp: ${timestamp}`); + return null; + } + + const date = new Date(parsed); + const minDate = new Date(MIN_SYNC_YEAR, 0, 1); + const maxDate = new Date(Date.now() + 24 * 60 * 60 * 1000); // now + 24h + + if (date < minDate) { + console.warn(`[sync-validation] Rejecting pre-${MIN_SYNC_YEAR} timestamp: ${timestamp}`); + return null; + } + + if (date > maxDate) { + console.warn(`[sync-validation] Clamping future timestamp: ${timestamp} -> now`); + return new Date().toISOString().replace('T', ' ').replace('Z', ''); + } + + return timestamp; +} + +/** + * Validates that a string is a valid http/https URL within length limits. + */ +export function isValidHttpUrl(urlStr: unknown, maxLength: number = STRING_LIMITS.url): boolean { + if (!urlStr || typeof urlStr !== 'string') return false; + if (urlStr.length > maxLength) return false; + + try { + const url = new URL(urlStr); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +/** + * Truncates a string to maxLength characters. Returns the original string if already within limits. + */ +export function truncate(value: string | null | undefined, maxLength: number): string { + if (!value) return ''; + if (value.length <= maxLength) return value; + console.warn(`[sync-validation] Truncating string from ${value.length} to ${maxLength} chars`); + return value.slice(0, maxLength); +} + +/** + * Truncates a string, returning null if the input is null/undefined. + */ +export function truncateOrNull(value: string | null | undefined, maxLength: number): string | null { + if (value == null) return null; + if (value.length <= maxLength) return value; + console.warn(`[sync-validation] Truncating string from ${value.length} to ${maxLength} chars`); + return value.slice(0, maxLength); +} + +/** + * Caps an array to MAX_RECORDS_PER_TABLE, logging a warning if truncated. + */ +export function capRecords(records: T[], tableName: string, max: number = MAX_RECORDS_PER_TABLE): T[] { + if (records.length <= max) return records; + console.warn( + `[sync-validation] Capping ${tableName} from ${records.length} to ${max} records`, + ); + return records.slice(0, max); +} + +// Known valid table names for sync tracking +export const VALID_SHARED_SYNC_TABLES = new Set([ + 'products', + 'product_sizes', + 'holes', + 'leds', + 'products_angles', + 'layouts', + 'product_sizes_layouts_sets', + 'placements', + 'sets', + 'placement_roles', + 'climbs', + 'climb_stats', + 'beta_links', + 'attempts', + 'kits', +]); + +export const VALID_USER_SYNC_TABLES = new Set([ + 'users', + 'walls', + 'wall_expungements', + 'draft_climbs', + 'ascents', + 'bids', + 'tags', + 'circuits', +]); diff --git a/packages/aurora-sync/src/sync/user-sync.ts b/packages/aurora-sync/src/sync/user-sync.ts index 79b5956e4..6678b4408 100644 --- a/packages/aurora-sync/src/sync/user-sync.ts +++ b/packages/aurora-sync/src/sync/user-sync.ts @@ -8,6 +8,20 @@ import { UNIFIED_TABLES } from '../db/table-select'; import { boardseshTicks, playlists, playlistClimbs, playlistOwnership } from '@boardsesh/db/schema/app'; import { randomUUID } from 'crypto'; import { convertQuality } from './convert-quality'; +import { + validateSyncTimestamp, + truncate, + truncateOrNull, + capRecords, + STRING_LIMITS, + VALID_USER_SYNC_TABLES, +} from './sync-validation'; + +function safeInt(value: unknown): number | null { + if (value == null) return null; + const num = Number(value); + return Number.isNaN(num) ? null : num; +} // Batch size for bulk inserts const BATCH_SIZE = 100; @@ -53,7 +67,7 @@ async function upsertTableData( const values = batch.map((item) => ({ boardType: boardName, id: Number(item.id), - username: item.username, + username: truncate(item.username, STRING_LIMITS.username), createdAt: item.created_at, })); await db @@ -76,14 +90,14 @@ async function upsertTableData( boardType: boardName, uuid: item.uuid, userId: Number(auroraUserId), - name: item.name, + name: truncate(item.name, STRING_LIMITS.name), productId: Number(item.product_id), isAdjustable: Boolean(item.is_adjustable), angle: Number(item.angle), layoutId: Number(item.layout_id), productSizeId: Number(item.product_size_id), hsm: Number(item.hsm), - serialNumber: item.serial_number, + serialNumber: truncateOrNull(item.serial_number, STRING_LIMITS.serialNumber), createdAt: item.created_at, })); await db @@ -107,24 +121,38 @@ async function upsertTableData( case 'draft_climbs': { const climbsSchema = UNIFIED_TABLES.climbs; - await processBatches(data, BATCH_SIZE, async (batch) => { + const validData = data.filter((item) => { + if (!item.uuid) { + log(` Warning: skipping draft_climb with missing uuid`); + return false; + } + if (item.layout_id == null || Number.isNaN(Number(item.layout_id))) { + log(` Warning: skipping draft_climb ${item.uuid} with invalid layout_id: ${item.layout_id}`); + return false; + } + return true; + }); + if (validData.length < data.length) { + log(` Filtered out ${data.length - validData.length} invalid draft_climbs`); + } + await processBatches(validData, BATCH_SIZE, async (batch) => { const values = batch.map((item) => ({ boardType: boardName, uuid: item.uuid, layoutId: Number(item.layout_id), - setterId: Number(auroraUserId), - setterUsername: item.setter_username || '', - name: item.name || 'Untitled Draft', - description: item.description || '', - hsm: Number(item.hsm), - edgeLeft: Number(item.edge_left), - edgeRight: Number(item.edge_right), - edgeBottom: Number(item.edge_bottom), - edgeTop: Number(item.edge_top), - angle: Number(item.angle), + setterId: safeInt(auroraUserId) ?? Number(auroraUserId), + setterUsername: truncate(item.setter_username || '', STRING_LIMITS.username), + name: truncate(item.name || 'Untitled Draft', STRING_LIMITS.name), + description: truncate(item.description || '', STRING_LIMITS.description), + hsm: safeInt(item.hsm), + edgeLeft: safeInt(item.edge_left), + edgeRight: safeInt(item.edge_right), + edgeBottom: safeInt(item.edge_bottom), + edgeTop: safeInt(item.edge_top), + angle: safeInt(item.angle), framesCount: Number(item.frames_count || 1), framesPace: Number(item.frames_pace || 0), - frames: item.frames || '', + frames: truncate(item.frames || '', STRING_LIMITS.frames), isDraft: true, isListed: false, createdAt: item.created_at || new Date().toISOString(), @@ -135,22 +163,25 @@ async function upsertTableData( .onConflictDoUpdate({ target: climbsSchema.uuid, set: { - layoutId: sql`excluded.layout_id`, - setterId: sql`excluded.setter_id`, - setterUsername: sql`excluded.setter_username`, - name: sql`excluded.name`, - description: sql`excluded.description`, - hsm: sql`excluded.hsm`, - edgeLeft: sql`excluded.edge_left`, - edgeRight: sql`excluded.edge_right`, - edgeBottom: sql`excluded.edge_bottom`, - edgeTop: sql`excluded.edge_top`, - angle: sql`excluded.angle`, - framesCount: sql`excluded.frames_count`, - framesPace: sql`excluded.frames_pace`, - frames: sql`excluded.frames`, - isDraft: sql`excluded.is_draft`, - isListed: sql`excluded.is_listed`, + // Only update fields if existing climb is still a draft. + // Published climbs (isDraft=false) are fully immutable via sync. + layoutId: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.layout_id ELSE ${climbsSchema.layoutId} END`, + setterId: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.setter_id ELSE ${climbsSchema.setterId} END`, + setterUsername: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.setter_username ELSE ${climbsSchema.setterUsername} END`, + name: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.name ELSE ${climbsSchema.name} END`, + description: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.description ELSE ${climbsSchema.description} END`, + hsm: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.hsm ELSE ${climbsSchema.hsm} END`, + edgeLeft: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.edge_left ELSE ${climbsSchema.edgeLeft} END`, + edgeRight: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.edge_right ELSE ${climbsSchema.edgeRight} END`, + edgeBottom: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.edge_bottom ELSE ${climbsSchema.edgeBottom} END`, + edgeTop: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.edge_top ELSE ${climbsSchema.edgeTop} END`, + angle: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.angle ELSE ${climbsSchema.angle} END`, + framesCount: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.frames_count ELSE ${climbsSchema.framesCount} END`, + framesPace: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.frames_pace ELSE ${climbsSchema.framesPace} END`, + frames: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN excluded.frames ELSE ${climbsSchema.frames} END`, + // Never unpublish: isDraft and isListed stay as-is + isDraft: sql`${climbsSchema.isDraft}`, + isListed: sql`${climbsSchema.isListed}`, }, }); }); @@ -174,7 +205,7 @@ async function upsertTableData( quality: convertQuality(item.quality), difficulty: item.difficulty ? Number(item.difficulty) : null, isBenchmark: Boolean(item.is_benchmark || 0), - comment: item.comment || '', + comment: truncate(item.comment || '', STRING_LIMITS.comment), climbedAt: new Date(item.climbed_at).toISOString(), createdAt: item.created_at ? new Date(item.created_at).toISOString() : now, updatedAt: now, @@ -227,7 +258,7 @@ async function upsertTableData( quality: null, difficulty: null, isBenchmark: false, - comment: item.comment || '', + comment: truncate(item.comment || '', STRING_LIMITS.comment), climbedAt: new Date(item.climbed_at).toISOString(), createdAt: new Date(item.created_at).toISOString(), updatedAt: now, @@ -264,6 +295,7 @@ async function upsertTableData( const tagsSchema = UNIFIED_TABLES.tags; await processBatches(data, BATCH_SIZE, async (batch) => { for (const item of batch) { + const tagName = truncate(item.name, STRING_LIMITS.name); // First try to update existing record const result = await db .update(tagsSchema) @@ -275,7 +307,7 @@ async function upsertTableData( eq(tagsSchema.boardType, boardName), eq(tagsSchema.entityUuid, item.entity_uuid), eq(tagsSchema.userId, Number(auroraUserId)), - eq(tagsSchema.name, item.name), + eq(tagsSchema.name, tagName), ), ) .returning(); @@ -286,7 +318,7 @@ async function upsertTableData( boardType: boardName, entityUuid: item.entity_uuid, userId: Number(auroraUserId), - name: item.name, + name: tagName, isListed: Boolean(item.is_listed), }); } @@ -303,9 +335,9 @@ async function upsertTableData( const values = batch.map((item) => ({ boardType: boardName, uuid: item.uuid, - name: item.name, - description: item.description, - color: item.color, + name: truncate(item.name, STRING_LIMITS.name), + description: truncateOrNull(item.description, STRING_LIMITS.description), + color: truncateOrNull(item.color, STRING_LIMITS.color), userId: Number(auroraUserId), isPublic: Boolean(item.is_public), createdAt: item.created_at, @@ -329,8 +361,11 @@ async function upsertTableData( // 2. Dual write to playlists table (only if NextAuth user exists) if (nextAuthUserId) { for (const item of data) { + const circuitName = truncate(item.name, STRING_LIMITS.name); + const circuitDescription = truncateOrNull(item.description, STRING_LIMITS.description); + const circuitColor = truncateOrNull(item.color, STRING_LIMITS.color); // Format color - Aurora uses hex without #, we store with # - const formattedColor = item.color ? `#${item.color}` : null; + const formattedColor = circuitColor ? `#${circuitColor}` : null; // Insert/update playlist const [playlist] = await db @@ -339,8 +374,8 @@ async function upsertTableData( uuid: item.uuid, // Use same UUID as Aurora circuit boardType: boardName, layoutId: null, // Nullable for Aurora-synced circuits - name: item.name || 'Untitled Circuit', - description: item.description || null, + name: circuitName || 'Untitled Circuit', + description: circuitDescription || null, isPublic: Boolean(item.is_public), color: formattedColor, auroraType: 'circuits', @@ -352,8 +387,8 @@ async function upsertTableData( .onConflictDoUpdate({ target: playlists.auroraId, set: { - name: item.name || 'Untitled Circuit', - description: item.description || null, + name: circuitName || 'Untitled Circuit', + description: circuitDescription || null, isPublic: Boolean(item.is_public), color: formattedColor, updatedAt: item.updated_at ? new Date(item.updated_at) : new Date(), @@ -417,18 +452,31 @@ async function updateUserSyncs( const userSyncsSchema = UNIFIED_TABLES.userSyncs; for (const sync of userSyncs) { + // Validate table_name against known list + if (!VALID_USER_SYNC_TABLES.has(sync.table_name)) { + console.warn(`[sync-validation] Skipping unknown user_sync table_name: ${String(sync.table_name).slice(0, 100)}`); + continue; + } + + // Validate timestamp + const validatedTimestamp = validateSyncTimestamp(sync.last_synchronized_at); + if (!validatedTimestamp) { + console.warn(`[sync-validation] Skipping user_sync for ${sync.table_name}: invalid timestamp`); + continue; + } + await tx .insert(userSyncsSchema) .values({ boardType: boardName, userId: Number(sync.user_id), tableName: sync.table_name, - lastSynchronizedAt: sync.last_synchronized_at, + lastSynchronizedAt: validatedTimestamp, }) .onConflictDoUpdate({ target: [userSyncsSchema.boardType, userSyncsSchema.userId, userSyncsSchema.tableName], set: { - lastSynchronizedAt: sync.last_synchronized_at, + lastSynchronizedAt: validatedTimestamp, }, }); } @@ -544,7 +592,7 @@ export async function syncUserData( for (const tableName of tables) { log(`Syncing ${tableName} for user ${auroraUserId} (batch ${syncAttempts})`); if (syncResults[tableName] && Array.isArray(syncResults[tableName])) { - const data = syncResults[tableName]; + const data = capRecords(syncResults[tableName], tableName); const upsertResult = await upsertTableData(tx, board, tableName, auroraUserId, nextAuthUserId, data, log); @@ -589,8 +637,8 @@ export async function syncUserData( error instanceof Error ? error.message.includes('violates foreign key constraint') ? `FK constraint violation: ${error.message.split('violates foreign key constraint')[1]?.split('"')[1] || 'unknown'}` - : error.message.slice(0, 500) - : String(error).slice(0, 500); + : error.message.slice(0, 2000) + : String(error).slice(0, 2000); log(`Database error: ${errorMessage}`); throw new Error(`Database error: ${errorMessage}`); } finally { diff --git a/packages/web/app/lib/data-sync/aurora/shared-sync.ts b/packages/web/app/lib/data-sync/aurora/shared-sync.ts index 1601c2498..37112e1df 100644 --- a/packages/web/app/lib/data-sync/aurora/shared-sync.ts +++ b/packages/web/app/lib/data-sync/aurora/shared-sync.ts @@ -7,6 +7,17 @@ import { NeonDatabase } from 'drizzle-orm/neon-serverless'; import { Attempt, BetaLink, Climb, ClimbStats, SharedSync, SyncPutFields } from '../../api-wrappers/sync-api-types'; import { UNIFIED_TABLES } from '../../db/queries/util/table-select'; import { convertLitUpHoldsStringToMap } from '@/app/components/board-renderer/util'; +import { + sanitizeNumber, + validateSyncTimestamp, + isValidHttpUrl, + truncate, + truncateOrNull, + capRecords, + CLIMB_STATS_BOUNDS, + STRING_LIMITS, + VALID_SHARED_SYNC_TABLES, +} from './sync-validation'; // Define shared sync tables in correct dependency order // Order matches what the Android app sends - keep full list to remain indistinguishable @@ -41,7 +52,7 @@ const upsertAttempts = (db: NeonDatabase>, board: AuroraBo boardType: board, id: Number(item.id), position: Number(item.position), - name: item.name, + name: truncate(item.name, STRING_LIMITS.name), }) .onConflictDoUpdate({ target: [attemptsSchema.boardType, attemptsSchema.id], @@ -49,7 +60,7 @@ const upsertAttempts = (db: NeonDatabase>, board: AuroraBo // Only allow position updates if they're reasonable (0-100) position: sql`CASE WHEN ${Number(item.position)} >= 0 AND ${Number(item.position)} <= 100 THEN ${Number(item.position)} ELSE ${attemptsSchema.position} END`, // Allow name updates for display purposes - name: item.name, + name: truncate(item.name, STRING_LIMITS.name), }, }); }), @@ -61,6 +72,20 @@ async function upsertClimbStats(db: NeonDatabase>, board: await Promise.all( data.map((item) => { + const diffAvg = sanitizeNumber(item.difficulty_average, CLIMB_STATS_BOUNDS.difficultyAverage.min, CLIMB_STATS_BOUNDS.difficultyAverage.max); + const displayDiff = sanitizeNumber(item.display_difficulty, CLIMB_STATS_BOUNDS.displayDifficulty.min, CLIMB_STATS_BOUNDS.displayDifficulty.max) ?? diffAvg; + const benchDiff = sanitizeNumber(item.benchmark_difficulty, CLIMB_STATS_BOUNDS.benchmarkDifficulty.min, CLIMB_STATS_BOUNDS.benchmarkDifficulty.max); + const ascCount = sanitizeNumber(item.ascensionist_count, CLIMB_STATS_BOUNDS.ascensionistCount.min, CLIMB_STATS_BOUNDS.ascensionistCount.max); + const qualAvg = sanitizeNumber(item.quality_average, CLIMB_STATS_BOUNDS.qualityAverage.min, CLIMB_STATS_BOUNDS.qualityAverage.max); + const faUsername = truncateOrNull(item.fa_username, STRING_LIMITS.username); + + // Skip entirely if all numeric values are invalid + const hasAnyValidNumeric = displayDiff != null || diffAvg != null || benchDiff != null || ascCount != null || qualAvg != null; + if (!hasAnyValidNumeric) { + console.warn(`[sync-validation] Skipping climb_stats for ${item.climb_uuid} angle ${item.angle}: all numeric values invalid`); + return Promise.resolve(); + } + return Promise.all([ // Update current stats db @@ -69,23 +94,24 @@ async function upsertClimbStats(db: NeonDatabase>, board: boardType: board, climbUuid: item.climb_uuid, angle: Number(item.angle), - displayDifficulty: Number(item.display_difficulty || item.difficulty_average), - benchmarkDifficulty: Number(item.benchmark_difficulty), - ascensionistCount: Number(item.ascensionist_count), - difficultyAverage: Number(item.difficulty_average), - qualityAverage: Number(item.quality_average), - faUsername: item.fa_username, + displayDifficulty: displayDiff, + benchmarkDifficulty: benchDiff, + ascensionistCount: ascCount, + difficultyAverage: diffAvg, + qualityAverage: qualAvg, + faUsername, faAt: item.fa_at, }) .onConflictDoUpdate({ target: [climbStatsSchema.boardType, climbStatsSchema.climbUuid, climbStatsSchema.angle], set: { - displayDifficulty: Number(item.display_difficulty || item.difficulty_average), - benchmarkDifficulty: Number(item.benchmark_difficulty), - ascensionistCount: Number(item.ascensionist_count), - difficultyAverage: Number(item.difficulty_average), - qualityAverage: Number(item.quality_average), - faUsername: item.fa_username, + // Use CASE to preserve existing DB value when incoming is null (invalid) + displayDifficulty: displayDiff != null ? displayDiff : sql`${climbStatsSchema.displayDifficulty}`, + benchmarkDifficulty: benchDiff != null ? benchDiff : sql`${climbStatsSchema.benchmarkDifficulty}`, + ascensionistCount: ascCount != null ? ascCount : sql`${climbStatsSchema.ascensionistCount}`, + difficultyAverage: diffAvg != null ? diffAvg : sql`${climbStatsSchema.difficultyAverage}`, + qualityAverage: qualAvg != null ? qualAvg : sql`${climbStatsSchema.qualityAverage}`, + faUsername, faAt: item.fa_at, }, }), @@ -95,12 +121,12 @@ async function upsertClimbStats(db: NeonDatabase>, board: boardType: board, climbUuid: item.climb_uuid, angle: Number(item.angle), - displayDifficulty: Number(item.display_difficulty || item.difficulty_average), - benchmarkDifficulty: Number(item.benchmark_difficulty), - ascensionistCount: Number(item.ascensionist_count), - difficultyAverage: Number(item.difficulty_average), - qualityAverage: Number(item.quality_average), - faUsername: item.fa_username, + displayDifficulty: displayDiff, + benchmarkDifficulty: benchDiff, + ascensionistCount: ascCount, + difficultyAverage: diffAvg, + qualityAverage: qualAvg, + faUsername, faAt: item.fa_at, }), ]); @@ -111,26 +137,38 @@ async function upsertClimbStats(db: NeonDatabase>, board: async function upsertBetaLinks(db: NeonDatabase>, board: AuroraBoardName, data: BetaLink[]) { const betaLinksSchema = UNIFIED_TABLES.betaLinks; + // Filter out records with invalid link URLs + const validData = data.filter((item) => { + if (!isValidHttpUrl(item.link)) { + console.warn(`[sync-validation] Skipping beta_link with invalid URL: ${String(item.link).slice(0, 100)}`); + return false; + } + return true; + }); + await Promise.all( - data.map((item) => { + validData.map((item) => { + // Null out invalid thumbnail URLs but keep the record + const thumbnail = isValidHttpUrl(item.thumbnail) ? truncateOrNull(item.thumbnail, STRING_LIMITS.url) : null; + return db .insert(betaLinksSchema) .values({ boardType: board, climbUuid: item.climb_uuid, - link: item.link, - foreignUsername: item.foreign_username, + link: truncate(item.link, STRING_LIMITS.url), + foreignUsername: truncateOrNull(item.foreign_username, STRING_LIMITS.username), angle: item.angle, - thumbnail: item.thumbnail, + thumbnail, isListed: item.is_listed, createdAt: item.created_at, }) .onConflictDoUpdate({ target: [betaLinksSchema.boardType, betaLinksSchema.climbUuid, betaLinksSchema.link], set: { - foreignUsername: item.foreign_username, + foreignUsername: truncateOrNull(item.foreign_username, STRING_LIMITS.username), angle: item.angle, - thumbnail: item.thumbnail, + thumbnail, isListed: item.is_listed, createdAt: item.created_at, }, @@ -145,14 +183,19 @@ async function upsertClimbs(db: NeonDatabase>, board: Auro await Promise.all( data.map(async (item: Climb) => { + const name = truncate(item.name, STRING_LIMITS.name); + const description = truncate(item.description, STRING_LIMITS.description); + const frames = truncate(item.frames, STRING_LIMITS.frames); + const setterUsername = truncate(item.setter_username, STRING_LIMITS.username); + // Insert or update the climb await db .insert(climbsSchema) .values({ uuid: item.uuid, boardType: board, - name: item.name, - description: item.description, + name, + description, hsm: item.hsm, edgeLeft: item.edge_left, edgeRight: item.edge_right, @@ -160,9 +203,9 @@ async function upsertClimbs(db: NeonDatabase>, board: Auro edgeTop: item.edge_top, framesCount: item.frames_count, framesPace: item.frames_pace, - frames: item.frames, + frames, setterId: item.setter_id, - setterUsername: item.setter_username, + setterUsername, layoutId: item.layout_id, isDraft: item.is_draft, isListed: item.is_listed, @@ -172,14 +215,15 @@ async function upsertClimbs(db: NeonDatabase>, board: Auro .onConflictDoUpdate({ target: [climbsSchema.uuid], set: { - // Only allow isDraft to change from false to true (publishing) - isDraft: sql`CASE WHEN ${climbsSchema.isDraft} = false AND ${item.is_draft} = true THEN true ELSE ${climbsSchema.isDraft} END`, - // Only allow isListed to change from false to true (making public) - isListed: sql`CASE WHEN ${climbsSchema.isListed} = false AND ${item.is_listed} = true THEN true ELSE ${climbsSchema.isListed} END`, - // Allow updates to descriptive fields - name: item.name, - description: item.description, - // Preserve all core climb data - never allow hostile updates to these critical fields + // Only allow isDraft to transition from true to false (publishing). + // Never allow unpublishing (false -> true). + isDraft: sql`CASE WHEN ${climbsSchema.isDraft} = true AND ${item.is_draft} = false THEN false ELSE ${climbsSchema.isDraft} END`, + // Only update isListed while still a draft + isListed: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${item.is_listed} ELSE ${climbsSchema.isListed} END`, + // Only update descriptive fields while still a draft + name: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${name} ELSE ${climbsSchema.name} END`, + description: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${description} ELSE ${climbsSchema.description} END`, + // Preserve all core climb data - never allow updates to these critical fields hsm: climbsSchema.hsm, edgeLeft: climbsSchema.edgeLeft, edgeRight: climbsSchema.edgeRight, @@ -248,17 +292,30 @@ async function updateSharedSyncs( const sharedSyncsSchema = UNIFIED_TABLES.sharedSyncs; for (const sync of sharedSyncs) { + // Validate table_name against known list + if (!VALID_SHARED_SYNC_TABLES.has(sync.table_name)) { + console.warn(`[sync-validation] Skipping unknown shared_sync table_name: ${String(sync.table_name).slice(0, 100)}`); + continue; + } + + // Validate timestamp + const validatedTimestamp = validateSyncTimestamp(sync.last_synchronized_at); + if (!validatedTimestamp) { + console.warn(`[sync-validation] Skipping shared_sync for ${sync.table_name}: invalid timestamp`); + continue; + } + await tx .insert(sharedSyncsSchema) .values({ boardType: boardName, tableName: sync.table_name, - lastSynchronizedAt: sync.last_synchronized_at, + lastSynchronizedAt: validatedTimestamp, }) .onConflictDoUpdate({ target: [sharedSyncsSchema.boardType, sharedSyncsSchema.tableName], set: { - lastSynchronizedAt: sync.last_synchronized_at, + lastSynchronizedAt: validatedTimestamp, }, }); } @@ -332,7 +389,7 @@ export async function syncSharedData( // Process each table - data is directly under table names for (const tableName of SHARED_SYNC_TABLES) { if (syncResults[tableName] && Array.isArray(syncResults[tableName])) { - const data = syncResults[tableName]; + const data = capRecords(syncResults[tableName], tableName); // Only process tables we actually care about if (TABLES_TO_PROCESS.has(tableName)) { diff --git a/packages/web/app/lib/data-sync/aurora/sync-validation.ts b/packages/web/app/lib/data-sync/aurora/sync-validation.ts new file mode 100644 index 000000000..d30838290 --- /dev/null +++ b/packages/web/app/lib/data-sync/aurora/sync-validation.ts @@ -0,0 +1,161 @@ +/** + * Validation utilities for Aurora sync data. + * + * Aurora API responses are untrusted input - all values must be validated + * before writing to our database. + */ + +// --- Bounds constants --- + +export const CLIMB_STATS_BOUNDS = { + displayDifficulty: { min: 0, max: 50 }, + benchmarkDifficulty: { min: 0, max: 50 }, + ascensionistCount: { min: 0, max: 10_000_000 }, + difficultyAverage: { min: 0, max: 50 }, + qualityAverage: { min: 0, max: 5 }, +} as const; + +export const STRING_LIMITS = { + name: 500, + description: 10_000, + comment: 5_000, + username: 255, + frames: 500_000, + url: 2_048, + color: 20, + serialNumber: 255, + tableName: 100, +} as const; + +export const MAX_RECORDS_PER_TABLE = 10_000; + +// Earliest reasonable timestamp for Aurora data (Aurora Climbing founded ~2016) +const MIN_SYNC_YEAR = 2016; + +// --- Validation functions --- + +/** + * Validates and clamps a numeric value within bounds. + * Returns fallback (default null) for NaN, Infinity, or out-of-range values. + */ +export function sanitizeNumber( + value: unknown, + min: number, + max: number, + fallback: number | null = null, +): number | null { + if (value == null) return fallback; + const num = Number(value); + if (!Number.isFinite(num)) return fallback; + if (num < min || num > max) return fallback; + return num; +} + +/** + * Validates a sync timestamp string. + * - Rejects unparseable timestamps + * - Rejects timestamps before MIN_SYNC_YEAR + * - Clamps future timestamps to current time + 24h + * Returns the validated timestamp string, or null if invalid. + */ +export function validateSyncTimestamp(timestamp: string): string | null { + if (!timestamp || typeof timestamp !== 'string') return null; + + const parsed = Date.parse(timestamp); + if (isNaN(parsed)) { + console.warn(`[sync-validation] Rejecting unparseable timestamp: ${timestamp}`); + return null; + } + + const date = new Date(parsed); + const minDate = new Date(MIN_SYNC_YEAR, 0, 1); + const maxDate = new Date(Date.now() + 24 * 60 * 60 * 1000); // now + 24h + + if (date < minDate) { + console.warn(`[sync-validation] Rejecting pre-${MIN_SYNC_YEAR} timestamp: ${timestamp}`); + return null; + } + + if (date > maxDate) { + console.warn(`[sync-validation] Clamping future timestamp: ${timestamp} -> now`); + return new Date().toISOString().replace('T', ' ').replace('Z', ''); + } + + return timestamp; +} + +/** + * Validates that a string is a valid http/https URL within length limits. + */ +export function isValidHttpUrl(urlStr: unknown, maxLength: number = STRING_LIMITS.url): boolean { + if (!urlStr || typeof urlStr !== 'string') return false; + if (urlStr.length > maxLength) return false; + + try { + const url = new URL(urlStr); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +/** + * Truncates a string to maxLength characters. Returns the original string if already within limits. + */ +export function truncate(value: string | null | undefined, maxLength: number): string { + if (!value) return ''; + if (value.length <= maxLength) return value; + console.warn(`[sync-validation] Truncating string from ${value.length} to ${maxLength} chars`); + return value.slice(0, maxLength); +} + +/** + * Truncates a string, returning null if the input is null/undefined. + */ +export function truncateOrNull(value: string | null | undefined, maxLength: number): string | null { + if (value == null) return null; + if (value.length <= maxLength) return value; + console.warn(`[sync-validation] Truncating string from ${value.length} to ${maxLength} chars`); + return value.slice(0, maxLength); +} + +/** + * Caps an array to MAX_RECORDS_PER_TABLE, logging a warning if truncated. + */ +export function capRecords(records: T[], tableName: string, max: number = MAX_RECORDS_PER_TABLE): T[] { + if (records.length <= max) return records; + console.warn( + `[sync-validation] Capping ${tableName} from ${records.length} to ${max} records`, + ); + return records.slice(0, max); +} + +// Known valid table names for sync tracking +export const VALID_SHARED_SYNC_TABLES = new Set([ + 'products', + 'product_sizes', + 'holes', + 'leds', + 'products_angles', + 'layouts', + 'product_sizes_layouts_sets', + 'placements', + 'sets', + 'placement_roles', + 'climbs', + 'climb_stats', + 'beta_links', + 'attempts', + 'kits', +]); + +export const VALID_USER_SYNC_TABLES = new Set([ + 'users', + 'walls', + 'wall_expungements', + 'draft_climbs', + 'ascents', + 'bids', + 'tags', + 'circuits', +]); diff --git a/packages/web/app/lib/data-sync/aurora/user-sync.ts b/packages/web/app/lib/data-sync/aurora/user-sync.ts index 44eefef58..74995eea6 100644 --- a/packages/web/app/lib/data-sync/aurora/user-sync.ts +++ b/packages/web/app/lib/data-sync/aurora/user-sync.ts @@ -1,13 +1,21 @@ import { getPool } from '@/app/lib/db/db'; import { userSync } from '../../api-wrappers/aurora/userSync'; import { SyncOptions, USER_TABLES, UserSyncData, AuroraBoardName } from '../../api-wrappers/aurora/types'; -import { eq, and, inArray } from 'drizzle-orm'; +import { eq, and, inArray, sql } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/neon-serverless'; import { NeonDatabase } from 'drizzle-orm/neon-serverless'; import { UNIFIED_TABLES } from '../../db/queries/util/table-select'; import { boardseshTicks, auroraCredentials, playlists, playlistClimbs, playlistOwnership } from '../../db/schema'; import { randomUUID } from 'crypto'; import { convertQuality } from './convert-quality'; +import { + validateSyncTimestamp, + truncate, + truncateOrNull, + capRecords, + STRING_LIMITS, + VALID_USER_SYNC_TABLES, +} from './sync-validation'; /** * Get NextAuth user ID from Aurora user ID @@ -41,18 +49,19 @@ async function upsertTableData( case 'users': { const usersSchema = UNIFIED_TABLES.users; for (const item of data) { + const username = truncate(item.username, STRING_LIMITS.username); await db .insert(usersSchema) .values({ boardType: boardName, id: Number(item.id), - username: item.username, + username, createdAt: item.created_at, }) .onConflictDoUpdate({ target: [usersSchema.boardType, usersSchema.id], set: { - username: item.username, + username, }, }); } @@ -62,32 +71,34 @@ async function upsertTableData( case 'walls': { const wallsSchema = UNIFIED_TABLES.walls; for (const item of data) { + const name = truncate(item.name, STRING_LIMITS.name); + const serialNumber = truncateOrNull(item.serial_number, STRING_LIMITS.serialNumber); await db .insert(wallsSchema) .values({ boardType: boardName, uuid: item.uuid, userId: Number(auroraUserId), - name: item.name, + name, productId: Number(item.product_id), isAdjustable: Boolean(item.is_adjustable), angle: Number(item.angle), layoutId: Number(item.layout_id), productSizeId: Number(item.product_size_id), hsm: Number(item.hsm), - serialNumber: item.serial_number, + serialNumber, createdAt: item.created_at, }) .onConflictDoUpdate({ target: [wallsSchema.boardType, wallsSchema.uuid], set: { - name: item.name, + name, isAdjustable: Boolean(item.is_adjustable), angle: Number(item.angle), layoutId: Number(item.layout_id), productSizeId: Number(item.product_size_id), hsm: Number(item.hsm), - serialNumber: item.serial_number, + serialNumber, }, }); } @@ -97,6 +108,11 @@ async function upsertTableData( case 'draft_climbs': { const climbsSchema = UNIFIED_TABLES.climbs; for (const item of data) { + const name = truncate(item.name || 'Untitled Draft', STRING_LIMITS.name); + const description = truncate(item.description || '', STRING_LIMITS.description); + const setterUsername = truncate(item.setter_username || '', STRING_LIMITS.username); + const frames = truncate(item.frames || '', STRING_LIMITS.frames); + await db .insert(climbsSchema) .values({ @@ -104,9 +120,9 @@ async function upsertTableData( boardType: boardName, layoutId: Number(item.layout_id), setterId: Number(auroraUserId), - setterUsername: item.setter_username || '', - name: item.name || 'Untitled Draft', - description: item.description || '', + setterUsername, + name, + description, hsm: Number(item.hsm), edgeLeft: Number(item.edge_left), edgeRight: Number(item.edge_right), @@ -115,7 +131,7 @@ async function upsertTableData( angle: Number(item.angle), framesCount: Number(item.frames_count || 1), framesPace: Number(item.frames_pace || 0), - frames: item.frames || '', + frames, isDraft: true, isListed: false, createdAt: item.created_at || new Date().toISOString(), @@ -123,22 +139,26 @@ async function upsertTableData( .onConflictDoUpdate({ target: climbsSchema.uuid, set: { - layoutId: Number(item.layout_id), - setterId: Number(auroraUserId), - setterUsername: item.setter_username || '', - name: item.name || 'Untitled Draft', - description: item.description || '', - hsm: Number(item.hsm), - edgeLeft: Number(item.edge_left), - edgeRight: Number(item.edge_right), - edgeBottom: Number(item.edge_bottom), - edgeTop: Number(item.edge_top), - angle: Number(item.angle), - framesCount: Number(item.frames_count || 1), - framesPace: Number(item.frames_pace || 0), - frames: item.frames || '', - isDraft: true, - isListed: false, + // Only update fields if existing climb is still a draft. + // Published climbs (isDraft=false) are fully immutable via sync. + layoutId: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${Number(item.layout_id)} ELSE ${climbsSchema.layoutId} END`, + setterId: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${Number(auroraUserId)} ELSE ${climbsSchema.setterId} END`, + setterUsername: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${setterUsername} ELSE ${climbsSchema.setterUsername} END`, + name: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${name} ELSE ${climbsSchema.name} END`, + description: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${description} ELSE ${climbsSchema.description} END`, + hsm: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${Number(item.hsm)} ELSE ${climbsSchema.hsm} END`, + edgeLeft: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${Number(item.edge_left)} ELSE ${climbsSchema.edgeLeft} END`, + edgeRight: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${Number(item.edge_right)} ELSE ${climbsSchema.edgeRight} END`, + edgeBottom: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${Number(item.edge_bottom)} ELSE ${climbsSchema.edgeBottom} END`, + edgeTop: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${Number(item.edge_top)} ELSE ${climbsSchema.edgeTop} END`, + angle: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${Number(item.angle)} ELSE ${climbsSchema.angle} END`, + framesCount: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${Number(item.frames_count || 1)} ELSE ${climbsSchema.framesCount} END`, + framesPace: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${Number(item.frames_pace || 0)} ELSE ${climbsSchema.framesPace} END`, + frames: sql`CASE WHEN ${climbsSchema.isDraft} = true THEN ${frames} ELSE ${climbsSchema.frames} END`, + // Never unpublish: isDraft stays as-is (draft_climbs always sends isDraft=true, + // but we must not flip a published climb back to draft) + isDraft: climbsSchema.isDraft, + isListed: climbsSchema.isListed, }, }); } @@ -150,6 +170,7 @@ async function upsertTableData( for (const item of data) { const status = Number(item.attempt_id) === 1 ? 'flash' : 'send'; const convertedQuality = convertQuality(item.quality); + const comment = truncate(item.comment || '', STRING_LIMITS.comment); await db .insert(boardseshTicks) @@ -165,7 +186,7 @@ async function upsertTableData( quality: convertedQuality, difficulty: item.difficulty ? Number(item.difficulty) : null, isBenchmark: Boolean(item.is_benchmark || 0), - comment: item.comment || '', + comment, climbedAt: new Date(item.climbed_at).toISOString(), createdAt: item.created_at ? new Date(item.created_at).toISOString() : new Date().toISOString(), updatedAt: new Date().toISOString(), @@ -184,7 +205,7 @@ async function upsertTableData( quality: convertedQuality, difficulty: item.difficulty ? Number(item.difficulty) : null, isBenchmark: Boolean(item.is_benchmark || 0), - comment: item.comment || '', + comment, climbedAt: new Date(item.climbed_at).toISOString(), updatedAt: new Date().toISOString(), auroraSyncedAt: new Date().toISOString(), @@ -197,6 +218,8 @@ async function upsertTableData( case 'bids': { // Write directly to boardsesh_ticks (requires NextAuth user ID) for (const item of data) { + const comment = truncate(item.comment || '', STRING_LIMITS.comment); + await db .insert(boardseshTicks) .values({ @@ -211,7 +234,7 @@ async function upsertTableData( quality: null, difficulty: null, isBenchmark: false, - comment: item.comment || '', + comment, climbedAt: new Date(item.climbed_at).toISOString(), createdAt: new Date(item.created_at).toISOString(), updatedAt: new Date().toISOString(), @@ -226,7 +249,7 @@ async function upsertTableData( angle: Number(item.angle), isMirror: Boolean(item.is_mirror), attemptCount: Number(item.bid_count || 1), - comment: item.comment || '', + comment, climbedAt: new Date(item.climbed_at).toISOString(), updatedAt: new Date().toISOString(), auroraSyncedAt: new Date().toISOString(), @@ -239,6 +262,7 @@ async function upsertTableData( case 'tags': { const tagsSchema = UNIFIED_TABLES.tags; for (const item of data) { + const tagName = truncate(item.name, STRING_LIMITS.name); // First try to update existing record const result = await db .update(tagsSchema) @@ -250,7 +274,7 @@ async function upsertTableData( eq(tagsSchema.boardType, boardName), eq(tagsSchema.entityUuid, item.entity_uuid), eq(tagsSchema.userId, Number(auroraUserId)), - eq(tagsSchema.name, item.name), + eq(tagsSchema.name, tagName), ), ) .returning(); @@ -261,7 +285,7 @@ async function upsertTableData( boardType: boardName, entityUuid: item.entity_uuid, userId: Number(auroraUserId), - name: item.name, + name: tagName, isListed: Boolean(item.is_listed), }); } @@ -272,15 +296,19 @@ async function upsertTableData( case 'circuits': { const circuitsSchema = UNIFIED_TABLES.circuits; for (const item of data) { + const circuitName = truncate(item.name, STRING_LIMITS.name); + const circuitDescription = truncateOrNull(item.description, STRING_LIMITS.description); + const circuitColor = truncateOrNull(item.color, STRING_LIMITS.color); + // 1. Write to unified circuits table await db .insert(circuitsSchema) .values({ boardType: boardName, uuid: item.uuid, - name: item.name, - description: item.description, - color: item.color, + name: circuitName, + description: circuitDescription, + color: circuitColor, userId: Number(auroraUserId), isPublic: Boolean(item.is_public), createdAt: item.created_at, @@ -289,9 +317,9 @@ async function upsertTableData( .onConflictDoUpdate({ target: [circuitsSchema.boardType, circuitsSchema.uuid], set: { - name: item.name, - description: item.description, - color: item.color, + name: circuitName, + description: circuitDescription, + color: circuitColor, isPublic: Boolean(item.is_public), updatedAt: item.updated_at, }, @@ -300,7 +328,7 @@ async function upsertTableData( // 2. Dual write to playlists table (only if NextAuth user exists) if (nextAuthUserId) { // Format color - Aurora uses hex without #, we store with # - const formattedColor = item.color ? `#${item.color}` : null; + const formattedColor = circuitColor ? `#${circuitColor}` : null; // Insert/update playlist const [playlist] = await db @@ -309,8 +337,8 @@ async function upsertTableData( uuid: item.uuid, // Use same UUID as Aurora circuit boardType: boardName, layoutId: null, // Nullable for Aurora-synced circuits - name: item.name || 'Untitled Circuit', - description: item.description || null, + name: circuitName || 'Untitled Circuit', + description: circuitDescription || null, isPublic: Boolean(item.is_public), color: formattedColor, auroraType: 'circuits', @@ -322,8 +350,8 @@ async function upsertTableData( .onConflictDoUpdate({ target: playlists.auroraId, set: { - name: item.name || 'Untitled Circuit', - description: item.description || null, + name: circuitName || 'Untitled Circuit', + description: circuitDescription || null, isPublic: Boolean(item.is_public), color: formattedColor, updatedAt: item.updated_at ? new Date(item.updated_at) : new Date(), @@ -384,18 +412,31 @@ async function updateUserSyncs( const userSyncsSchema = UNIFIED_TABLES.userSyncs; for (const sync of userSyncs) { + // Validate table_name against known list + if (!VALID_USER_SYNC_TABLES.has(sync.table_name)) { + console.warn(`[sync-validation] Skipping unknown user_sync table_name: ${String(sync.table_name).slice(0, 100)}`); + continue; + } + + // Validate timestamp + const validatedTimestamp = validateSyncTimestamp(sync.last_synchronized_at); + if (!validatedTimestamp) { + console.warn(`[sync-validation] Skipping user_sync for ${sync.table_name}: invalid timestamp`); + continue; + } + await tx .insert(userSyncsSchema) .values({ boardType: boardName, userId: Number(sync.user_id), tableName: sync.table_name, - lastSynchronizedAt: sync.last_synchronized_at, + lastSynchronizedAt: validatedTimestamp, }) .onConflictDoUpdate({ target: [userSyncsSchema.boardType, userSyncsSchema.userId, userSyncsSchema.tableName], set: { - lastSynchronizedAt: sync.last_synchronized_at, + lastSynchronizedAt: validatedTimestamp, }, }); } @@ -507,7 +548,7 @@ export async function syncUserData( for (const tableName of tables) { console.log(`Syncing ${tableName} for user ${userId} (batch ${syncAttempts})`); if (syncResults[tableName] && Array.isArray(syncResults[tableName])) { - const data = syncResults[tableName]; + const data = capRecords(syncResults[tableName], tableName); // Skip ascents/bids if no NextAuth user (can't dual write) if ((tableName === 'ascents' || tableName === 'bids') && !nextAuthUserId) {