feat: auth-gate demo chat + budget circuit breaker #68
@@ -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
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+85
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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>"
|
||||
|
||||
Reference in New Issue
Block a user