1// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2// Selebox â Web event logger (Phase 3.3 of the moderation system) 3// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4// Thin client wrapper around the server-side emit RPCs: 5// register_session_device â on every page load 6// record_read_event â on chapter open / close 7// record_view_event â on video play / threshold-crossed / end 8// 9// Design goals: 10// ⢠FIRE-AND-FORGET â every call returns a Promise that resolves to 11// `void`. Errors are swallowed and console.warned in __DEV__. The 12// caller never has to `await` or handle errors; failed emits are 13// just lost telemetry, never user-visible. 14// ⢠LAZY device_id â generated once per browser via crypto.randomUUID 15// and stored in localStorage. No prompt, no opt-in screen; this is 16// internal anti-fraud telemetry. 17// ⢠LIGHTWEIGHT â no batching, no buffer. Each event is one RPC call. 18// Total volume per session is < 20 events; one RPC each is fine. 19// ⢠AUTH-AWARE â RPCs require auth.uid(). When the user is signed 20// out, calls short-circuit before hitting the wire. 21// 22// Callers don't need to know about device_id management â just call 23// `logRead({...})` or `logView({...})` and it threads through. 24// ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 25 26import { supabase } from './supabase.js?otaStep7=2026-10-06-1&v=20261006-step7'; 27 28 29// ââ device_id ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 30// Generated once per browser, stored in localStorage. Survives across 31// sessions; resets when the user clears their browser data (which is 32// the correct privacy boundary â same as "this device wants to be 33// forgotten"). Returns the same UUID on every call. 34 35const DEVICE_ID_KEY = 'selebox_device_id'; 36 37export function getDeviceId() { 38 try { 39 let id = localStorage.getItem(DEVICE_ID_KEY); 40 if (id && /^[0-9a-f-]{30,}$/i.test(id)) return id; 41 id = (typeof crypto !== 'undefined' && crypto.randomUUID) 42 ? crypto.randomUUID() 43 // Fallback for older browsers: timestamp + 16 hex chars. 44 : Date.now().toString(16) + '-' + 45 Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join(''); 46 localStorage.setItem(DEVICE_ID_KEY, id); 47 return id; 48 } catch { 49 // localStorage blocked (private mode, etc.). Telemetry still works 50 // but device_id is per-page-load â detection just loses one 51 // correlation axis. 52 return 'no-storage'; 53 } 54} 55 56 57// ââ UUID validation ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 58// Server-side RPCs declare chapter_id / video_id / book_id as `uuid`. 59// Postgres rejects non-UUID strings with a cast error. Legacy Appwrite 60// content has 16-char hex IDs (not UUIDs); sending those silently fails 61// inside _emit's catch. Filter at the client so legacy content stops 62// triggering pointless RPC calls. Result: legacy content doesn't 63// contribute to fraud-detection signals â same as the existing 64// behavior, just without the noise. 65const _UUID_RE = 66 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; 67const _isUuid = (val) => typeof val === 'string' && _UUID_RE.test(val); 68 69 70// ââ Internal: fire-and-forget RPC wrapper ââââââââââââââââââââââââââââââââ 71// Returns void. Errors caught + optionally logged. 72 73async function _emit(rpc, args) { 74 try { 75 // Skip when signed out â the RPCs return not_authenticated anyway, 76 // saves a round-trip. 77 const { data: { session } } = await supabase.auth.getSession(); 78 if (!session?.user) return; 79 const { error } = await supabase.rpc(rpc, args); 80 if (error && typeof console !== 'undefined') { 81 // Don't spam the console for transient errors; the server's 82 // error code (e.g. invalid_event_kind) is the actionable info. 83 console.debug('[event-log]', rpc, 'error:', error.message); 84 } 85 } catch (err) { 86 // Network blip / fetch throw â swallow. 87 if (typeof console !== 'undefined') { 88 console.debug('[event-log]', rpc, 'threw:', err?.message); 89 } 90 } 91} 92 93 94// ââ Public API âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 95 96/** 97 * Register the current device with the server. Idempotent â the 98 * server upserts on (user_id, device_id), so calling this every 99 * page load just refreshes last_seen + ip_hash. 100 * 101 * Call this on every page load that finishes auth (i.e. after 102 * supabase.auth.getSession() returns a user).
103 */ 104export async function registerSessionDevice() { 105 await _emit('register_session_device', { 106 p_device_id: getDeviceId(), 107 p_user_agent: (navigator?.userAgent || '').slice(0, 500), 108 p_platform: 'web', 109 p_app_version: null, 110 }); 111} 112 113/** 114 * Log a read event (chapter open, scroll milestone, or close). 115 * 116 * @param {Object} args 117 * @param {string} args.chapterId UUID of the chapter (required) 118 * @param {string} [args.bookId] UUID of the parent book (optional, faster filtering) 119 * @param {number} [args.dwellMs] Milliseconds since chapter open 120 * @param {number} [args.scrollPct] 0-100 furthest scroll reached 121 * @param {boolean} [args.completed] True when chapter completed 122 */ 123export async function logRead({ chapterId, bookId, dwellMs, scrollPct, completed } = {}) { 124 // Skip non-UUID IDs â see _isUuid comment above. 125 if (!_isUuid(chapterId)) return; 126 await _emit('record_read_event', { 127 p_chapter_id: chapterId, 128 p_book_id: _isUuid(bookId) ? bookId : null, 129 p_dwell_ms: dwellMs ?? null, 130 p_scroll_pct: scrollPct ?? null, 131 p_completed: completed ?? null, 132 p_device_id: getDeviceId(), 133 }); 134} 135 136/** 137 * Log a video view event (play, pause, seek, threshold, end). 138 * 139 * @param {Object} args 140 * @param {string} args.videoId UUID of the video (required) 141 * @param {string} args.kind One of 'play'|'pause'|'seek'| 142 * 'threshold_approached'|'threshold_crossed'|'end' 143 * @param {number} [args.watchedSeconds] Total seconds watched at event time 144 * @param {number} [args.thresholdSeconds] Only set for threshold_* events 145 */ 146export async function logView({ videoId, kind, watchedSeconds, thresholdSeconds } = {}) { 147 // Same UUID gate as logRead â server expects uuid for video_id. 148 if (!_isUuid(videoId) || !kind) return; 149 await _emit('record_view_event', { 150 p_video_id: videoId, 151 p_event_kind: kind, 152 p_watched_seconds: watchedSeconds ?? null, 153 p_threshold_seconds: thresholdSeconds ?? null, 154 p_device_id: getDeviceId(), 155 }); 156} 157 158/** Local staging diagnostics. Whitelist numbers; never emit content or identities. 159 * Existing read/view RPCs have fixed event kinds, so these are browser events 160 * for the release harness, not invented server event kinds or impressions. 161 */ 162export function logFeedPolicy(metrics={}) { 163 const detail={policyVersion:metrics.rankingVersion===3?'web-ranking-3':'web-variety-1'}; 164 for(const key of ['rankingVersion','uniqueCreators','seenCount','catchupCount','discoveryCount','selectedCount','adjacentViolations','windowViolations','deferredCount','sourceBatches','fallback','errors','latencyMs','invalidIdentity']) { 165 const value=Number(metrics[key]||0); 166 detail[key]=Number.isFinite(value)?Math.max(0,value):0; 167 } 168 if(typeof window!=='undefined')window.dispatchEvent(new CustomEvent('selebox:feed-policy',{detail})); 169 return detail; 170}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.