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