From 3330b8e68dd6f9298580e0a8b51878f3e88f4ff0 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 24 Mar 2026 14:20:08 -0700 Subject: [PATCH] test: RLS smoke test + telemetry field name verification - verify-rls.sh: 9-check smoke test (5 reads + 3 inserts + 1 update) verifying anon key is fully locked out after migration. - telemetry.test.ts: verifies JSONL uses raw field names (v, ts, sessions) that the edge function expects, not Postgres column names. - README.md: fixes privacy claim to match actual RLS policy. --- README.md | 2 +- supabase/verify-rls.sh | 96 ++++++++++++++++++++++++++++++++++++++++++ test/telemetry.test.ts | 22 ++++++++-- 3 files changed, 116 insertions(+), 4 deletions(-) create mode 100755 supabase/verify-rls.sh diff --git a/README.md b/README.md index 253d5425..fd81d78c 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ gstack includes **opt-in** usage telemetry to help improve the project. Here's e - **What's never sent:** code, file paths, repo names, branch names, prompts, or any user-generated content. - **Change anytime:** `gstack-config set telemetry off` disables everything instantly. -Data is stored in [Supabase](https://supabase.com) (open source Firebase alternative). The schema is in [`supabase/migrations/001_telemetry.sql`](supabase/migrations/001_telemetry.sql) — you can verify exactly what's collected. The Supabase publishable key in the repo is a public key (like a Firebase API key) — row-level security policies restrict it to insert-only access. +Data is stored in [Supabase](https://supabase.com) (open source Firebase alternative). The schema is in [`supabase/migrations/`](supabase/migrations/) — you can verify exactly what's collected. The Supabase publishable key in the repo is a public key (like a Firebase API key) — row-level security policies deny all direct access. Telemetry flows through validated edge functions that enforce schema checks, event type allowlists, and field length limits. **Local analytics are always available.** Run `gstack-analytics` to see your personal usage dashboard from the local JSONL file — no remote data needed. diff --git a/supabase/verify-rls.sh b/supabase/verify-rls.sh new file mode 100755 index 00000000..00b0669c --- /dev/null +++ b/supabase/verify-rls.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# verify-rls.sh — smoke test that anon key is locked out after 002_tighten_rls.sql +# +# Run manually after deploying the migration: +# bash supabase/verify-rls.sh +# +# All 9 checks should PASS (anon key denied for reads AND writes). +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +. "$SCRIPT_DIR/config.sh" + +URL="$GSTACK_SUPABASE_URL" +KEY="$GSTACK_SUPABASE_ANON_KEY" +PASS=0 +FAIL=0 + +check() { + local desc="$1" + local method="$2" + local path="$3" + local data="${4:-}" + + local args=(-sf -o /dev/null -w '%{http_code}' --max-time 10 + -H "apikey: ${KEY}" + -H "Content-Type: application/json") + + if [ "$method" = "GET" ]; then + HTTP="$(curl "${args[@]}" "${URL}/rest/v1/${path}" 2>/dev/null || echo "000")" + elif [ "$method" = "POST" ]; then + HTTP="$(curl "${args[@]}" -X POST "${URL}/rest/v1/${path}" -H "Prefer: return=minimal" -d "$data" 2>/dev/null || echo "000")" + elif [ "$method" = "PATCH" ]; then + HTTP="$(curl "${args[@]}" -X PATCH "${URL}/rest/v1/${path}" -d "$data" 2>/dev/null || echo "000")" + fi + + # Success = anything that is NOT a 200/201 with data + # 403, 401, or empty 200 (=[]) all count as "denied" + case "$HTTP" in + 200) + # For GETs, check if response is empty array + BODY="$(curl -sf --max-time 10 "${URL}/rest/v1/${path}" -H "apikey: ${KEY}" -H "Content-Type: application/json" 2>/dev/null || echo "")" + if [ "$BODY" = "[]" ] || [ -z "$BODY" ]; then + echo " PASS $desc (HTTP $HTTP, empty)" + PASS=$(( PASS + 1 )) + else + echo " FAIL $desc (HTTP $HTTP, got data)" + FAIL=$(( FAIL + 1 )) + fi + ;; + 401|403|404|406) + echo " PASS $desc (HTTP $HTTP, denied)" + PASS=$(( PASS + 1 )) + ;; + 201) + echo " FAIL $desc (HTTP $HTTP, write succeeded!)" + FAIL=$(( FAIL + 1 )) + ;; + 000) + echo " WARN $desc (connection failed)" + FAIL=$(( FAIL + 1 )) + ;; + *) + echo " PASS $desc (HTTP $HTTP)" + PASS=$(( PASS + 1 )) + ;; + esac +} + +echo "RLS Lockdown Verification" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "Read denial checks:" +check "SELECT telemetry_events" GET "telemetry_events?select=*&limit=1" +check "SELECT installations" GET "installations?select=*&limit=1" +check "SELECT update_checks" GET "update_checks?select=*&limit=1" +check "SELECT crash_clusters" GET "crash_clusters?select=*&limit=1" +check "SELECT skill_sequences" GET "skill_sequences?select=skill_a&limit=1" + +echo "" +echo "Write denial checks:" +check "INSERT telemetry_events" POST "telemetry_events" '{"gstack_version":"test","os":"test","event_timestamp":"2026-01-01T00:00:00Z","outcome":"test"}' +check "INSERT update_checks" POST "update_checks" '{"gstack_version":"test","os":"test"}' +check "INSERT installations" POST "installations" '{"installation_id":"test_verify_rls"}' +check "UPDATE installations" PATCH "installations?installation_id=eq.test_verify_rls" '{"gstack_version":"hacked"}' + +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "Results: $PASS passed, $FAIL failed (of 9 checks)" + +if [ "$FAIL" -gt 0 ]; then + echo "VERDICT: FAIL — anon key still has access" + exit 1 +else + echo "VERDICT: PASS — anon key fully locked out" + exit 0 +fi diff --git a/test/telemetry.test.ts b/test/telemetry.test.ts index 4dc79b29..c9b42473 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry.test.ts @@ -244,16 +244,32 @@ describe('gstack-analytics', () => { }); describe('gstack-telemetry-sync', () => { - test('exits silently with no endpoint configured', () => { - // Default: GSTACK_TELEMETRY_ENDPOINT is not set → exit 0 + test('exits silently with no Supabase URL configured', () => { + // Default: GSTACK_SUPABASE_URL is not set → exit 0 const result = run(`${BIN}/gstack-telemetry-sync`); expect(result).toBe(''); }); test('exits silently with no JSONL file', () => { - const result = run(`${BIN}/gstack-telemetry-sync`, { GSTACK_TELEMETRY_ENDPOINT: 'http://localhost:9999' }); + const result = run(`${BIN}/gstack-telemetry-sync`, { GSTACK_SUPABASE_URL: 'http://localhost:9999' }); expect(result).toBe(''); }); + + test('does not rename JSONL field names (edge function expects raw names)', () => { + setConfig('telemetry', 'anonymous'); + run(`${BIN}/gstack-telemetry-log --skill qa --duration 60 --outcome success --session-id raw-fields-1`); + + const events = parseJsonl(); + expect(events).toHaveLength(1); + // Edge function expects these raw field names, NOT Postgres column names + expect(events[0]).toHaveProperty('v'); + expect(events[0]).toHaveProperty('ts'); + expect(events[0]).toHaveProperty('sessions'); + // Should NOT have Postgres column names + expect(events[0]).not.toHaveProperty('schema_version'); + expect(events[0]).not.toHaveProperty('event_timestamp'); + expect(events[0]).not.toHaveProperty('concurrent_sessions'); + }); }); describe('gstack-community-dashboard', () => {