From c72127032e5d74bb5dced9595b2861f325e37a33 Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Tue, 12 May 2026 12:22:59 -0500 Subject: [PATCH 1/2] Fix initStripe load order, subscription webhook email extraction, chat textarea UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - checkout.el: swap stripe_el_script before auth_script so initStripe is defined when Supabase auth fires onAuthStateChange on page load - main.el: fix Stripe webhook email extraction for checkout.session.completed (subscription) events — customer_details is nested at data.object level, not at root; previous code only worked for payment_intent.succeeded - page_close.c: replace with ")); _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 8682d15..cf0eb03 100644 --- a/dist/page_css.c +++ b/dist/page_css.c @@ -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" diff --git a/src/checkout.el b/src/checkout.el index b5a4e5e..9b3b37f 100644 --- a/src/checkout.el +++ b/src/checkout.el @@ -341,7 +341,7 @@ 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 { diff --git a/src/js/chat-widget.el b/src/js/chat-widget.el index 6a345fb..2916356 100644 --- a/src/js/chat-widget.el +++ b/src/js/chat-widget.el @@ -497,6 +497,7 @@ fn main() -> Void { return; } input.value = ''; + input.style.height = 'auto'; btn.disabled = true; addMsg('user', msg); @@ -617,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'; + }); } })()") } diff --git a/src/main.el b/src/main.el index 59374c0..b25bf4d 100644 --- a/src/main.el +++ b/src/main.el @@ -1548,13 +1548,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" -- 2.52.0 From 99ed8b85f702fbe13d73d123ad4de12fc9de1dad Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Tue, 12 May 2026 12:31:45 -0500 Subject: [PATCH 2/2] Fix webhook failing to update plan for pre-existing Supabase users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit supabase_admin_invite re-sends a magic link for users who already have an account (e.g. signed up via attestation before paying) but does not touch their user_metadata — leaving plan as "free" after purchase. Fix: add supabase_admin_update_user (PUT /auth/v1/admin/users/{id}) and call it after every invite so user_metadata is always stamped with the correct plan, name, and stripe_customer_id. Idempotent for new and returning users. Also fix waitlist_upsert to use on_conflict=email,plan so the upsert works for users who already have a waitlist row from attestation, rather than silently failing on duplicate key. --- dist/web_stubs.c | 49 ++++++++++++++++++++++++++++++++++++++++++++++++ src/main.el | 19 +++++++++++++++++-- 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/dist/web_stubs.c b/dist/web_stubs.c index a78c371..b1365a8 100644 --- a/dist/web_stubs.c +++ b/dist/web_stubs.c @@ -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. * diff --git a/src/main.el b/src/main.el index b25bf4d..a9511a9 100644 --- a/src/main.el +++ b/src/main.el @@ -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 "" } @@ -1620,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 -- 2.52.0