feat: auth-gate demo chat + budget circuit breaker
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 2m10s

Gate the demo chat behind Supabase auth: the widget now fetches Supabase
config on open, shows a compact sign-in pane (Google OAuth or email/password)
when the user is unauthenticated, and passes the access_token to /api/demo.
The server verifies the token via supabase_auth_user() before any processing
and uses the verified user ID as the rate-limit key.

Add a budget kill switch: a demo_config table in Supabase holds a
demo_enabled flag that /api/demo polls every 60s (cached, fails open).
A Cloud Function (demo-budget-guard) is triggered by a GCP Pub/Sub budget
alert and sets demo_enabled = 'false' when spend crosses 90% of the $150
daily budget. Budget and topic are provisioned; function is live in
us-central1.
This commit is contained in:
2026-05-10 23:44:54 -05:00
parent bdb6ddc581
commit fe418bf3f7
8 changed files with 383 additions and 34 deletions
+52 -1
View File
@@ -1164,6 +1164,37 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
if str_len(msg) > 8000 {
return "{\"error\":\"Message too long. Please keep your message under 8000 characters.\"}"
}
// Kill switch budget circuit breaker (Supabase demo_config)
// Polls demo_config.demo_enabled every 60s. Fails open on error so
// a Supabase hiccup does not break the demo for legitimate users.
let ks_sb_url: String = state_get("__supabase_project_url__")
let ks_sb_key: String = state_get("__supabase_service_key__")
let ks_now: Int = unix_timestamp()
let ks_checked_at: String = state_get("__demo_enabled_checked_at__")
let ks_checked_n: Int = if str_eq(ks_checked_at, "") { 0 } else { str_to_int(ks_checked_at) }
let ks_enabled: String = state_get("__demo_enabled_cache__")
// On first boot set defaults
if str_eq(ks_enabled, "") {
state_set("__demo_enabled_cache__", "true")
let ks_enabled = "true"
}
// Refresh cache if >60s old and service key is present
if (ks_now - ks_checked_n) > 60 && !str_eq(ks_sb_key, "") {
let ks_resp: String = supabase_get(ks_sb_url, ks_sb_key,
"demo_config?key=eq.demo_enabled&select=value&limit=1")
let ks_row: String = json_array_get(ks_resp, 0)
if !str_eq(ks_row, "") {
let ks_val: String = json_get(ks_row, "value")
if !str_eq(ks_val, "") {
state_set("__demo_enabled_cache__", ks_val)
let ks_enabled = ks_val
}
}
state_set("__demo_enabled_checked_at__", int_to_str(ks_now))
}
if str_eq(ks_enabled, "false") {
return "{\"error\":\"The demo is temporarily unavailable. Check back soon.\",\"disabled\":true}"
}
// Global circuit breaker
// Caps total demo requests per Cloud Run instance per UTC day to 2000.
// This bounds per-instance API spend regardless of uid diversity.
@@ -1186,13 +1217,33 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
}
state_set("__global_demo_count__", int_to_str(global_cnt + 1))
// Auth: verify Supabase access_token
// The widget sends an access_token from the signed-in Supabase session.
// Verify it against the Supabase auth API to get the verified user ID.
// Reject unauthenticated requests outright.
let access_token: String = json_get(body, "access_token")
let auth_sb_url: String = state_get("__supabase_project_url__")
let auth_anon: String = state_get("__supabase_anon_key__")
let verified_uid: String = ""
if str_eq(access_token, "") {
return "{\"error\":\"Sign in required to use the demo.\",\"auth_required\":true}"
}
// supabase_auth_user calls GET /auth/v1/user with both Authorization
// (user's Bearer token) and apikey (anon key) headers.
let auth_resp: String = supabase_auth_user(auth_sb_url, auth_anon, access_token)
let auth_uid: String = json_get(auth_resp, "id")
if str_eq(auth_uid, "") {
return "{\"error\":\"Sign in required to use the demo.\",\"auth_required\":true}"
}
let verified_uid = auth_uid
// Per-uid rate limit (Supabase shared across all instances)
// Uses demo_rate_limits table: uid (PK), count, day_number, updated_at.
// Falls back to in-process state_get/state_set when the service key is
// absent (local dev without SUPABASE_SERVICE_KEY set).
// Returns rate_limited JSON with reset_at (next midnight UTC) so
// the frontend can show a real countdown.
let rate_uid: String = json_get(body, "uid")
let rate_uid: String = verified_uid
let now_ts: Int = unix_timestamp()
let today_day: Int = now_ts / 86400
let next_reset: Int = (today_day + 1) * 86400