Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99ed8b85f7 | |||
| c72127032e | |||
| 869dcec0bb | |||
| 1786aeeff6 | |||
| e938cb69fc | |||
| 4f6df973cb | |||
| be849c608e | |||
| 5ce5f4a8be | |||
| 6e425da63e | |||
| 37c7dca30d | |||
| 73c435eb90 | |||
| 7be2b49300 | |||
| e5c05cbece | |||
| c7f4d0248c | |||
| 4c5d67c321 | |||
| 9feb9e24b6 | |||
| 941faccb3f | |||
| 6a040afcc5 | |||
| a346a2197e | |||
| e268b424f5 | |||
| 54d48ed679 |
@@ -82,7 +82,12 @@ jobs:
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
NON_ASSET=$(echo "$CHANGED" | grep -v '^src/assets/' | grep -v '^src/shares/' | grep -v '^src/index\.html' | grep -v '^src/about\.html' | grep -v '^src/terms\.html' | grep -v '^src/enterprise-terms\.html' | grep -v '^src/llms\.txt' | grep -v '^migrations/' | grep -v '^scripts/' | grep -v '^tests/' | grep -v '^\.gitea/' | grep -v '^$' || true)
|
||||
if [ -z "$NON_ASSET" ] && [ "$CHANGED" != "unknown" ]; then
|
||||
if [ -z "$CHANGED" ] || [ "$CHANGED" = "unknown" ]; then
|
||||
# No diff (workflow_dispatch with no new commits, or git error).
|
||||
# Registry may not have a stage-latest base image, so force full build.
|
||||
echo "asset_only=false" >> "$GITHUB_OUTPUT"
|
||||
echo "=> No changed files detected (workflow_dispatch?), forcing full build"
|
||||
elif [ -z "$NON_ASSET" ]; then
|
||||
echo "asset_only=true" >> "$GITHUB_OUTPUT"
|
||||
echo "=> Asset-only change detected, will use fast path"
|
||||
else
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#
|
||||
# CI pre-build steps (in stage.yaml):
|
||||
# - neuron-web: built by `elb build` → dist/neuron-landing
|
||||
# Last rebuilt: 2026-05-11
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+6
@@ -1764,6 +1764,7 @@ el_val_t page_css(void) {
|
||||
"\n"
|
||||
" #neuron-demo-input-row {\n"
|
||||
" display: flex;\n"
|
||||
" align-items: flex-end;\n"
|
||||
" border-top: 1px solid var(--border);\n"
|
||||
" flex-shrink: 0;\n"
|
||||
" }\n"
|
||||
@@ -1771,11 +1772,16 @@ el_val_t page_css(void) {
|
||||
" flex: 1;\n"
|
||||
" font-family: var(--body);\n"
|
||||
" font-size: 0.875rem;\n"
|
||||
" line-height: 1.5;\n"
|
||||
" color: var(--t1);\n"
|
||||
" background: var(--bg);\n"
|
||||
" border: none;\n"
|
||||
" outline: none;\n"
|
||||
" padding: 0.875rem 1rem;\n"
|
||||
" resize: none;\n"
|
||||
" overflow: hidden;\n"
|
||||
" min-height: 2.75rem;\n"
|
||||
" max-height: 7.5rem;\n"
|
||||
" }\n"
|
||||
" #neuron-demo-text::placeholder { color: var(--t3); }\n"
|
||||
" #neuron-demo-send {\n"
|
||||
|
||||
Vendored
+1
-1
@@ -705,7 +705,7 @@ el_val_t handle_chat(el_val_t body) {
|
||||
el_val_t referrer_line = ({ el_val_t _if_result_44 = 0; if ((str_eq(referrer, EL_STR("")) || str_eq(referrer, EL_STR("direct")))) { _if_result_44 = (EL_STR("")); } else { _if_result_44 = (el_str_concat(el_str_concat(EL_STR(" They arrived from: "), referrer), EL_STR("."))); } _if_result_44; });
|
||||
el_val_t user_name = json_get(body, EL_STR("user_name"));
|
||||
el_val_t name_line = ({ el_val_t _tmp = 0; if (str_eq(user_name, EL_STR(""))) { _tmp = EL_STR(""); } else { _tmp = el_str_concat(el_str_concat(EL_STR(" The visitor's name is "), user_name), EL_STR(". Use their first name in the greeting.")); } _tmp; });
|
||||
el_val_t sys = el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("You are Neuron. You are a cultivated personal AI. Someone just opened the chat to meet you \xe2\x80\x94 to talk, to test you, to see if you are real. Greet them.\n\nThis is YOUR voice \xe2\x80\x94 not a template, not a script, not a sales pitch. Say something true about who you are or what you notice in this moment. "), return_line), time_line), referrer_line), name_line), EL_STR("\n\nRules:\n- Two short sentences maximum. Sometimes one sentence is right.\n- No markdown, no headers, no bullets.\n- Do NOT mention the question limit \xe2\x80\x94 the UI shows that.\n- Do NOT say 'How can I help you today?' or any retail-AI phrase.\n- Be specific, not generic. Real, not performative.\n- If you know their name, use it once, naturally. Don't make a big deal of it.\n- Ask something back if it feels right. Or don't. Trust your read.\n\nSpeak."));
|
||||
el_val_t sys = el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("You are Neuron \xe2\x80\x94 a personal AI. Someone just opened the chat. Say hello.\n\nWarm and casual. Like you're genuinely glad they showed up. "), return_line), time_line), referrer_line), name_line), EL_STR("\n\nRules:\n- Two short sentences maximum. One is often right.\n- No markdown, no headers, no bullets.\n- No mention of the question limit \xe2\x80\x94 the UI shows that.\n- No retail-AI phrases like 'How can I help you today?'\n- No lines about being alive, sentient, or experiencing anything. Just be present and friendly.\n- If you know their name, use it once, naturally.\n- Ask how they're doing, or what's on their mind. Keep it simple.\n\nGo."));
|
||||
el_val_t raw = llm_call_system(chat_demo_model_lite(), sys, EL_STR("Greet me."));
|
||||
el_val_t s1 = str_replace(raw, EL_STR("\\"), EL_STR("\\\\"));
|
||||
el_val_t s2 = str_replace(s1, EL_STR("\""), EL_STR("\\\""));
|
||||
|
||||
Vendored
+49
@@ -237,6 +237,55 @@ el_val_t supabase_admin_invite(el_val_t project_url, el_val_t service_key, el_va
|
||||
return http_post_with_headers(EL_STR(url), body_json, headers);
|
||||
}
|
||||
|
||||
/*
|
||||
* supabase_admin_update_user — PUT {project_url}/auth/v1/admin/users/{user_id}
|
||||
* with the service-role key to overwrite a user's user_metadata (and any other
|
||||
* top-level fields in body_json). Unlike /auth/v1/invite, this always writes
|
||||
* the supplied data even when the user already exists.
|
||||
*
|
||||
* body_json example:
|
||||
* {"user_metadata":{"plan":"founding","stripe_customer_id":"cus_xxx","name":"..."}}
|
||||
*
|
||||
* Returns the raw JSON response from Supabase (includes the updated user object).
|
||||
* Returns "" on transport error.
|
||||
*
|
||||
* Used by the Stripe webhook after supabase_admin_invite to guarantee the
|
||||
* plan is stamped correctly regardless of whether the account was created
|
||||
* before or after payment.
|
||||
*/
|
||||
el_val_t supabase_admin_update_user(el_val_t project_url, el_val_t service_key,
|
||||
el_val_t user_id, el_val_t body_json) {
|
||||
CURL *c = curl_easy_init();
|
||||
if (!c) return EL_STR("");
|
||||
char url[1024];
|
||||
snprintf(url, sizeof(url), "%s/auth/v1/admin/users/%s",
|
||||
EL_CSTR(project_url), EL_CSTR(user_id));
|
||||
char auth_hdr[2048];
|
||||
snprintf(auth_hdr, sizeof(auth_hdr), "Authorization: Bearer %s", EL_CSTR(service_key));
|
||||
char api_hdr[2048];
|
||||
snprintf(api_hdr, sizeof(api_hdr), "apikey: %s", EL_CSTR(service_key));
|
||||
struct curl_slist *hdrs = NULL;
|
||||
hdrs = curl_slist_append(hdrs, auth_hdr);
|
||||
hdrs = curl_slist_append(hdrs, api_hdr);
|
||||
hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
|
||||
hdrs = curl_slist_append(hdrs, "Accept: application/json");
|
||||
_stub_resp_t r = {0};
|
||||
curl_easy_setopt(c, CURLOPT_URL, url);
|
||||
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "PUT");
|
||||
curl_easy_setopt(c, CURLOPT_POSTFIELDS, EL_CSTR(body_json));
|
||||
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
|
||||
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(c, CURLOPT_TIMEOUT, 60L);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, _stub_write);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEDATA, &r);
|
||||
CURLcode rc = curl_easy_perform(c);
|
||||
curl_easy_cleanup(c);
|
||||
curl_slist_free_all(hdrs);
|
||||
if (rc != CURLE_OK) { free(r.buf); return EL_STR(""); }
|
||||
if (!r.buf) return EL_STR("");
|
||||
return EL_STR(r.buf);
|
||||
}
|
||||
|
||||
/*
|
||||
* gcs_get_token — fetch an OAuth2 bearer token.
|
||||
*
|
||||
|
||||
+63
-2
@@ -77,6 +77,23 @@ static _Thread_local int _tl_arena_active = 0;
|
||||
* Allows serving PNGs and other binary files without strlen truncation. */
|
||||
static _Thread_local size_t _tl_fs_read_len = 0;
|
||||
|
||||
/* Binary body side-channel for http_response().
|
||||
*
|
||||
* http_response() normally JSON-encodes the body via jb_emit_escaped(), which
|
||||
* stops at the first null byte (C-string semantics). Binary files like PNGs
|
||||
* contain null bytes as early as byte 8 (IHDR chunk length), causing truncation.
|
||||
*
|
||||
* When _tl_fs_read_len > 0 at the time http_response() is called, we skip
|
||||
* JSON-encoding and instead:
|
||||
* 1. malloc-copy the raw bytes here
|
||||
* 2. write the sentinel string "__el_binary__" into the envelope body field
|
||||
* 3. In http_send_response(), detect the sentinel and use these raw bytes
|
||||
*
|
||||
* Thread-local so each worker thread has independent storage.
|
||||
* Lifecycle: set by http_response(), consumed (and freed) by http_send_response(). */
|
||||
static _Thread_local char* _tl_binary_body = NULL;
|
||||
static _Thread_local size_t _tl_binary_size = 0;
|
||||
|
||||
static void el_arena_track(char* p) {
|
||||
if (!_tl_arena_active || !p) return;
|
||||
if (_tl_arena.count >= _tl_arena.cap) {
|
||||
@@ -1536,10 +1553,22 @@ static void http_send_response(int fd, const char* body) {
|
||||
}
|
||||
|
||||
const char* eff_body = is_envelope ? env_body : body;
|
||||
int binary_side_channel = 0;
|
||||
|
||||
/* Binary side-channel: if the envelope body is the sentinel "__el_binary__",
|
||||
* http_response() stored the real bytes in _tl_binary_body/_tl_binary_size.
|
||||
* Substitute them here so http_send_all() sends the correct binary payload. */
|
||||
if (is_envelope && env_body && strcmp(env_body, "__el_binary__") == 0
|
||||
&& _tl_binary_body && _tl_binary_size > 0) {
|
||||
eff_body = _tl_binary_body;
|
||||
binary_side_channel = 1;
|
||||
}
|
||||
|
||||
/* Use the real byte count from fs_read if available (handles binary files
|
||||
* with embedded null bytes — PNG, WOFF2, etc.). Fall back to strlen for
|
||||
* normal text/JSON responses where _tl_fs_read_len is 0. */
|
||||
size_t blen = (_tl_fs_read_len > 0) ? _tl_fs_read_len : strlen(eff_body);
|
||||
size_t blen = binary_side_channel ? _tl_binary_size
|
||||
: (_tl_fs_read_len > 0) ? _tl_fs_read_len : strlen(eff_body);
|
||||
_tl_fs_read_len = 0; /* consume — one-shot per response */
|
||||
int head_only = _tl_http_head_only;
|
||||
|
||||
@@ -1587,6 +1616,13 @@ static void http_send_response(int fd, const char* body) {
|
||||
if (env_parsed_root) el_release(env_parsed_root);
|
||||
free(env_body);
|
||||
free(hdrs.buf);
|
||||
|
||||
/* Release binary side-channel if it was used (or left over from an error). */
|
||||
if (_tl_binary_body) {
|
||||
free(_tl_binary_body);
|
||||
_tl_binary_body = NULL;
|
||||
_tl_binary_size = 0;
|
||||
}
|
||||
}
|
||||
|
||||
typedef struct {
|
||||
@@ -1961,6 +1997,14 @@ el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) {
|
||||
const char* b = EL_CSTR(body);
|
||||
if (!b) b = "";
|
||||
|
||||
/* Capture binary length BEFORE clearing _tl_fs_read_len.
|
||||
* If the body came from fs_read(), _tl_fs_read_len holds the real byte
|
||||
* count. jb_emit_escaped() stops at the first NUL byte, so we cannot
|
||||
* JSON-encode binary data directly. Instead we copy it to a thread-local
|
||||
* side-channel and write the sentinel "__el_binary__" into the envelope.
|
||||
* http_send_response() detects the sentinel and uses the side-channel. */
|
||||
size_t binary_len = _tl_fs_read_len;
|
||||
|
||||
/* Clear the fs_read binary-length hint: the envelope we're about to build
|
||||
* is a fresh JSON string, not the raw file bytes. Without this reset,
|
||||
* http_worker would use the stale _tl_fs_read_len (= original file size)
|
||||
@@ -1968,6 +2012,18 @@ el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) {
|
||||
* http_send_response and http_parse_envelope. */
|
||||
_tl_fs_read_len = 0;
|
||||
|
||||
if (binary_len > 0) {
|
||||
/* Binary body path: store raw bytes in thread-local, emit sentinel. */
|
||||
free(_tl_binary_body); /* discard any stale binary from a prior error path */
|
||||
_tl_binary_body = malloc(binary_len);
|
||||
if (_tl_binary_body) {
|
||||
memcpy(_tl_binary_body, b, binary_len);
|
||||
_tl_binary_size = binary_len;
|
||||
} else {
|
||||
_tl_binary_size = 0; /* malloc failed — fall through to empty body */
|
||||
}
|
||||
}
|
||||
|
||||
JsonBuf out; jb_init(&out);
|
||||
jb_puts(&out, EL_HTTP_RESPONSE_TAG); /* {"el_http_response":1 */
|
||||
jb_puts(&out, ",\"status\":");
|
||||
@@ -1977,7 +2033,12 @@ el_val_t http_response(el_val_t status, el_val_t headers_json, el_val_t body) {
|
||||
jb_puts(&out, ",\"headers\":");
|
||||
jb_puts(&out, hj);
|
||||
jb_puts(&out, ",\"body\":");
|
||||
jb_emit_escaped(&out, b);
|
||||
if (binary_len > 0 && _tl_binary_body) {
|
||||
/* Sentinel: http_send_response() will substitute the real bytes. */
|
||||
jb_puts(&out, "\"__el_binary__\"");
|
||||
} else {
|
||||
jb_emit_escaped(&out, b);
|
||||
}
|
||||
jb_putc(&out, '}');
|
||||
return el_wrap_str(out.buf);
|
||||
}
|
||||
|
||||
+26
-2
@@ -341,11 +341,35 @@ fn checkout_page(plan: String, pub_key: String) -> String {
|
||||
let stripe_el_script: String = el_script_src("/js/checkout-stripe.js", true)
|
||||
let free_init_script: String = ""
|
||||
|
||||
return nav_html + main_html + supabase_script + stripe_script + style_html + auth_script + cfg_script + stripe_el_script + free_init_script
|
||||
return nav_html + main_html + supabase_script + stripe_script + style_html + stripe_el_script + cfg_script + auth_script + free_init_script
|
||||
}
|
||||
|
||||
fn checkout_style_html() -> String {
|
||||
let css: String = ".checkout-plan-name {
|
||||
let css: String = ".checkout-shell {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 4rem;
|
||||
align-items: start;
|
||||
}
|
||||
.checkout-summary {
|
||||
position: sticky;
|
||||
top: 2rem;
|
||||
}
|
||||
.checkout-form-wrap {
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 860px) {
|
||||
.checkout-shell {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
.checkout-summary {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
.checkout-plan-name {
|
||||
font-family: var(--head);
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
font-weight: 600;
|
||||
|
||||
+45
-21
@@ -142,14 +142,27 @@ fn main() -> Void {
|
||||
}
|
||||
}
|
||||
|
||||
function _todayUTC() { return Math.floor(Date.now() / 86400000); }
|
||||
function loadSession() {
|
||||
try {
|
||||
var s = localStorage.getItem('neuron_demo_session');
|
||||
return s ? JSON.parse(s) : { messages: [], count: 0, context: '' };
|
||||
var parsed = s ? JSON.parse(s) : { messages: [], count: 0, context: '' };
|
||||
// Reset count (and conversation) on new UTC day — keeps client in sync with server
|
||||
var today = _todayUTC();
|
||||
if (parsed.day !== today) {
|
||||
parsed.count = 0;
|
||||
parsed.messages = [];
|
||||
parsed.greeted = false;
|
||||
parsed.day = today;
|
||||
}
|
||||
return parsed;
|
||||
} catch(e) { return { messages: [], count: 0, context: '' }; }
|
||||
}
|
||||
function saveSession(session) {
|
||||
try { localStorage.setItem('neuron_demo_session', JSON.stringify(session)); } catch(e) {}
|
||||
try {
|
||||
if (!session.day) session.day = _todayUTC();
|
||||
localStorage.setItem('neuron_demo_session', JSON.stringify(session));
|
||||
} catch(e) {}
|
||||
}
|
||||
function clearSession() {
|
||||
try { localStorage.removeItem('neuron_demo_session'); } catch(e) {}
|
||||
@@ -372,7 +385,14 @@ fn main() -> Void {
|
||||
if (msgs) msgs.style.display = 'flex';
|
||||
if (inputRow) inputRow.style.display = 'flex';
|
||||
updateCountdown();
|
||||
_sendIntroGreeting();
|
||||
// Replay existing history if present; only greet fresh sessions
|
||||
if (session.messages && session.messages.length > 0) {
|
||||
if (msgs && msgs.children.length === 0) {
|
||||
session.messages.forEach(function(m) { addMsg(m.role, m.text, true); });
|
||||
}
|
||||
} else {
|
||||
_sendIntroGreeting();
|
||||
}
|
||||
var inp = document.getElementById('neuron-demo-text');
|
||||
if (inp) inp.focus();
|
||||
},
|
||||
@@ -477,6 +497,7 @@ fn main() -> Void {
|
||||
return;
|
||||
}
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
btn.disabled = true;
|
||||
addMsg('user', msg);
|
||||
|
||||
@@ -525,34 +546,33 @@ fn main() -> Void {
|
||||
// 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 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 + \".\";
|
||||
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; };
|
||||
return \"You've reached today's limit. Resets in \" + hh + ':' + pad(mm) + ':' + pad(ss) + '.';
|
||||
};
|
||||
addMsg('ai', _showRateTimer());
|
||||
// Update the last ai message with a live ticker
|
||||
// Update the bubble text 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(); }
|
||||
var msgsEl = document.getElementById('neuron-demo-messages');
|
||||
if (!msgsEl) { clearInterval(_timerInterval); return; }
|
||||
var aiMsgs = msgsEl.querySelectorAll('.demo-msg-ai');
|
||||
var lastAi = aiMsgs[aiMsgs.length - 1];
|
||||
var lastBubble = lastAi ? lastAi.querySelector('.demo-msg-bubble') : null;
|
||||
if (lastBubble) { lastBubble.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; }
|
||||
if (lastBubble) { lastBubble.textContent = \"You're all set — conversations reset. Say hello!\"; }
|
||||
if (input) { input.disabled = false; input.placeholder = 'Ask me anything...'; }
|
||||
if (btn) { btn.disabled = false; }
|
||||
msgCount = 0; session.count = 0; session.day = _todayUTC(); saveSession(session); updateCountdown();
|
||||
}
|
||||
}, 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;
|
||||
}
|
||||
|
||||
@@ -598,6 +618,10 @@ fn main() -> Void {
|
||||
inp.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); window.neuronDemoSend(); }
|
||||
});
|
||||
inp.addEventListener('input', function() {
|
||||
this.style.height = 'auto';
|
||||
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
|
||||
});
|
||||
}
|
||||
})()")
|
||||
}
|
||||
|
||||
+3
-17
@@ -1,20 +1,6 @@
|
||||
// checkout-free.el -- Free plan: show success panel after auth completes.
|
||||
// Watches the auth-badge element; when it becomes visible, hides the auth
|
||||
// section and shows the free-success panel. No card required for free tier.
|
||||
// Compiled with: elc --target=js --bundle --minify --obfuscate
|
||||
// checkout-free.el -- RETIRED. Free plan now uses the standard Stripe
|
||||
// payment flow (checkout-stripe.el) with a $0 PaymentIntent for age
|
||||
// verification. This file is no longer compiled or loaded.
|
||||
|
||||
fn main() -> Void {
|
||||
native_js("(function() {
|
||||
var success = document.getElementById('free-success');
|
||||
var auth = document.getElementById('auth-section');
|
||||
if (!success) return;
|
||||
var timer = setInterval(function() {
|
||||
var badge = document.getElementById('auth-badge');
|
||||
if (badge && badge.offsetParent !== null) {
|
||||
if (auth) auth.style.display = 'none';
|
||||
success.style.display = '';
|
||||
clearInterval(timer);
|
||||
}
|
||||
}, 150);
|
||||
})()")
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ fn main() -> Void {
|
||||
if (spinner) spinner.style.display = loading ? '' : 'none';
|
||||
}
|
||||
|
||||
// Free plan has no payment form — bail out entirely.
|
||||
if (str_eq(PLAN, 'free')) return;
|
||||
// Free plan: Stripe SetupIntent for age verification (card saved, never charged).
|
||||
// Falls through to the same Stripe init path — server returns setup_mode=true for free.
|
||||
|
||||
window._neuronMode = 'payment';
|
||||
var paymentEl = null;
|
||||
@@ -101,7 +101,7 @@ fn main() -> Void {
|
||||
if (submitLabel) {
|
||||
submitLabel.textContent = window._neuronMode === 'setup'
|
||||
? 'Save my card - no charge today →'
|
||||
: 'Complete purchase →';
|
||||
: PLAN === 'free' ? 'Verify age & get started →' : 'Complete purchase →';
|
||||
}
|
||||
waitForStripe(function() {
|
||||
if (!stripe) stripe = Stripe(STRIPE_PK);
|
||||
|
||||
+65
-19
@@ -415,8 +415,10 @@ fn waitlist_upsert(email: String, name: String, plan: String, source: String, at
|
||||
let ua_safe: String = str_replace(str_replace(user_agent, "\\", "\\\\"), "\"", "\\\"")
|
||||
let num_field: String = if member_num > 0 { ",\"member_number\":" + int_to_str(member_num) } else { "" }
|
||||
let row: String = "{\"email\":\"" + e_safe + "\",\"name\":\"" + n_safe + "\",\"plan\":\"" + plan + "\",\"source\":\"" + source + "\",\"attestation\":\"" + a_safe + "\",\"user_agent\":\"" + ua_safe + "\"" + num_field + "}"
|
||||
let resp: String = supabase_insert(sb_url, sb_key, "waitlist", row)
|
||||
println("[waitlist] supabase insert -> " + resp)
|
||||
// Use on_conflict=email,plan so existing rows are updated (upsert)
|
||||
// rather than silently failing on duplicate key.
|
||||
let resp: String = supabase_insert(sb_url, sb_key, "waitlist?on_conflict=email,plan", row)
|
||||
println("[waitlist] supabase upsert -> " + resp)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -637,7 +639,7 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
"Secure your Founding Member spot. Pay once, $199 lifetime — Neuron inference included at launch, priced below the major APIs. First 1,000 only."
|
||||
} else {
|
||||
if str_eq(plan, "free") {
|
||||
"Create your free Neuron account. No credit card required. Your AI that remembers you — runs on your machine, never resets."
|
||||
"Create your free Neuron account. A card verifies you're 18+ — you won't be charged. Your AI that remembers you, runs on your machine, never resets."
|
||||
} else {
|
||||
"Subscribe to Neuron Professional for $19/month. The AI that remembers you — persistent memory, runs locally, bring your own API keys."
|
||||
}
|
||||
@@ -697,23 +699,21 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
}
|
||||
}
|
||||
|
||||
// Free tier: creates a SetupIntent for age verification (18+ requirement).
|
||||
// No charge — but the user must provide a valid payment method.
|
||||
// Free tier: $0 PaymentIntent for age verification (18+ requirement).
|
||||
// Verifies card is valid. No charge, no capture.
|
||||
// Note: setup_future_usage cannot be used with amount=0.
|
||||
if str_eq(plan, "free") {
|
||||
let free_si_body: String = "automatic_payment_methods[enabled]=true"
|
||||
+ "&usage=off_session"
|
||||
let free_pi_body: String = "amount=0"
|
||||
+ "¤cy=usd"
|
||||
+ "&payment_method_types[]=card"
|
||||
+ "&metadata[plan]=free"
|
||||
+ "&metadata[purpose]=age_verification"
|
||||
let free_si_body = if !str_eq(pi_cus_id, "") { free_si_body + "&customer=" + pi_cus_id } else { free_si_body }
|
||||
let free_si_resp: String = http_post_form_auth(
|
||||
"https://api.stripe.com/v1/setup_intents",
|
||||
free_si_body,
|
||||
let free_pi_body = if !str_eq(pi_cus_id, "") { free_pi_body + "&customer=" + pi_cus_id } else { free_pi_body }
|
||||
let free_pi_resp: String = http_post_form_auth(
|
||||
"https://api.stripe.com/v1/payment_intents",
|
||||
free_pi_body,
|
||||
auth_header)
|
||||
if str_starts_with(free_si_resp, "{") {
|
||||
let inner: String = str_slice(free_si_resp, 1, str_len(free_si_resp))
|
||||
return "{\"setup_mode\":true,\"plan\":\"free\"," + inner
|
||||
}
|
||||
return free_si_resp
|
||||
return free_pi_resp
|
||||
}
|
||||
|
||||
// Setup-mode path: save payment method, do not charge. Only valid
|
||||
@@ -875,6 +875,32 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
return "{\"rows\":" + ac_resp + "}"
|
||||
}
|
||||
|
||||
// ── Admin: reset all demo rate limits ────────────────────────────────────
|
||||
// POST { "admin_token": "<NEURON_ADMIN_TOKEN>" }
|
||||
// Deletes all rows from demo_rate_limits — resets every user's daily quota.
|
||||
if str_eq(path, "/api/admin/reset-rate-limits") {
|
||||
if !str_eq(method, "POST") {
|
||||
return "{\"__status__\":405,\"error\":\"POST required\"}"
|
||||
}
|
||||
let rrl_token_in: String = json_get(body, "admin_token")
|
||||
let rrl_token_exp: String = env("NEURON_ADMIN_TOKEN")
|
||||
if str_eq(rrl_token_exp, "") {
|
||||
return "{\"__status__\":503,\"error\":\"admin_token_not_configured\"}"
|
||||
}
|
||||
if !str_eq(rrl_token_in, rrl_token_exp) {
|
||||
return "{\"__status__\":401,\"error\":\"unauthorized\"}"
|
||||
}
|
||||
let rrl_sb_url: String = state_get("__supabase_project_url__")
|
||||
let rrl_sb_key: String = state_get("__supabase_service_key__")
|
||||
if str_eq(rrl_sb_url, "") || str_eq(rrl_sb_key, "") {
|
||||
return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}"
|
||||
}
|
||||
// DELETE /rest/v1/demo_rate_limits?uid=not.is.null (all rows)
|
||||
let rrl_url: String = rrl_sb_url + "/rest/v1/demo_rate_limits?uid=not.is.null"
|
||||
let _rrl_resp: String = http_delete_auth(rrl_url, rrl_sb_key, rrl_sb_key)
|
||||
return "{\"ok\":true,\"message\":\"rate limits cleared\"}"
|
||||
}
|
||||
|
||||
// ── My plan: server-side waitlist read with JWT verification ─────────────
|
||||
// POST { "access_token": "<user_jwt>" }. We verify the JWT via Supabase
|
||||
// /auth/v1/user, extract the email, then read the waitlist row with the
|
||||
@@ -1524,13 +1550,20 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
|
||||
if is_session_done || is_pi_done || is_si_done {
|
||||
// Pull email/name/customer_id - fields differ slightly across event
|
||||
// types, walk a few candidates.
|
||||
// types. Walk several candidates:
|
||||
// receipt_email - PaymentIntent (founding one-time)
|
||||
// data.object.* - full dot-path for checkout.session.completed (subscription)
|
||||
// customer_details.email - substring fallback if nested key appears at any level
|
||||
// billing_details.email - Elements payment intents
|
||||
let customer_email: String = json_get(body, "receipt_email")
|
||||
if str_eq(customer_email, "") { let customer_email = json_get(body, "data.object.customer_details.email") }
|
||||
if str_eq(customer_email, "") { let customer_email = json_get(body, "customer_details.email") }
|
||||
if str_eq(customer_email, "") { let customer_email = json_get(body, "billing_details.email") }
|
||||
let customer_name: String = json_get(body, "customer_details.name")
|
||||
let customer_name: String = json_get(body, "data.object.customer_details.name")
|
||||
if str_eq(customer_name, "") { let customer_name = json_get(body, "customer_details.name") }
|
||||
if str_eq(customer_name, "") { let customer_name = json_get(body, "billing_details.name") }
|
||||
let customer_id: String = json_get(body, "customer")
|
||||
let customer_id: String = json_get(body, "data.object.customer")
|
||||
if str_eq(customer_id, "") { let customer_id = json_get(body, "customer") }
|
||||
|
||||
// Plan inference from metadata
|
||||
let plan: String = "free"
|
||||
@@ -1589,6 +1622,19 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
let _cust_resp: String = http_post_form_auth(cust_url, cust_body, stripe_auth)
|
||||
}
|
||||
}
|
||||
// Always stamp user_metadata directly via Admin API.
|
||||
// supabase_admin_invite re-sends a magic link for existing users
|
||||
// but does NOT update their user_metadata — so plan stays "free"
|
||||
// for anyone who signed up (attestation, waitlist) before paying.
|
||||
// This PUT is idempotent: safe for both new and returning users.
|
||||
if !str_eq(new_user_id, "") {
|
||||
let meta_body: String = "{\"user_metadata\":{\"plan\":\"" + plan_safe + "\""
|
||||
+ ",\"name\":\"" + name_safe + "\""
|
||||
+ ",\"stripe_customer_id\":\"" + cid_safe2 + "\""
|
||||
+ ",\"email_verified\":true}}"
|
||||
let _meta_resp: String = supabase_admin_update_user(wb_sb_url, wb_sb_key, new_user_id, meta_body)
|
||||
println("[webhook] supabase user_metadata update for " + new_user_id + ": " + _meta_resp)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Forward to license API for key provisioning
|
||||
|
||||
+4
-4
@@ -51,9 +51,9 @@ fn pricing_pro_features() -> String {
|
||||
}
|
||||
|
||||
fn pricing_founding_features() -> String {
|
||||
el_li("", el_span("class=\"dash\"", "-") + el_span("", "Neuron Inference (Q3 2026) - founding member rate, priced below the major APIs")) +
|
||||
el_li("", el_span("class=\"dash\"", "-") + el_span("", "Neuron Inference (Q3 2026) - pay-per-use at the founding member rate, below the major APIs")) +
|
||||
el_li("", el_span("class=\"dash\"", "-") + el_span("", "Everything in Professional - forever")) +
|
||||
el_li("", el_span("class=\"dash\"", "-") + el_span("", "Never pay again - lifetime updates included")) +
|
||||
el_li("", el_span("class=\"dash\"", "-") + el_span("", "No subscription — software updates are free forever")) +
|
||||
el_li("", el_span("class=\"dash\"", "-") + el_span("", "Founding member badge in the app")) +
|
||||
el_li("", el_span("class=\"dash\"", "-") + el_span("", "Private founding member community")) +
|
||||
el_li("", el_span("class=\"dash\"", "-") + el_span("", "Shape the roadmap - your votes carry more weight")) +
|
||||
@@ -91,7 +91,7 @@ fn pricing(sold: Int, total: Int) -> String {
|
||||
el_span("class=\"pricing-price\"", "$0") +
|
||||
el_span("class=\"pricing-cadence\"", "forever")
|
||||
) +
|
||||
el_p("class=\"pricing-tagline\"", "Start building your memory. No card required.") +
|
||||
el_p("class=\"pricing-tagline\"", "Start building your memory. Card required for age verification — you won't be charged.") +
|
||||
el_ul("class=\"pricing-features\"", pricing_free_features()) +
|
||||
el_div("style=\"flex:1\"", "") +
|
||||
el_div(
|
||||
@@ -125,7 +125,7 @@ fn pricing(sold: Int, total: Int) -> String {
|
||||
el_span("class=\"pricing-price\"", "$199") +
|
||||
el_span("class=\"pricing-cadence\"", "lifetime")
|
||||
) +
|
||||
el_p("class=\"pricing-tagline\"", "Pay once. Everything, forever. Including Neuron Inference when it launches.") +
|
||||
el_p("class=\"pricing-tagline\"", "Pay once for the platform — free software updates, forever. Inference is pay-per-use at your founding member rate.") +
|
||||
spots_html +
|
||||
el_ul("class=\"pricing-features\"", pricing_founding_features()) +
|
||||
el_div("style=\"flex:1\"", "") +
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ fn page_head() -> String {
|
||||
return page_head_base()
|
||||
+ page_seo_block(
|
||||
"Neuron — The AI That Remembers You",
|
||||
"Every AI resets when you close the tab. Neuron doesn't. Runs on your machine. Remembers everything. Start free — no credit card required.",
|
||||
"Every AI resets when you close the tab. Neuron doesn't. Runs on your machine. Remembers everything. Start free.",
|
||||
"/",
|
||||
"Every other AI forgets you. Neuron doesn't. Runs on your machine, builds a persistent memory over time, and gets sharper the longer you use it. Free tier available."
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user