feat(chat): IP-keyed daily rate limit (10/day), live reset countdown, web_demo Anthropic key

This commit is contained in:
Will Anderson
2026-05-05 04:10:22 -05:00
parent 9b69783306
commit 70820cf078
2 changed files with 62 additions and 7 deletions
+35
View File
@@ -276,6 +276,41 @@ fn main() -> Void {
});
var d = await r.json();
if (thinking) thinking.remove();
// Server-side rate limit — show a live countdown to reset
if (d.rate_limited && d.reset_at) {
var _showRateTimer = function() {
var now = Math.floor(Date.now() / 1000);
var secsLeft = Math.max(0, d.reset_at - now);
var hh = Math.floor(secsLeft / 3600);
var mm = Math.floor((secsLeft % 3600) / 60);
var ss = secsLeft % 60;
var pad = function(n) { return n < 10 ? '0' + n : '' + n; };
var ts = hh > 0 ? (hh + ':' + pad(mm) + ':' + pad(ss)) : (pad(mm) + ':' + pad(ss));
return 'You\'ve had 10 conversations today. Come back in ' + ts + '.';
};
addMsg('ai', _showRateTimer());
// Update the last ai message with a live ticker
var _timerInterval = setInterval(function() {
var thMsgsInner = document.getElementById('neuron-demo-msgs');
if (!thMsgsInner) { clearInterval(_timerInterval); return; }
var aiMsgs = thMsgsInner.querySelectorAll('.neuron-msg-ai');
var lastAi = aiMsgs[aiMsgs.length - 1];
if (lastAi) { lastAi.textContent = _showRateTimer(); }
if (Math.floor(Date.now() / 1000) >= d.reset_at) {
clearInterval(_timerInterval);
if (lastAi) { lastAi.textContent = 'You\'re all set — conversations reset. Say hello!'; }
if (input) { input.disabled = false; input.placeholder = 'Ask me anything...'; }
if (btn) { btn.disabled = false; }
}
}, 1000);
if (input) { input.disabled = true; input.placeholder = 'Come back tomorrow...'; }
if (btn) { btn.disabled = true; }
if (btn) { btn.disabled = false; }
if (input) { input.focus(); }
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.';
+27 -7
View File
@@ -1086,16 +1086,36 @@ fn handle_request(method: String, path: String, body: String) -> String {
if str_eq(msg, "") {
return "{\"error\":\"message required\"}"
}
// Rate limit: 25 requests per uid per hour (stored in process state)
// Rate limit: 10 chats per uid per day (UTC day, keyed by uid).
// State key: "__rl_<uid>" "<count>|<day_number>"
// day_number = unix_timestamp / 86400 (integer UTC day)
// 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")
if !str_eq(rate_uid, "") {
let rate_key: String = "__rate__" + rate_uid
let rate_val: String = state_get(rate_key)
let rate_count: Int = if str_eq(rate_val, "") { 0 } else { str_to_int(rate_val) }
if rate_count >= 25 {
return "{\"response\":\"You've hit the rate limit. Come back in an hour.\"}"
let now_ts: Int = unix_timestamp()
let today_day: Int = now_ts / 86400
let next_reset: Int = (today_day + 1) * 86400
let rl_key: String = "__rl_" + rate_uid
let rl_val: String = state_get(rl_key)
let rl_count: Int = 0
let rl_day: Int = 0
if !str_eq(rl_val, "") {
// format: "count|day"
let parts: [String] = str_split(rl_val, "|")
if native_list_len(parts) >= 2 {
let rl_count = str_to_int(native_list_get(parts, 0))
let rl_day = str_to_int(native_list_get(parts, 1))
}
}
state_set(rate_key, int_to_str(rate_count + 1))
// Reset count if it's a new day
if rl_day != today_day {
let rl_count = 0
}
if rl_count >= 10 {
return "{\"rate_limited\":true,\"reset_at\":" + int_to_str(next_reset) + "}"
}
state_set(rl_key, int_to_str(rl_count + 1) + "|" + int_to_str(today_day))
}
// Turnstile: verify on first message only (tokens are single-use).
// Per-message verification breaks chat flow. Forms get full verification.