From 721abce5a5cc8eb5f451be3447b4dcf1d949684b Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Mon, 16 Mar 2026 09:59:20 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20review-driven=20hardening=20=E2=80=94=20?= =?UTF-8?q?env=20guards,=20token=20expiry,=20slug=20validation,=20dashboar?= =?UTF-8?q?d=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From CEO plan review: - Edge functions: early guard on missing env vars instead of non-null assert crash - cli-team: wire isTokenExpired check (was imported but unused) - Migration 007: CHECK constraint on team slug (a-z0-9 hyphens, 2-50 chars) - Dashboard: streak badges on leaderboard, repo slug in who's-online, contextual empty states that teach, 60s refresh (was 30s) Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/cli-team.ts | 5 ++ supabase/functions/dashboard/ui.ts | 55 ++++++++++++++----- supabase/functions/regression-alert/index.ts | 8 ++- supabase/functions/weekly-digest/index.ts | 8 ++- .../007_team_settings_and_functions.sql | 9 +++ 5 files changed, 67 insertions(+), 18 deletions(-) diff --git a/lib/cli-team.ts b/lib/cli-team.ts index 449bc3ae..5692d2b1 100644 --- a/lib/cli-team.ts +++ b/lib/cli-team.ts @@ -35,6 +35,11 @@ async function getValidToken(): Promise<{ token: string; config: ReturnType
Active Now
-
Cost This Week
-
-

Recent Eval Runs

DateBranchPass RateCost
-

Recent Ships

DateVersionBranchPR
+

Recent Eval Runs

DateBranchPass RateCost
+

Recent Ships

DateVersionBranchPR

Pass Rate Trend

-

Recent Eval Runs

DateUserBranchPass RateCostTier
+

Recent Eval Runs

DateUserBranchPass RateCostTier
-

Recent Ships

DateVersionBranchPR
+

Recent Ships

DateVersionBranchPR

Ships Per Person This Week

@@ -183,12 +183,12 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string {
-

This Week

#WhoShipsEvalsSessionsPass RateCost
+

This Week

#WhoShipsEvalsSessionsPass RateCost
-

Recent QA Reports

DateRepoHealth Score
+

Recent QA Reports

DateRepoHealth Score
@@ -394,12 +394,12 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string { } // ================================================================ - // Auto-refresh (30s, pauses when tab hidden) + // Auto-refresh (60s, pauses when tab hidden) // ================================================================ function startAutoRefresh() { stopAutoRefresh(); - refreshTimer = setInterval(() => { fetchAll(); }, 30000); + refreshTimer = setInterval(() => { fetchAll(); }, 60000); } function stopAutoRefresh() { @@ -527,7 +527,7 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string { function renderSparkline(containerId, values) { const el = document.getElementById(containerId); if (!el) return; - if (!values || values.length === 0) { el.innerHTML = 'No data yet'; return; } + if (!values || values.length === 0) { el.innerHTML = 'No data points yet. Run evals to see pass rate trends.'; return; } const W = 600, H = 120, PAD = 30; const max = Math.max(...values, 1); @@ -559,7 +559,7 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string { function renderHBarChart(containerId, items) { const el = document.getElementById(containerId); if (!el) return; - if (!items || items.length === 0) { el.innerHTML = 'No data yet'; return; } + if (!items || items.length === 0) { el.innerHTML = 'No activity to chart yet. Ship PRs to see the breakdown.'; return; } const W = 600, barH = 28, gap = 6; const H = items.length * (barH + gap) + 20; @@ -583,7 +583,7 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string { function renderVBarChart(containerId, items) { const el = document.getElementById(containerId); if (!el) return; - if (!items || items.length === 0) { el.innerHTML = 'No data yet'; return; } + if (!items || items.length === 0) { el.innerHTML = 'No cost data yet. Eval costs appear here after runs are pushed.'; return; } const W = 600, H = 180, PAD_BOTTOM = 40, PAD_TOP = 20, PAD_LEFT = 50; const maxVal = Math.max(...items.map(function(d) { return d.value; }), 0.01); @@ -806,16 +806,40 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string { board[uid].sessions++; }); } - // Online status from heartbeats + // Online status from heartbeats (also capture repo_slug) if (data.heartbeats) { data.heartbeats.forEach(function(h) { const uid = h.user_id || h.hostname; if (uid && board[uid] && withinMinutes(h.timestamp, 15)) { board[uid].online = true; + if (h.repo_slug) board[uid].repo = h.repo_slug; } }); } + // Compute streak badges from ship_logs (consecutive ship days this week) + const streaks = {}; + if (data.shipLogs) { + const byUser = {}; + data.shipLogs.filter(function(r) { return new Date(r.created_at) >= ws; }).forEach(function(r) { + const uid = r.user_id || 'unknown'; + if (!byUser[uid]) byUser[uid] = new Set(); + byUser[uid].add(new Date(r.created_at).toISOString().slice(0, 10)); + }); + Object.keys(byUser).forEach(function(uid) { + const dates = Array.from(byUser[uid]).sort(); + let maxRun = 1, run = 1; + for (let i = 1; i < dates.length; i++) { + const prev = new Date(dates[i - 1]); + const curr = new Date(dates[i]); + const diffDays = Math.round((curr - prev) / (1000 * 60 * 60 * 24)); + if (diffDays === 1) { run++; if (run > maxRun) maxRun = run; } + else { run = 1; } + } + streaks[uid] = maxRun; + }); + } + // Sort by ships desc, then evals desc const sorted = Object.keys(board).map(function(uid) { return Object.assign({ uid: uid }, board[uid]); @@ -824,9 +848,12 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string { const tbody = clearTbody('tbl-leaderboard'); sorted.forEach(function(entry, i) { const rate = entry.total_tests ? ((entry.passed / entry.total_tests) * 100).toFixed(0) + '%' : '-'; + const streak = streaks[entry.uid] || 0; + const streakBadge = streak >= 5 ? '\u{1F525}\u{1F525} ' : (streak >= 3 ? '\u{1F525} ' : ''); + const displayName = entry.uid.slice(0, 8) + (entry.repo ? ' — ' + escapeHTML(entry.repo) : ''); const nameCell = entry.online - ? { html: '' + escapeHTML(entry.uid.slice(0, 8)) } - : entry.uid.slice(0, 8); + ? { html: '' + streakBadge + displayName } + : { html: streakBadge + displayName }; tbody.appendChild(makeRow([ i + 1, nameCell, diff --git a/supabase/functions/regression-alert/index.ts b/supabase/functions/regression-alert/index.ts index 25abcea4..a418f7a9 100644 --- a/supabase/functions/regression-alert/index.ts +++ b/supabase/functions/regression-alert/index.ts @@ -71,8 +71,12 @@ Deno.serve(async (req: Request) => { return new Response('OK (skipped: total_tests=0)', { status: 200 }); } - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + const supabaseUrl = Deno.env.get('SUPABASE_URL'); + const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'); + if (!supabaseUrl || !serviceKey) { + console.error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY env vars'); + return new Response('OK (missing env vars)', { status: 200 }); + } const supabase = createClient(supabaseUrl, serviceKey); // Check cooldown (5-min dedup) diff --git a/supabase/functions/weekly-digest/index.ts b/supabase/functions/weekly-digest/index.ts index a2838bc1..29702bf1 100644 --- a/supabase/functions/weekly-digest/index.ts +++ b/supabase/functions/weekly-digest/index.ts @@ -79,8 +79,12 @@ export function formatDigestMessage(data: DigestData): string { Deno.serve(async (_req: Request) => { try { - const supabaseUrl = Deno.env.get('SUPABASE_URL')!; - const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + const supabaseUrl = Deno.env.get('SUPABASE_URL'); + const serviceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY'); + if (!supabaseUrl || !serviceKey) { + console.error('Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY env vars'); + return new Response('OK (missing env vars)', { status: 200 }); + } const supabase = createClient(supabaseUrl, serviceKey); const weekAgo = new Date(Date.now() - 7 * 86_400_000).toISOString(); diff --git a/supabase/migrations/007_team_settings_and_functions.sql b/supabase/migrations/007_team_settings_and_functions.sql index 9b5820dc..a2184ae6 100644 --- a/supabase/migrations/007_team_settings_and_functions.sql +++ b/supabase/migrations/007_team_settings_and_functions.sql @@ -35,6 +35,12 @@ create policy "admin_write_settings" on team_settings ) ); +-- Add CHECK constraint on teams.slug if not already present +do $$ begin + alter table teams add constraint chk_team_slug check (slug ~ '^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$'); +exception when duplicate_object then null; +end $$; + -- ─── alert_cooldowns ──────────────────────────────────────── create table if not exists alert_cooldowns ( @@ -76,6 +82,9 @@ begin if team_name is null or length(trim(team_name)) = 0 then raise exception 'team_name cannot be empty'; end if; + if team_slug !~ '^[a-z0-9][a-z0-9-]{0,48}[a-z0-9]$' then + raise exception 'team_slug must be 2-50 chars, lowercase alphanumeric and hyphens only, must start and end with alphanumeric'; + end if; if auth.uid() is null then raise exception 'must be authenticated'; end if;