Fix initStripe load order, subscription webhook email, chat textarea #134

Merged
will.anderson merged 2 commits from fix/webhook-initstripe-textarea into dev 2026-05-12 17:49:29 +00:00
6 changed files with 89 additions and 7 deletions
+1 -1
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -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"
+49
View File
@@ -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.
*
+1 -1
View File
@@ -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 {
+5
View File
@@ -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';
});
}
})()")
}
+27 -5
View File
@@ -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 ""
}
@@ -1548,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"
@@ -1613,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