Initial import from garrytan/gstack@026751e (main snapshot via local relay)
Some checks failed
Workflow Lint / actionlint (push) Has been cancelled
Build CI Image / build (push) Has been cancelled
Skill Docs Freshness / check-freshness (push) Has been cancelled
Periodic Evals / build-image (push) Has been cancelled
Periodic Evals / evals (map[file:test/codex-e2e.test.ts name:e2e-codex]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/gemini-e2e.test.ts name:e2e-gemini]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-design.test.ts name:e2e-design]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-plan.test.ts name:e2e-plan]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-bugs.test.ts name:e2e-qa-bugs]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-qa-workflow.test.ts name:e2e-qa-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-review.test.ts name:e2e-review]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-e2e-workflow.test.ts name:e2e-workflow]) (push) Has been cancelled
Periodic Evals / evals (map[file:test/skill-routing-e2e.test.ts name:e2e-routing]) (push) Has been cancelled

Source: https://github.com/garrytan/gstack/commit/026751e
This commit is contained in:
Rocky
2026-05-19 21:18:17 +02:00
commit 834c6db075
797 changed files with 267839 additions and 0 deletions

8
supabase/config.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Supabase project config for gstack telemetry
# These are PUBLIC keys — safe to commit (like Firebase public config).
# RLS denies all access to the anon key. All reads and writes go through
# edge functions (which use SUPABASE_SERVICE_ROLE_KEY server-side).
GSTACK_SUPABASE_URL="https://frugpmstpnojnhfyimgv.supabase.co"
GSTACK_SUPABASE_ANON_KEY="sb_publishable_tR4i6cyMIrYTE3s6OyHGHw_ppx2p6WK"

View File

@@ -0,0 +1,215 @@
// gstack community-pulse edge function
// Returns aggregated community stats for the dashboard:
// weekly active count, top skills, crash clusters, version distribution.
// Uses server-side cache (community_pulse_cache table) to prevent DoS.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const CACHE_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
Deno.serve(async () => {
const supabase = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""
);
try {
// Check cache first
const { data: cached } = await supabase
.from("community_pulse_cache")
.select("data, refreshed_at")
.eq("id", 1)
.single();
if (cached?.refreshed_at) {
const age = Date.now() - new Date(cached.refreshed_at).getTime();
if (age < CACHE_MAX_AGE_MS) {
return new Response(JSON.stringify(cached.data), {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=3600",
},
});
}
}
// Cache is stale or missing — recompute
const weekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString();
// Weekly active (update checks this week)
const { count: thisWeek } = await supabase
.from("update_checks")
.select("*", { count: "exact", head: true })
.gte("checked_at", weekAgo);
// Last week (for change %)
const { count: lastWeek } = await supabase
.from("update_checks")
.select("*", { count: "exact", head: true })
.gte("checked_at", twoWeeksAgo)
.lt("checked_at", weekAgo);
const current = thisWeek ?? 0;
const previous = lastWeek ?? 0;
const changePct = previous > 0
? Math.round(((current - previous) / previous) * 100)
: 0;
// Top skills (last 7 days)
const { data: skillRows } = await supabase
.from("telemetry_events")
.select("skill")
.eq("event_type", "skill_run")
.gte("event_timestamp", weekAgo)
.not("skill", "is", null)
.limit(1000);
const skillCounts: Record<string, number> = {};
for (const row of skillRows ?? []) {
if (row.skill) {
skillCounts[row.skill] = (skillCounts[row.skill] ?? 0) + 1;
}
}
const topSkills = Object.entries(skillCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([skill, count]) => ({ skill, count }));
// Crash clusters (top 5)
const { data: crashes } = await supabase
.from("crash_clusters")
.select("error_class, gstack_version, total_occurrences, identified_users")
.limit(5);
// Version distribution (last 7 days)
const versionCounts: Record<string, number> = {};
const { data: versionRows } = await supabase
.from("telemetry_events")
.select("gstack_version")
.eq("event_type", "skill_run")
.gte("event_timestamp", weekAgo)
.limit(1000);
for (const row of versionRows ?? []) {
if (row.gstack_version) {
versionCounts[row.gstack_version] = (versionCounts[row.gstack_version] ?? 0) + 1;
}
}
const topVersions = Object.entries(versionCounts)
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([version, count]) => ({ version, count }));
// Security events — aggregate attack_attempt events from the last 7 days.
// Fields emitted by gstack-telemetry-log --event-type attack_attempt:
// security_url_domain, security_payload_hash, security_confidence,
// security_layer, security_verdict.
const { data: attackRows } = await supabase
.from("telemetry_events")
.select("security_url_domain, security_layer, security_verdict, installation_id")
.eq("event_type", "attack_attempt")
.gte("event_timestamp", weekAgo)
.limit(5000);
// k-anonymity threshold. A domain (or layer) must be reported by at least
// K_ANON distinct installations to appear in the aggregate. Without this,
// a single user's attack log leaks their targeted domains to every other
// gstack user who polls /community-pulse. With it, the dashboard shows
// only community-wide patterns.
const K_ANON = 5;
const attacksTotal = attackRows?.length ?? 0;
const domainCounts: Record<string, number> = {};
const domainInstallations: Record<string, Set<string>> = {};
const layerCounts: Record<string, number> = {};
const layerInstallations: Record<string, Set<string>> = {};
const verdictCounts: Record<string, number> = {};
for (const row of attackRows ?? []) {
const iid = row.installation_id ?? "";
if (row.security_url_domain) {
domainCounts[row.security_url_domain] = (domainCounts[row.security_url_domain] ?? 0) + 1;
if (iid) {
(domainInstallations[row.security_url_domain] ??= new Set()).add(iid);
}
}
if (row.security_layer) {
layerCounts[row.security_layer] = (layerCounts[row.security_layer] ?? 0) + 1;
if (iid) {
(layerInstallations[row.security_layer] ??= new Set()).add(iid);
}
}
if (row.security_verdict) {
// Verdict distribution is low-cardinality (block/warn/log_only) and
// aggregates population-wide with no re-identification risk, so no
// k-anon filter.
verdictCounts[row.security_verdict] = (verdictCounts[row.security_verdict] ?? 0) + 1;
}
}
const topAttackDomains = Object.entries(domainCounts)
.filter(([domain]) => (domainInstallations[domain]?.size ?? 0) >= K_ANON)
.sort(([, a], [, b]) => b - a)
.slice(0, 10)
.map(([domain, count]) => ({ domain, count }));
const topAttackLayers = Object.entries(layerCounts)
.filter(([layer]) => (layerInstallations[layer]?.size ?? 0) >= K_ANON)
.sort(([, a], [, b]) => b - a)
.map(([layer, count]) => ({ layer, count }));
const attackVerdictDistribution = Object.entries(verdictCounts)
.sort(([, a], [, b]) => b - a)
.map(([verdict, count]) => ({ verdict, count }));
const result = {
weekly_active: current,
change_pct: changePct,
top_skills: topSkills,
crashes: crashes ?? [],
versions: topVersions,
// Security aggregate for the /security-dashboard view
security: {
attacks_last_7_days: attacksTotal,
top_attack_domains: topAttackDomains,
top_attack_layers: topAttackLayers,
verdict_distribution: attackVerdictDistribution,
},
};
// Upsert cache
await supabase
.from("community_pulse_cache")
.upsert({
id: 1,
data: result,
refreshed_at: new Date().toISOString(),
});
return new Response(JSON.stringify(result), {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=3600",
},
});
} catch {
return new Response(
JSON.stringify({
weekly_active: 0,
change_pct: 0,
top_skills: [],
crashes: [],
versions: [],
security: {
attacks_last_7_days: 0,
top_attack_domains: [],
top_attack_layers: [],
verdict_distribution: [],
},
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
}
});

View File

@@ -0,0 +1,141 @@
// gstack telemetry-ingest edge function
// Validates and inserts a batch of telemetry events.
// Called by bin/gstack-telemetry-sync.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
interface TelemetryEvent {
v: number;
ts: string;
event_type: string;
skill: string;
session_id?: string;
gstack_version: string;
os: string;
arch?: string;
duration_s?: number;
outcome: string;
error_class?: string;
used_browse?: boolean;
sessions?: number;
installation_id?: string;
}
const MAX_BATCH_SIZE = 100;
const MAX_PAYLOAD_BYTES = 50_000; // 50KB
Deno.serve(async (req) => {
if (req.method !== "POST") {
return new Response("POST required", { status: 405 });
}
// Check payload size
const contentLength = parseInt(req.headers.get("content-length") || "0");
if (contentLength > MAX_PAYLOAD_BYTES) {
return new Response("Payload too large", { status: 413 });
}
try {
const body = await req.json();
const events: TelemetryEvent[] = Array.isArray(body) ? body : [body];
if (events.length > MAX_BATCH_SIZE) {
return new Response(`Batch too large (max ${MAX_BATCH_SIZE})`, { status: 400 });
}
// Use the anon key, not the service role key.
// The service role key bypasses Row Level Security (RLS) and grants full
// unrestricted database access — wildly over-privileged for a public
// telemetry endpoint that only needs INSERT on two tables.
// The anon key + properly configured RLS INSERT policies is correct.
// See: https://supabase.com/docs/guides/database/postgres/row-level-security
const supabase = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_ANON_KEY") ?? ""
);
// Validate and transform events
const rows = [];
const installationUpserts: Map<string, { version: string; os: string }> = new Map();
for (const event of events) {
// Required fields
if (!event.ts || !event.gstack_version || !event.os || !event.outcome) {
continue; // skip malformed
}
// Validate schema version
if (event.v !== 1) continue;
// Validate event_type
const validTypes = ["skill_run", "upgrade_prompted", "upgrade_completed"];
if (!validTypes.includes(event.event_type)) continue;
rows.push({
schema_version: event.v,
event_type: event.event_type,
gstack_version: String(event.gstack_version).slice(0, 20),
os: String(event.os).slice(0, 20),
arch: event.arch ? String(event.arch).slice(0, 20) : null,
event_timestamp: event.ts,
skill: event.skill ? String(event.skill).slice(0, 50) : null,
session_id: event.session_id ? String(event.session_id).slice(0, 50) : null,
duration_s: typeof event.duration_s === "number" ? event.duration_s : null,
outcome: String(event.outcome).slice(0, 20),
error_class: event.error_class ? String(event.error_class).slice(0, 100) : null,
used_browse: event.used_browse === true,
concurrent_sessions: typeof event.sessions === "number" ? event.sessions : 1,
installation_id: event.installation_id ? String(event.installation_id).slice(0, 64) : null,
});
// Track installations for upsert
if (event.installation_id) {
installationUpserts.set(event.installation_id, {
version: event.gstack_version,
os: event.os,
});
}
}
if (rows.length === 0) {
return new Response(JSON.stringify({ inserted: 0 }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// Insert events
const { error: insertError } = await supabase
.from("telemetry_events")
.insert(rows);
if (insertError) {
return new Response(JSON.stringify({ error: insertError.message }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
// Upsert installations (update last_seen)
for (const [id, data] of installationUpserts) {
await supabase
.from("installations")
.upsert(
{
installation_id: id,
last_seen: new Date().toISOString(),
gstack_version: data.version,
os: data.os,
},
{ onConflict: "installation_id" }
);
}
return new Response(JSON.stringify({ inserted: rows.length }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch {
return new Response("Invalid request", { status: 400 });
}
});

View File

@@ -0,0 +1,37 @@
// gstack update-check edge function
// Logs an install ping and returns the current latest version.
// Called by bin/gstack-update-check as a parallel background request.
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const CURRENT_VERSION = Deno.env.get("GSTACK_CURRENT_VERSION") || "0.6.4.1";
Deno.serve(async (req) => {
if (req.method !== "POST") {
return new Response(CURRENT_VERSION, { status: 200 });
}
try {
const { version, os } = await req.json();
if (!version || !os) {
return new Response(CURRENT_VERSION, { status: 200 });
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? ""
);
// Log the update check (fire-and-forget)
await supabase.from("update_checks").insert({
gstack_version: String(version).slice(0, 20),
os: String(os).slice(0, 20),
});
return new Response(CURRENT_VERSION, { status: 200 });
} catch {
// Always return the version, even if logging fails
return new Response(CURRENT_VERSION, { status: 200 });
}
});

View File

@@ -0,0 +1,89 @@
-- gstack telemetry schema
-- Tables for tracking usage, installations, and update checks.
-- Main telemetry events (skill runs, upgrades)
CREATE TABLE telemetry_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
received_at TIMESTAMPTZ DEFAULT now(),
schema_version INTEGER NOT NULL DEFAULT 1,
event_type TEXT NOT NULL DEFAULT 'skill_run',
gstack_version TEXT NOT NULL,
os TEXT NOT NULL,
arch TEXT,
event_timestamp TIMESTAMPTZ NOT NULL,
skill TEXT,
session_id TEXT,
duration_s NUMERIC,
outcome TEXT NOT NULL,
error_class TEXT,
used_browse BOOLEAN DEFAULT false,
concurrent_sessions INTEGER DEFAULT 1,
installation_id TEXT -- nullable, only for "community" tier
);
-- Index for skill_sequences view performance
CREATE INDEX idx_telemetry_session_ts ON telemetry_events (session_id, event_timestamp);
-- Index for crash clustering
CREATE INDEX idx_telemetry_error ON telemetry_events (error_class, gstack_version) WHERE outcome = 'error';
-- Retention tracking per installation
CREATE TABLE installations (
installation_id TEXT PRIMARY KEY,
first_seen TIMESTAMPTZ DEFAULT now(),
last_seen TIMESTAMPTZ DEFAULT now(),
gstack_version TEXT,
os TEXT
);
-- Install pings from update checks
CREATE TABLE update_checks (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
checked_at TIMESTAMPTZ DEFAULT now(),
gstack_version TEXT NOT NULL,
os TEXT NOT NULL
);
-- RLS: anon key can INSERT and SELECT (all telemetry data is anonymous)
ALTER TABLE telemetry_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY "anon_insert_only" ON telemetry_events FOR INSERT WITH CHECK (true);
CREATE POLICY "anon_select" ON telemetry_events FOR SELECT USING (true);
ALTER TABLE installations ENABLE ROW LEVEL SECURITY;
CREATE POLICY "anon_insert_only" ON installations FOR INSERT WITH CHECK (true);
CREATE POLICY "anon_select" ON installations FOR SELECT USING (true);
-- Allow upsert (update last_seen)
CREATE POLICY "anon_update_last_seen" ON installations FOR UPDATE USING (true) WITH CHECK (true);
ALTER TABLE update_checks ENABLE ROW LEVEL SECURITY;
CREATE POLICY "anon_insert_only" ON update_checks FOR INSERT WITH CHECK (true);
CREATE POLICY "anon_select" ON update_checks FOR SELECT USING (true);
-- Crash clustering view
CREATE VIEW crash_clusters AS
SELECT
error_class,
gstack_version,
COUNT(*) as total_occurrences,
COUNT(DISTINCT installation_id) as identified_users, -- community tier only
COUNT(*) - COUNT(installation_id) as anonymous_occurrences, -- events without installation_id
MIN(event_timestamp) as first_seen,
MAX(event_timestamp) as last_seen
FROM telemetry_events
WHERE outcome = 'error' AND error_class IS NOT NULL
GROUP BY error_class, gstack_version
ORDER BY total_occurrences DESC;
-- Skill sequence co-occurrence view
CREATE VIEW skill_sequences AS
SELECT
a.skill as skill_a,
b.skill as skill_b,
COUNT(DISTINCT a.session_id) as co_occurrences
FROM telemetry_events a
JOIN telemetry_events b ON a.session_id = b.session_id
AND a.skill != b.skill
AND a.event_timestamp < b.event_timestamp
WHERE a.event_type = 'skill_run' AND b.event_type = 'skill_run'
GROUP BY a.skill, b.skill
HAVING COUNT(DISTINCT a.session_id) >= 10
ORDER BY co_occurrences DESC;

View File

@@ -0,0 +1,36 @@
-- 002_tighten_rls.sql
-- Lock down read/update access. Keep INSERT policies so old clients can still
-- write via PostgREST while new clients migrate to edge functions.
-- Drop all SELECT policies (anon key should not read telemetry data)
DROP POLICY IF EXISTS "anon_select" ON telemetry_events;
DROP POLICY IF EXISTS "anon_select" ON installations;
DROP POLICY IF EXISTS "anon_select" ON update_checks;
-- Drop dangerous UPDATE policy (was unrestricted on all columns)
DROP POLICY IF EXISTS "anon_update_last_seen" ON installations;
-- Keep INSERT policies — old clients (pre-v0.11.16) still POST directly to
-- PostgREST. These will be dropped in a future migration once adoption of
-- edge-function-based sync is widespread.
-- (anon_insert_only ON telemetry_events — kept)
-- (anon_insert_only ON installations — kept)
-- (anon_insert_only ON update_checks — kept)
-- Explicitly revoke view access (belt-and-suspenders)
REVOKE SELECT ON crash_clusters FROM anon;
REVOKE SELECT ON skill_sequences FROM anon;
-- Keep error_message and failed_step columns (exist on live schema, may be
-- used in future). Add them to the migration record so repo matches live.
ALTER TABLE telemetry_events ADD COLUMN IF NOT EXISTS error_message TEXT;
ALTER TABLE telemetry_events ADD COLUMN IF NOT EXISTS failed_step TEXT;
-- Cache table for community-pulse aggregation (prevents DoS via repeated queries)
CREATE TABLE IF NOT EXISTS community_pulse_cache (
id INTEGER PRIMARY KEY DEFAULT 1,
data JSONB NOT NULL DEFAULT '{}'::jsonb,
refreshed_at TIMESTAMPTZ DEFAULT now()
);
ALTER TABLE community_pulse_cache ENABLE ROW LEVEL SECURITY;
-- No anon policies — only service_role_key (used by edge functions) can read/write

View File

@@ -0,0 +1,25 @@
-- 003_installations_upsert_policy.sql
-- Re-add a scoped UPDATE policy for installations so the telemetry-ingest
-- edge function can upsert (update last_seen) using the caller's anon key
-- instead of the service role key.
--
-- Migration 002 dropped the overly broad "anon_update_last_seen" policy
-- (which allowed UPDATE on ALL columns). This replacement uses:
-- 1. An RLS policy to allow UPDATE (required for any row access)
-- 2. Column-level GRANT to restrict anon to only the tracking columns
-- the edge function actually writes (last_seen, gstack_version, os)
--
-- This means anon callers cannot UPDATE first_seen or installation_id,
-- closing the residual risk from the broad RLS-only approach.
-- RLS policy: allow UPDATE on rows (required for PostgREST/upsert)
CREATE POLICY "anon_update_tracking" ON installations
FOR UPDATE
USING (true)
WITH CHECK (true);
-- Column-level restriction: anon can only UPDATE these three columns.
-- PostgreSQL GRANT UPDATE (col, ...) is enforced at the query level —
-- any UPDATE touching other columns will be rejected with a permission error.
REVOKE UPDATE ON installations FROM anon;
GRANT UPDATE (last_seen, gstack_version, os) ON installations TO anon;

View File

@@ -0,0 +1,44 @@
-- gstack attack telemetry — schema extension for prompt injection events.
--
-- Ships alongside the gstack-telemetry-log `--event-type attack_attempt`
-- flag (bin/gstack-telemetry-log, commits 28ce883c + f68fa4a9). These
-- columns are nullable so the existing skill_run events continue inserting
-- unchanged.
--
-- Fields (1:1 with gstack-telemetry-log flags):
-- security_url_domain — hostname only, never path/query
-- security_payload_hash — salted SHA-256 hex
-- security_confidence — 0..1 numeric, clamped client-side
-- security_layer — stackone_content | testsavant_content
-- | transcript_classifier | aria_regex | canary
-- | deberta_content
-- security_verdict — block | warn | log_only
--
-- Indices:
-- * (security_url_domain, event_timestamp) — for "top domains last 7 days"
-- * (security_layer, event_timestamp) WHERE event_type='attack_attempt'
-- — for layer-distribution queries
--
-- Privacy rules (enforced client-side, documented here):
-- * domain only, never path or query string
-- * payload_hash is a salted hash, not the payload
-- * salt is per-device local file (~/.gstack/security/device-salt) —
-- preventing cross-device rainbow table attacks
ALTER TABLE telemetry_events
ADD COLUMN security_url_domain TEXT,
ADD COLUMN security_payload_hash TEXT,
ADD COLUMN security_confidence NUMERIC,
ADD COLUMN security_layer TEXT,
ADD COLUMN security_verdict TEXT;
-- Top-domains query: ORDER BY count DESC WHERE event_type='attack_attempt'
-- AND event_timestamp > now() - interval '7 days'
CREATE INDEX idx_telemetry_attack_domain
ON telemetry_events (security_url_domain, event_timestamp)
WHERE event_type = 'attack_attempt';
-- Layer-distribution query
CREATE INDEX idx_telemetry_attack_layer
ON telemetry_events (security_layer, event_timestamp)
WHERE event_type = 'attack_attempt';

143
supabase/verify-rls.sh Executable file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# verify-rls.sh — smoke test after deploying 002_tighten_rls.sql
#
# Verifies:
# - SELECT denied on all tables and views (security fix)
# - UPDATE denied on installations (security fix)
# - INSERT still allowed on tables (kept for old client compat)
#
# Run manually after deploying the migration:
# bash supabase/verify-rls.sh
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
TOTAL=0
# check <description> <expected> <method> <path> [data]
# expected: "deny" (want 401/403) or "allow" (want 200/201)
check() {
local desc="$1"
local expected="$2"
local method="$3"
local path="$4"
local data="${5:-}"
TOTAL=$(( TOTAL + 1 ))
local resp_file
resp_file="$(mktemp 2>/dev/null || echo "/tmp/verify-rls-$$-$TOTAL")"
local http_code
if [ "$method" = "GET" ]; then
http_code="$(curl -s -o "$resp_file" -w '%{http_code}' --max-time 10 \
"${URL}/rest/v1/${path}" \
-H "apikey: ${KEY}" \
-H "Authorization: Bearer ${KEY}" \
-H "Content-Type: application/json" 2>/dev/null)" || http_code="000"
elif [ "$method" = "POST" ]; then
http_code="$(curl -s -o "$resp_file" -w '%{http_code}' --max-time 10 \
-X POST "${URL}/rest/v1/${path}" \
-H "apikey: ${KEY}" \
-H "Authorization: Bearer ${KEY}" \
-H "Content-Type: application/json" \
-H "Prefer: return=minimal" \
-d "$data" 2>/dev/null)" || http_code="000"
elif [ "$method" = "PATCH" ]; then
http_code="$(curl -s -o "$resp_file" -w '%{http_code}' --max-time 10 \
-X PATCH "${URL}/rest/v1/${path}" \
-H "apikey: ${KEY}" \
-H "Authorization: Bearer ${KEY}" \
-H "Content-Type: application/json" \
-d "$data" 2>/dev/null)" || http_code="000"
fi
# Trim to last 3 chars (the HTTP code) in case of concatenation
http_code="$(echo "$http_code" | grep -oE '[0-9]{3}$' || echo "000")"
if [ "$expected" = "deny" ]; then
case "$http_code" in
401|403)
echo " PASS $desc (HTTP $http_code, denied)"
PASS=$(( PASS + 1 )) ;;
200|204)
# For GETs: 200+empty means RLS filtering (pass). 200+data means leak (fail).
# For PATCH: 204 means no rows matched — could be RLS or missing row.
if [ "$method" = "GET" ]; then
body="$(cat "$resp_file" 2>/dev/null || echo "")"
if [ "$body" = "[]" ] || [ -z "$body" ]; then
echo " PASS $desc (HTTP $http_code, empty — RLS filtering)"
PASS=$(( PASS + 1 ))
else
echo " FAIL $desc (HTTP $http_code, got data!)"
FAIL=$(( FAIL + 1 ))
fi
else
# PATCH 204 = no rows affected. RLS blocked the update or row doesn't exist.
# Either way, the attacker can't modify data.
echo " PASS $desc (HTTP $http_code, no rows affected)"
PASS=$(( PASS + 1 ))
fi ;;
000)
echo " WARN $desc (connection failed)"
FAIL=$(( FAIL + 1 )) ;;
*)
echo " WARN $desc (HTTP $http_code — unexpected)"
FAIL=$(( FAIL + 1 )) ;;
esac
elif [ "$expected" = "allow" ]; then
case "$http_code" in
200|201|204|409)
# 409 = conflict (duplicate key) — INSERT policy works, row already exists
echo " PASS $desc (HTTP $http_code, allowed as expected)"
PASS=$(( PASS + 1 )) ;;
401|403)
echo " FAIL $desc (HTTP $http_code, denied — should be allowed)"
FAIL=$(( FAIL + 1 )) ;;
000)
echo " WARN $desc (connection failed)"
FAIL=$(( FAIL + 1 )) ;;
*)
echo " WARN $desc (HTTP $http_code — unexpected)"
FAIL=$(( FAIL + 1 )) ;;
esac
fi
rm -f "$resp_file" 2>/dev/null || true
}
echo "RLS Verification (after 002_tighten_rls.sql)"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "Read denial (should be blocked):"
check "SELECT telemetry_events" deny GET "telemetry_events?select=*&limit=1"
check "SELECT installations" deny GET "installations?select=*&limit=1"
check "SELECT update_checks" deny GET "update_checks?select=*&limit=1"
check "SELECT crash_clusters" deny GET "crash_clusters?select=*&limit=1"
check "SELECT skill_sequences" deny GET "skill_sequences?select=skill_a&limit=1"
echo ""
echo "Update denial (should be blocked):"
check "UPDATE installations" deny PATCH "installations?installation_id=eq.test_verify_rls" '{"gstack_version":"hacked"}'
echo ""
echo "Insert allowed (kept for old client compat):"
check "INSERT telemetry_events" allow POST "telemetry_events" '{"gstack_version":"verify_rls_test","os":"test","event_timestamp":"2026-01-01T00:00:00Z","outcome":"test"}'
check "INSERT update_checks" allow POST "update_checks" '{"gstack_version":"verify_rls_test","os":"test"}'
check "INSERT installations" allow POST "installations" '{"installation_id":"verify_rls_test"}'
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Results: $PASS passed, $FAIL failed (of $TOTAL checks)"
if [ "$FAIL" -gt 0 ]; then
echo "VERDICT: FAIL"
exit 1
else
echo "VERDICT: PASS — reads/updates blocked, inserts allowed"
exit 0
fi