fix: review-driven hardening — env guards, token expiry, slug validation, dashboard UX

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) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-16 09:59:20 -05:00
parent 2357f134ce
commit 721abce5a5
5 changed files with 67 additions and 18 deletions
+5
View File
@@ -35,6 +35,11 @@ async function getValidToken(): Promise<{ token: string; config: ReturnType<type
return null;
}
if (config.auth.expires_at && isTokenExpired(config.auth.expires_at)) {
console.error('Auth token expired. Run: gstack sync setup');
return null;
}
return { token, config };
}
+41 -14
View File
@@ -157,19 +157,19 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string {
<div class="stat-card"><div class="label">Active Now</div><div class="value" id="stat-active-now">-</div></div>
<div class="stat-card"><div class="label">Cost This Week</div><div class="value" id="stat-cost-week">-</div></div>
</div>
<div class="panel"><h3>Recent Eval Runs</h3><table id="tbl-overview-evals"><thead><tr><th>Date</th><th>Branch</th><th>Pass Rate</th><th>Cost</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-overview-evals" style="display:none">No data yet</div></div>
<div class="panel"><h3>Recent Ships</h3><table id="tbl-overview-ships"><thead><tr><th>Date</th><th>Version</th><th>Branch</th><th>PR</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-overview-ships" style="display:none">No data yet</div></div>
<div class="panel"><h3>Recent Eval Runs</h3><table id="tbl-overview-evals"><thead><tr><th>Date</th><th>Branch</th><th>Pass Rate</th><th>Cost</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-overview-evals" style="display:none">No eval runs yet. Push results with: gstack eval push result.json</div></div>
<div class="panel"><h3>Recent Ships</h3><table id="tbl-overview-ships"><thead><tr><th>Date</th><th>Version</th><th>Branch</th><th>PR</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-overview-ships" style="display:none">No ships yet. Ship your first PR with: /ship</div></div>
</div>
<!-- Evals -->
<div class="tab-panel" id="panel-evals">
<div class="panel"><h3>Pass Rate Trend</h3><div class="chart-container" id="chart-sparkline"></div></div>
<div class="panel"><h3>Recent Eval Runs</h3><table id="tbl-evals"><thead><tr><th>Date</th><th>User</th><th>Branch</th><th>Pass Rate</th><th>Cost</th><th>Tier</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-evals" style="display:none">No data yet</div></div>
<div class="panel"><h3>Recent Eval Runs</h3><table id="tbl-evals"><thead><tr><th>Date</th><th>User</th><th>Branch</th><th>Pass Rate</th><th>Cost</th><th>Tier</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-evals" style="display:none">No eval data yet. Run your test suite and push results: gstack eval push result.json</div></div>
</div>
<!-- Ships -->
<div class="tab-panel" id="panel-ships">
<div class="panel"><h3>Recent Ships</h3><table id="tbl-ships"><thead><tr><th>Date</th><th>Version</th><th>Branch</th><th>PR</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-ships" style="display:none">No data yet</div></div>
<div class="panel"><h3>Recent Ships</h3><table id="tbl-ships"><thead><tr><th>Date</th><th>Version</th><th>Branch</th><th>PR</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-ships" style="display:none">No ships yet. Use /ship to create PRs with full review and version bumps.</div></div>
<div class="panel"><h3>Ships Per Person This Week</h3><div class="chart-container" id="chart-ships-per-person"></div></div>
</div>
@@ -183,12 +183,12 @@ export function getDashboardHTML(supabaseUrl: string, anonKey: string): string {
<!-- Leaderboard -->
<div class="tab-panel" id="panel-leaderboard">
<div class="panel"><h3>This Week</h3><table id="tbl-leaderboard"><thead><tr><th>#</th><th>Who</th><th>Ships</th><th>Evals</th><th>Sessions</th><th>Pass Rate</th><th>Cost</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-leaderboard" style="display:none">No data yet</div></div>
<div class="panel"><h3>This Week</h3><table id="tbl-leaderboard"><thead><tr><th>#</th><th>Who</th><th>Ships</th><th>Evals</th><th>Sessions</th><th>Pass Rate</th><th>Cost</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-leaderboard" style="display:none">No activity this week yet. Ship, run evals, or start a Claude session to appear here.</div></div>
</div>
<!-- QA -->
<div class="tab-panel" id="panel-qa">
<div class="panel"><h3>Recent QA Reports</h3><table id="tbl-qa"><thead><tr><th>Date</th><th>Repo</th><th>Health Score</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-qa" style="display:none">No data yet</div></div>
<div class="panel"><h3>Recent QA Reports</h3><table id="tbl-qa"><thead><tr><th>Date</th><th>Repo</th><th>Health Score</th></tr></thead><tbody></tbody></table><div class="panel-empty" id="empty-qa" style="display:none">No QA reports yet. Run /qa on a web app to generate your first health score.</div></div>
</div>
</div>
</div>
@@ -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 = '<span class="panel-empty">No data yet</span>'; return; }
if (!values || values.length === 0) { el.innerHTML = '<span class="panel-empty">No data points yet. Run evals to see pass rate trends.</span>'; 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 = '<span class="panel-empty">No data yet</span>'; return; }
if (!items || items.length === 0) { el.innerHTML = '<span class="panel-empty">No activity to chart yet. Ship PRs to see the breakdown.</span>'; 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 = '<span class="panel-empty">No data yet</span>'; return; }
if (!items || items.length === 0) { el.innerHTML = '<span class="panel-empty">No cost data yet. Eval costs appear here after runs are pushed.</span>'; 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 ? ' &mdash; ' + escapeHTML(entry.repo) : '');
const nameCell = entry.online
? { html: '<span class="online-dot"></span>' + escapeHTML(entry.uid.slice(0, 8)) }
: entry.uid.slice(0, 8);
? { html: '<span class="online-dot"></span>' + streakBadge + displayName }
: { html: streakBadge + displayName };
tbody.appendChild(makeRow([
i + 1,
nameCell,
+6 -2
View File
@@ -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)
+6 -2
View File
@@ -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();
@@ -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;