// server/supabase/functions/audio-purge/policy.ts // Pure rules for which queued storage keys the service-role worker may remove. // storage_key is client-written (RLS only pins its first segment), so the // worker re-checks it before acting with service-role storage access. export const AUDIO_BUCKET = 'audio' export const MAX_STORAGE_KEY_LENGTH = 1024 export interface PurgeQueueEntry { id: number userId: string storageKey: string leaseToken: string } export interface PurgePlan { /** Entries whose object may be removed from the audio bucket. */ removable: PurgeQueueEntry[] /** Entries dropped without touching storage (key outside the owner's prefix). */ rejected: PurgeQueueEntry[] } const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i // deno-lint-ignore no-control-regex const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/ /** True when key is a plain object path inside the owner's own audio prefix. */ export function isPurgeableStorageKey(userId: string, key: string): boolean { if (!UUID_PATTERN.test(userId)) return false if (key.length === 0 || key.length > MAX_STORAGE_KEY_LENGTH) return false if (key.includes('\\') || CONTROL_CHARACTERS.test(key)) return false const segments = key.split('/') if (segments.length < 2 || segments[0] !== userId) return false return segments.every((segment) => segment.length > 0 && segment !== '.' && segment !== '..') } export function planPurge(entries: readonly PurgeQueueEntry[]): PurgePlan { const removable: PurgeQueueEntry[] = [] const rejected: PurgeQueueEntry[] = [] for (const entry of entries) { if (isPurgeableStorageKey(entry.userId, entry.storageKey)) removable.push(entry) else rejected.push(entry) } return { removable, rejected } }