feat: auth-gate demo chat + budget circuit breaker #68

Merged
will.anderson merged 2 commits from dev into stage 2026-05-11 04:46:03 +00:00
8 changed files with 383 additions and 34 deletions
+35
View File
@@ -0,0 +1,35 @@
import json
import base64
import os
import requests
from datetime import datetime, timezone
def budget_alert(event, context):
"""Triggered by a Pub/Sub budget alert. Disables demo if threshold exceeded."""
data = base64.b64decode(event['data']).decode('utf-8')
alert = json.loads(data)
# Only act on threshold exceeded alerts (not forecasts)
cost_amount = alert.get('costAmount', 0)
budget_amount = alert.get('budgetAmount', 1)
threshold = cost_amount / budget_amount if budget_amount else 0
if threshold < 0.9:
print(f"Threshold {threshold:.1%} below 90%, no action")
return
supabase_url = os.environ['SUPABASE_URL']
service_key = os.environ['SUPABASE_SERVICE_KEY']
resp = requests.patch(
f"{supabase_url}/rest/v1/demo_config?key=eq.demo_enabled",
headers={
'Authorization': f'Bearer {service_key}',
'apikey': service_key,
'Content-Type': 'application/json',
'Prefer': 'return=minimal',
},
json={'value': 'false', 'updated_at': datetime.now(timezone.utc).isoformat()}
)
print(f"Demo disabled — budget at {threshold:.1%}. Supabase: {resp.status_code}")
@@ -0,0 +1 @@
requests==2.31.0
+1 -1
View File
File diff suppressed because one or more lines are too long
+85
View File
@@ -1794,6 +1794,91 @@ el_val_t page_css(void) {
" }\n"
" #neuron-demo-send:hover { background: #0078D4; }\n"
" #neuron-demo-send:disabled { opacity: 0.5; cursor: not-allowed; }\n"
" #neuron-demo-auth {\n"
" flex-direction: column;\n"
" align-items: center;\n"
" gap: 0.75rem;\n"
" padding: 1.5rem 1.25rem;\n"
" flex: 1;\n"
" }\n"
" .demo-auth-heading {\n"
" font-family: var(--body);\n"
" font-size: 0.85rem;\n"
" font-weight: 600;\n"
" color: var(--t1);\n"
" text-align: center;\n"
" margin: 0 0 0.25rem;\n"
" }\n"
" .demo-auth-google-btn {\n"
" display: flex;\n"
" align-items: center;\n"
" gap: 0.6rem;\n"
" width: 100%;\n"
" padding: 0.65rem 1rem;\n"
" font-family: var(--body);\n"
" font-size: 0.82rem;\n"
" font-weight: 500;\n"
" color: var(--t1);\n"
" background: #fff;\n"
" border: 1px solid var(--border);\n"
" border-radius: 6px;\n"
" cursor: pointer;\n"
" justify-content: center;\n"
" transition: border-color 180ms, box-shadow 180ms;\n"
" }\n"
" .demo-auth-google-btn:hover {\n"
" border-color: rgba(0,82,160,0.45);\n"
" box-shadow: 0 0 0 3px rgba(0,82,160,0.08);\n"
" }\n"
" .demo-auth-email-toggle {\n"
" background: none;\n"
" border: none;\n"
" cursor: pointer;\n"
" font-family: var(--body);\n"
" font-size: 0.78rem;\n"
" color: var(--t3);\n"
" text-decoration: underline;\n"
" padding: 0;\n"
" }\n"
" .demo-auth-email-form {\n"
" display: none;\n"
" flex-direction: column;\n"
" gap: 0.5rem;\n"
" width: 100%;\n"
" }\n"
" .demo-auth-email-form input {\n"
" width: 100%;\n"
" padding: 0.6rem 0.75rem;\n"
" font-family: var(--body);\n"
" font-size: 0.82rem;\n"
" color: var(--t1);\n"
" background: var(--bg);\n"
" border: 1px solid var(--border);\n"
" border-radius: 6px;\n"
" outline: none;\n"
" }\n"
" .demo-auth-email-form input:focus { border-color: var(--navy); }\n"
" .demo-auth-submit-btn {\n"
" width: 100%;\n"
" padding: 0.6rem 1rem;\n"
" font-family: var(--body);\n"
" font-size: 0.82rem;\n"
" font-weight: 600;\n"
" color: #fff;\n"
" background: var(--navy);\n"
" border: none;\n"
" border-radius: 6px;\n"
" cursor: pointer;\n"
" transition: background 180ms;\n"
" }\n"
" .demo-auth-submit-btn:hover { background: #0078D4; }\n"
" .demo-auth-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }\n"
" .demo-auth-msg {\n"
" font-family: var(--body);\n"
" font-size: 0.75rem;\n"
" margin: 0;\n"
" text-align: center;\n"
" }\n"
" @media (max-width: 600px) {\n"
" #neuron-demo-text { font-size: 1rem; padding: 1rem; }\n"
" #neuron-demo-send { padding: 1rem 1.25rem; min-width: 64px; }\n"
+22
View File
@@ -0,0 +1,22 @@
-- 20260510000000_demo_config.sql
--
-- Kill switch for the demo chat endpoint. Backs the budget-alert Cloud Function
-- that flips demo_enabled to 'false' when GCP spend crosses 90% of the daily
-- budget threshold. The web tier polls this table with a 60s TTL cache so the
-- demo is disabled within one minute of a budget alert firing.
--
-- Service-role bypasses RLS. Public anon has no access (policy USING (false)).
CREATE TABLE IF NOT EXISTS public.demo_config (
key text PRIMARY KEY,
value text NOT NULL,
updated_at timestamptz DEFAULT now()
);
ALTER TABLE public.demo_config ENABLE ROW LEVEL SECURITY;
CREATE POLICY "service only" ON public.demo_config USING (false);
-- Seed the kill switch as enabled
INSERT INTO public.demo_config (key, value) VALUES ('demo_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
+186 -32
View File
@@ -1,9 +1,9 @@
// chat-widget.el -- Neuron demo chat widget with Turnstile, session persistence,
// local engram graph, and share-pill.
// chat-widget.el -- Neuron demo chat widget with Supabase auth, Turnstile,
// session persistence, local engram graph, and share-pill.
// Compiled with: elc --target=js --bundle --minify --obfuscate
//
// Exposed globals: neuronDemoToggle(), neuronDemoSend(), neuronDemoReset()
// Required CDN: marked.js, Cloudflare Turnstile
// Required CDN: marked.js, Cloudflare Turnstile, Supabase JS
fn main() -> Void {
native_js("(function() {
@@ -15,6 +15,126 @@ fn main() -> Void {
var isOpen = false;
var MAX = 10;
// ── Supabase auth state ───────────────────────────────────────────────────
var supabaseClient = null;
var _supabaseSession = null; // current session (null = not authenticated)
function initSupabaseWidget(cb) {
if (supabaseClient) { cb(); return; }
fetch('/api/supabase-config')
.then(function(r) { return r.json(); })
.then(function(cfg) {
supabaseClient = window.supabase.createClient(cfg.url, cfg.anon_key, {
auth: { flowType: 'implicit' }
});
supabaseClient.auth.getSession().then(function(res) {
if (res.data && res.data.session) {
_supabaseSession = res.data.session;
}
// Listen for sign-in from OAuth redirect
supabaseClient.auth.onAuthStateChange(function(event, session) {
if (session) {
_supabaseSession = session;
_onWidgetAuthenticated();
}
});
cb();
});
})
.catch(function() { cb(); });
}
function _onWidgetAuthenticated() {
var authPane = document.getElementById('neuron-demo-auth');
var gate = document.getElementById('neuron-demo-gate');
var msgs = document.getElementById('neuron-demo-messages');
var inputRow = document.getElementById('neuron-demo-input-row');
if (authPane) authPane.style.display = 'none';
// Only reveal chat UI if Turnstile has also passed
if (turnstileVerified) {
if (gate) gate.style.display = 'none';
if (msgs) msgs.style.display = 'flex';
if (inputRow) inputRow.style.display = 'flex';
var msgs2 = document.getElementById('neuron-demo-messages');
if (msgs2 && msgs2.children.length === 0) {
if (session && session.messages && session.messages.length > 0) {
session.messages.forEach(function(m) { addMsg(m.role, m.text, true); });
} else if (!session.greeted) {
addMsg('ai', 'Hey. What is on your mind?', true);
session.greeted = true;
saveSession(session);
}
}
var inp = document.getElementById('neuron-demo-text');
if (inp) inp.focus();
}
}
function _renderWidgetAuthPane() {
var authPane = document.getElementById('neuron-demo-auth');
if (!authPane) return;
authPane.innerHTML = '';
authPane.style.display = 'flex';
var heading = document.createElement('p');
heading.className = 'demo-auth-heading';
heading.textContent = 'Sign in to chat with Neuron';
authPane.appendChild(heading);
var googleBtn = document.createElement('button');
googleBtn.className = 'demo-auth-google-btn';
googleBtn.innerHTML = '<svg width=\"18\" height=\"18\" viewBox=\"0 0 48 48\"><path fill=\"#EA4335\" d=\"M24 9.5c3.14 0 5.95 1.08 8.17 2.84l6.09-6.09C34.46 3.19 29.53 1 24 1 14.62 1 6.68 6.84 3.32 15.09l7.1 5.52C12.16 14.02 17.6 9.5 24 9.5z\"/><path fill=\"#4285F4\" d=\"M46.5 24.5c0-1.64-.15-3.22-.42-4.75H24v9h12.7c-.55 2.99-2.2 5.53-4.68 7.24l7.19 5.59C43.07 37.23 46.5 31.3 46.5 24.5z\"/><path fill=\"#FBBC05\" d=\"M10.42 28.39A14.6 14.6 0 0 1 9.5 24c0-1.52.26-3 .72-4.39l-7.1-5.52A23.5 23.5 0 0 0 .5 24c0 3.78.88 7.36 2.44 10.56l7.48-6.17z\"/><path fill=\"#34A853\" d=\"M24 47c5.53 0 10.17-1.83 13.56-4.97l-7.19-5.59C28.56 37.88 26.38 38.5 24 38.5c-6.4 0-11.84-4.52-13.58-10.61l-7.48 6.17C6.68 43.16 14.62 47 24 47z\"/><path fill=\"none\" d=\"M0 0h48v48H0z\"/></svg> Continue with Google';
googleBtn.onclick = function() {
if (!supabaseClient) return;
supabaseClient.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: window.location.href }
});
};
authPane.appendChild(googleBtn);
var emailToggle = document.createElement('button');
emailToggle.className = 'demo-auth-email-toggle';
emailToggle.textContent = 'or continue with email';
authPane.appendChild(emailToggle);
var emailForm = document.createElement('div');
emailForm.className = 'demo-auth-email-form';
emailForm.style.display = 'none';
emailForm.innerHTML = '<input type=\"email\" id=\"demo-auth-email\" placeholder=\"Email\" autocomplete=\"email\" />'
+ '<input type=\"password\" id=\"demo-auth-password\" placeholder=\"Password\" autocomplete=\"current-password\" />'
+ '<button class=\"demo-auth-submit-btn\" id=\"demo-auth-submit\">Sign in</button>'
+ '<p class=\"demo-auth-msg\" id=\"demo-auth-msg\" style=\"display:none\"></p>';
authPane.appendChild(emailForm);
emailToggle.onclick = function() {
emailForm.style.display = emailForm.style.display === 'none' ? 'flex' : 'none';
};
var submitBtn = emailForm.querySelector('#demo-auth-submit');
if (submitBtn) {
submitBtn.onclick = function() {
var email = (document.getElementById('demo-auth-email') || {}).value || '';
var pass = (document.getElementById('demo-auth-password') || {}).value || '';
var msgEl = document.getElementById('demo-auth-msg');
if (!email || !pass) {
if (msgEl) { msgEl.textContent = 'Email and password required.'; msgEl.style.display = ''; msgEl.style.color = '#e53e3e'; }
return;
}
submitBtn.disabled = true;
supabaseClient.auth.signInWithPassword({ email: email, password: pass }).then(function(res) {
if (res.error) {
if (msgEl) { msgEl.textContent = res.error.message || 'Sign in failed.'; msgEl.style.display = ''; msgEl.style.color = '#e53e3e'; }
submitBtn.disabled = false;
} else {
_supabaseSession = res.data.session;
_onWidgetAuthenticated();
}
}).catch(function() { submitBtn.disabled = false; });
};
}
}
function loadSession() {
try {
var s = localStorage.getItem('neuron_demo_session');
@@ -148,7 +268,7 @@ fn main() -> Void {
var btn = document.getElementById('neuron-demo-btn');
if (btn) btn.style.display = isOpen ? 'none' : '';
var msgs = document.getElementById('neuron-demo-messages');
if (isOpen && turnstileVerified && msgs && msgs.style.display !== 'none' && msgs.children.length === 0) {
if (isOpen && turnstileVerified && _supabaseSession && msgs && msgs.style.display !== 'none' && msgs.children.length === 0) {
if (session.messages && session.messages.length > 0) {
session.messages.forEach(function(m) { addMsg(m.role, m.text, true); });
var remaining = MAX - msgCount;
@@ -165,36 +285,50 @@ fn main() -> Void {
var input = document.getElementById('neuron-demo-text');
if (isOpen && input && !input.disabled) input.focus();
updateCountdown();
if (isOpen && !turnstileWidgetId && typeof turnstile !== 'undefined') {
var container = document.getElementById('neuron-demo-turnstile');
if (container) {
turnstileWidgetId = turnstile.render(container, {
sitekey: TURNSTILE_SITE_KEY,
size: 'compact',
callback: function(token) {
turnstileToken = token;
turnstileVerified = true;
if (typeof turnstile !== 'undefined' && turnstileWidgetId !== null) {
try { turnstile.remove(turnstileWidgetId); } catch(e) {}
turnstileWidgetId = null;
if (isOpen) {
// Initialize Supabase on first open, then decide what to show
initSupabaseWidget(function() {
if (!_supabaseSession) {
// Not authenticated — show auth pane, hide Turnstile gate
var gate = document.getElementById('neuron-demo-gate');
if (gate) gate.style.display = 'none';
_renderWidgetAuthPane();
} else {
// Authenticated — proceed with Turnstile gate as normal
if (!turnstileWidgetId && typeof turnstile !== 'undefined') {
var container = document.getElementById('neuron-demo-turnstile');
if (container) {
turnstileWidgetId = turnstile.render(container, {
sitekey: TURNSTILE_SITE_KEY,
size: 'compact',
callback: function(token) {
turnstileToken = token;
turnstileVerified = true;
if (typeof turnstile !== 'undefined' && turnstileWidgetId !== null) {
try { turnstile.remove(turnstileWidgetId); } catch(e) {}
turnstileWidgetId = null;
}
var gate = document.getElementById('neuron-demo-gate');
var msgs = document.getElementById('neuron-demo-messages');
var inputRow = document.getElementById('neuron-demo-input-row');
if (gate) gate.style.display = 'none';
if (msgs) msgs.style.display = 'flex';
if (inputRow) inputRow.style.display = 'flex';
addMsg('ai', 'Hey. What is on your mind?', true);
updateCountdown();
var inp = document.getElementById('neuron-demo-text');
if (inp) inp.focus();
},
'expired-callback': function() {
turnstileToken = '';
turnstileVerified = false;
}
});
}
var gate = document.getElementById('neuron-demo-gate');
var msgs = document.getElementById('neuron-demo-messages');
var inputRow = document.getElementById('neuron-demo-input-row');
if (gate) gate.style.display = 'none';
if (msgs) msgs.style.display = 'flex';
if (inputRow) inputRow.style.display = 'flex';
addMsg('ai', 'Hey. What is on your mind?', true);
updateCountdown();
var inp = document.getElementById('neuron-demo-text');
if (inp) inp.focus();
},
'expired-callback': function() {
turnstileToken = '';
turnstileVerified = false;
}
});
}
}
});
}
};
@@ -310,6 +444,7 @@ fn main() -> Void {
});
var activated_nodes = _ra(session._m, msg);
var questionsRemaining = Math.max(0, (MAX - msgCount) - 1);
var accessToken = (_supabaseSession && _supabaseSession.access_token) ? _supabaseSession.access_token : '';
var r = await fetch('/api/demo', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@@ -318,6 +453,7 @@ fn main() -> Void {
history: hist,
cf_token: turnstileVerified && !session._cfSent ? turnstileToken : '',
uid: session.uid || '',
access_token: accessToken,
activated_nodes: activated_nodes,
engram_node_count: (session._m && session._m.nodes) ? session._m.nodes.length : 0,
questions_remaining: questionsRemaining,
@@ -361,6 +497,24 @@ fn main() -> Void {
return;
}
// Auth required — show auth pane again
if (d.auth_required) {
addMsg('ai', 'Please sign in to continue chatting with Neuron.');
_renderWidgetAuthPane();
var msgs2 = document.getElementById('neuron-demo-messages');
var inputRow2 = document.getElementById('neuron-demo-input-row');
if (msgs2) msgs2.style.display = 'none';
if (inputRow2) inputRow2.style.display = 'none';
return;
}
// Demo disabled by budget circuit breaker
if (d.disabled) {
addMsg('ai', d.error || 'The demo is temporarily unavailable. Check back soon.');
if (input) { input.disabled = true; input.placeholder = 'Demo unavailable'; }
if (btn) { btn.disabled = true; }
return;
}
_um(session, d.sn, d.se);
var reply = d.response || d.reply || d.message || '';
var isError = !reply || reply === 'Stepped out for a moment. Try again.';
+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
+1
View File
@@ -42,6 +42,7 @@ fn page_head() -> String {
+ el_link_stylesheet("https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;1,400;1,500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap")
+ page_css()
+ "<script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\" integrity=\"sha384-948ahk4ZmxYVYOc+rxN1H2gM1EJ2Duhp7uHtZ4WSLkV4Vtx5MUqnV+l7u9B+jFv+\" crossorigin=\"anonymous\"></script>"
+ "<script src=\"https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2\" defer></script>"
+ "<script src=\"https://challenges.cloudflare.com/turnstile/v0/api.js\" async defer></script>"
+ "<noscript><style>.reveal { opacity: 1 !important; transform: none !important; }</style></noscript>"
+ "<script async src=\"https://www.googletagmanager.com/gtag/js?id=G-Y1EE43X9RN\"></script>"