diff --git a/cloud-functions/demo-budget-guard/main.py b/cloud-functions/demo-budget-guard/main.py new file mode 100644 index 0000000..8968bdf --- /dev/null +++ b/cloud-functions/demo-budget-guard/main.py @@ -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}") diff --git a/cloud-functions/demo-budget-guard/requirements.txt b/cloud-functions/demo-budget-guard/requirements.txt new file mode 100644 index 0000000..2c24336 --- /dev/null +++ b/cloud-functions/demo-budget-guard/requirements.txt @@ -0,0 +1 @@ +requests==2.31.0 diff --git a/dist/page_close.c b/dist/page_close.c index 71a5bca..93b37ef 100644 --- a/dist/page_close.c +++ b/dist/page_close.c @@ -5,7 +5,7 @@ el_val_t _page_close_impl(void); el_val_t _page_close_impl(void) { - el_val_t widgets = ({ el_val_t _html_1 = EL_STR(""); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Try Neuron")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Neuron")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Live Demo")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Send")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Preview")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("This is what you are about to publish")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("×")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Cancel")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Publish to gallery")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1; }); + el_val_t widgets = ({ el_val_t _html_1 = EL_STR(""); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Try Neuron")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Neuron")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Live Demo")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Send")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Preview")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("This is what you are about to publish")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("×")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Cancel")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("Publish to gallery")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1 = el_str_concat(_html_1, EL_STR("")); _html_1; }); return el_str_concat(widgets, EL_STR("")); return 0; } diff --git a/dist/page_css.c b/dist/page_css.c index 4b11ce5..1629300 100644 --- a/dist/page_css.c +++ b/dist/page_css.c @@ -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" diff --git a/migrations/20260510000000_demo_config.sql b/migrations/20260510000000_demo_config.sql new file mode 100644 index 0000000..8c1a5c8 --- /dev/null +++ b/migrations/20260510000000_demo_config.sql @@ -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; diff --git a/src/js/chat-widget.el b/src/js/chat-widget.el index bb638b6..153a383 100644 --- a/src/js/chat-widget.el +++ b/src/js/chat-widget.el @@ -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 = ' 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 = '' + + '' + + '' + + '

'; + 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.'; diff --git a/src/main.el b/src/main.el index ae09ca8..5feb0aa 100644 --- a/src/main.el +++ b/src/main.el @@ -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 diff --git a/src/styles.el b/src/styles.el index 00d4629..3ad5054 100644 --- a/src/styles.el +++ b/src/styles.el @@ -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() + "" + + "" + "" + "" + ""