feat: auth-gate demo chat + budget circuit breaker
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 2m10s
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:
+52
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user