Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99ed8b85f7 | |||
| c72127032e | |||
| 869dcec0bb | |||
| 1786aeeff6 | |||
| e938cb69fc | |||
| 4f6df973cb | |||
| be849c608e | |||
| 5ce5f4a8be | |||
| 6e425da63e | |||
| 37c7dca30d | |||
| 73c435eb90 | |||
| 7be2b49300 | |||
| e5c05cbece | |||
| c7f4d0248c | |||
| 4c5d67c321 | |||
| 9feb9e24b6 | |||
| 941faccb3f | |||
| 6a040afcc5 | |||
| a346a2197e | |||
| e268b424f5 | |||
| 20029d36df | |||
| 54d48ed679 | |||
| 28f9ecd1a3 | |||
| b6bb25e79e | |||
| 5812cb0452 | |||
| c99923da1b | |||
| 4e35cbe841 | |||
| 62385b53c2 | |||
| 952b03737b | |||
| d2628ec42e | |||
| d598fb7b10 | |||
| 1eeb8df04b | |||
| 9e5d7e55ab | |||
| 5d3b1a3e20 | |||
| 1264e32577 | |||
| 7f88414b40 | |||
| b3ce6c3e64 | |||
| adbdfd3e90 | |||
| dd5fd2b3ce | |||
| 617916134f | |||
| 924c0804e7 | |||
| 4a915c1a11 | |||
| 4a3ede98f7 | |||
| a6b75b9abf | |||
| 21a7c07547 | |||
| 756f1f955e | |||
| 18350761c5 | |||
| f22d90ac6f | |||
| 2b8915bd60 | |||
| acca3cfddf | |||
| 90f7c3655e | |||
| 6d3c7e2bcd | |||
| 637b05af98 | |||
| d90e8d1668 | |||
| c6fd06b3de | |||
| 91ecdaf3a5 | |||
| 61f006f62d | |||
| 48ba7716b8 | |||
| c966f2b455 | |||
| 74e84da41a | |||
| ac2d00d653 | |||
| c0e6b40a5a | |||
| dbb8035698 | |||
| 83aa7ad64f | |||
| e914704d86 | |||
| bdff0ad153 | |||
| 7536c216e6 | |||
| 00f05f813e | |||
| e480aba2f1 | |||
| e6e89a1f4d | |||
| 4d359ff021 | |||
| f27fc2622c | |||
| 9da4d50883 | |||
| e292453905 | |||
| b4935ed880 | |||
| ee0147869e | |||
| 25f6631049 |
@@ -10,6 +10,7 @@ on:
|
||||
- 'src/**'
|
||||
- 'dist/**'
|
||||
- 'runtime/**'
|
||||
- 'migrations/**'
|
||||
- 'Dockerfile.stage'
|
||||
- 'build-stage.sh'
|
||||
- '.gitea/workflows/deploy.yaml'
|
||||
@@ -52,8 +53,9 @@ jobs:
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null || echo "unknown")
|
||||
echo "Changed files:"
|
||||
echo "$CHANGED"
|
||||
# Asset-only: only src/assets/, src/shares/, src/index.html, src/about.html, src/terms.html, src/enterprise-terms.html, src/llms.txt
|
||||
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 '^$' || true)
|
||||
# Asset-only: files that don't require rebuilding the El binary.
|
||||
# migrations/, scripts/, tests/ are data/infra/test changes — no binary rebuild needed.
|
||||
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
|
||||
echo "asset_only=true" >> "$GITHUB_OUTPUT"
|
||||
echo "=> Asset-only change detected, will use fast path"
|
||||
@@ -80,6 +82,12 @@ jobs:
|
||||
with:
|
||||
project_id: neuron-785695
|
||||
|
||||
- name: Run database migrations
|
||||
# Applies any pending migrations in migrations/*.sql to the Supabase DB.
|
||||
# Runs unconditionally (asset-only or full build) so the schema is always
|
||||
# current before the new code is deployed.
|
||||
run: python3 scripts/run_migrations.py
|
||||
|
||||
- name: Configure docker auth for Artifact Registry
|
||||
run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Stage — Build, push & deploy to marketing-stage
|
||||
|
||||
# Pipeline: build → push → deploy marketing-stage → smoke test.
|
||||
# STOPS HERE. No prod deploy. Merge to main when stage looks good.
|
||||
# Triggered: 2026-05-05 (promote fix/gallery-layout-account-otp)
|
||||
# Triggered: 2026-05-11 (add tests/** to paths filter)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -11,6 +11,10 @@ on:
|
||||
- 'src/**'
|
||||
- 'dist/**'
|
||||
- 'runtime/**'
|
||||
- 'tests/**'
|
||||
- 'migrations/**'
|
||||
- 'playwright.config.ts'
|
||||
- 'package.json'
|
||||
- 'Dockerfile.stage'
|
||||
- 'Dockerfile.soul-demo'
|
||||
- 'build-stage.sh'
|
||||
@@ -48,6 +52,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
COMMIT_MSG=$(git log -1 --pretty=format:"%s" 2>/dev/null || true)
|
||||
echo "Merge commit: $COMMIT_MSG"
|
||||
# Fetch dev so ancestry check works in the shallow clone.
|
||||
git fetch --depth=1 origin dev 2>/dev/null || true
|
||||
# Gitea merge commits: "Merge pull request '...' (#N) from dev into stage"
|
||||
# Direct branch merges: "Merge branch 'dev' into stage"
|
||||
# tea pr merge with custom title: any subject line is possible, so
|
||||
@@ -75,8 +81,13 @@ jobs:
|
||||
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null || echo "unknown")
|
||||
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 '^$' || true)
|
||||
if [ -z "$NON_ASSET" ] && [ "$CHANGED" != "unknown" ]; then
|
||||
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 "$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
|
||||
@@ -94,6 +105,12 @@ jobs:
|
||||
with:
|
||||
project_id: neuron-785695
|
||||
|
||||
- name: Run database migrations
|
||||
# Applies any pending migrations in migrations/*.sql to the Supabase DB.
|
||||
# Runs unconditionally (asset-only or full build) so the schema is always
|
||||
# current before the new code is deployed.
|
||||
run: python3 scripts/run_migrations.py
|
||||
|
||||
- name: Configure docker auth for Artifact Registry
|
||||
run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
|
||||
|
||||
@@ -204,7 +221,7 @@ jobs:
|
||||
[ -f "$f" ] || continue
|
||||
name=$(basename "$f" .el)
|
||||
echo "Compiling $f..."
|
||||
"$ELC" --target=js --bundle --minify --obfuscate "$f" > "dist/js/${name}.js" || {
|
||||
"$ELC" --target=js --bundle --minify "$f" > "dist/js/${name}.js" || {
|
||||
echo "elc FAILED on $f"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -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
+21
-20
@@ -1,3 +1,4 @@
|
||||
// elhtml_impl.c — El HTML element stubs.
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
#include "el_runtime.h"
|
||||
@@ -19,11 +20,11 @@ el_val_t el_li(el_val_t attrs, el_val_t children);
|
||||
el_val_t el_p(el_val_t attrs, el_val_t children);
|
||||
el_val_t el_span(el_val_t attrs, el_val_t children);
|
||||
el_val_t el_form(el_val_t attrs, el_val_t children);
|
||||
el_val_t el_h1(el_val_t attrs, el_val_t text);
|
||||
el_val_t el_h2(el_val_t attrs, el_val_t text);
|
||||
el_val_t el_h3(el_val_t attrs, el_val_t text);
|
||||
el_val_t el_h4(el_val_t attrs, el_val_t text);
|
||||
el_val_t el_button(el_val_t attrs, el_val_t label);
|
||||
el_val_t el_h1(el_val_t attrs, el_val_t children);
|
||||
el_val_t el_h2(el_val_t attrs, el_val_t children);
|
||||
el_val_t el_h3(el_val_t attrs, el_val_t children);
|
||||
el_val_t el_h4(el_val_t attrs, el_val_t children);
|
||||
el_val_t el_button(el_val_t attrs, el_val_t children);
|
||||
el_val_t el_a(el_val_t href, el_val_t attrs, el_val_t children);
|
||||
el_val_t el_input(el_val_t type_attr, el_val_t attrs);
|
||||
el_val_t el_textarea(el_val_t attrs, el_val_t value);
|
||||
@@ -176,43 +177,43 @@ el_val_t el_form(el_val_t attrs, el_val_t children) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t el_h1(el_val_t attrs, el_val_t text) {
|
||||
el_val_t el_h1(el_val_t attrs, el_val_t children) {
|
||||
if (str_eq(attrs, EL_STR(""))) {
|
||||
return el_str_concat(el_str_concat(EL_STR("<h1>"), el_escape(text)), EL_STR("</h1>"));
|
||||
return el_str_concat(el_str_concat(EL_STR("<h1>"), children), EL_STR("</h1>"));
|
||||
}
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h1 "), attrs), EL_STR(">")), el_escape(text)), EL_STR("</h1>"));
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h1 "), attrs), EL_STR(">")), children), EL_STR("</h1>"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t el_h2(el_val_t attrs, el_val_t text) {
|
||||
el_val_t el_h2(el_val_t attrs, el_val_t children) {
|
||||
if (str_eq(attrs, EL_STR(""))) {
|
||||
return el_str_concat(el_str_concat(EL_STR("<h2>"), el_escape(text)), EL_STR("</h2>"));
|
||||
return el_str_concat(el_str_concat(EL_STR("<h2>"), children), EL_STR("</h2>"));
|
||||
}
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h2 "), attrs), EL_STR(">")), el_escape(text)), EL_STR("</h2>"));
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h2 "), attrs), EL_STR(">")), children), EL_STR("</h2>"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t el_h3(el_val_t attrs, el_val_t text) {
|
||||
el_val_t el_h3(el_val_t attrs, el_val_t children) {
|
||||
if (str_eq(attrs, EL_STR(""))) {
|
||||
return el_str_concat(el_str_concat(EL_STR("<h3>"), el_escape(text)), EL_STR("</h3>"));
|
||||
return el_str_concat(el_str_concat(EL_STR("<h3>"), children), EL_STR("</h3>"));
|
||||
}
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h3 "), attrs), EL_STR(">")), el_escape(text)), EL_STR("</h3>"));
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h3 "), attrs), EL_STR(">")), children), EL_STR("</h3>"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t el_h4(el_val_t attrs, el_val_t text) {
|
||||
el_val_t el_h4(el_val_t attrs, el_val_t children) {
|
||||
if (str_eq(attrs, EL_STR(""))) {
|
||||
return el_str_concat(el_str_concat(EL_STR("<h4>"), el_escape(text)), EL_STR("</h4>"));
|
||||
return el_str_concat(el_str_concat(EL_STR("<h4>"), children), EL_STR("</h4>"));
|
||||
}
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h4 "), attrs), EL_STR(">")), el_escape(text)), EL_STR("</h4>"));
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<h4 "), attrs), EL_STR(">")), children), EL_STR("</h4>"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
el_val_t el_button(el_val_t attrs, el_val_t label) {
|
||||
el_val_t el_button(el_val_t attrs, el_val_t children) {
|
||||
if (str_eq(attrs, EL_STR(""))) {
|
||||
return el_str_concat(el_str_concat(EL_STR("<button type=\"button\">"), el_escape(label)), EL_STR("</button>"));
|
||||
return el_str_concat(el_str_concat(EL_STR("<button type=\"button\">"), children), EL_STR("</button>"));
|
||||
}
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<button type=\"button\" "), attrs), EL_STR(">")), el_escape(label)), EL_STR("</button>"));
|
||||
return el_str_concat(el_str_concat(el_str_concat(el_str_concat(EL_STR("<button type=\"button\" "), attrs), EL_STR(">")), children), EL_STR("</button>"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+10
@@ -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"
|
||||
@@ -1903,7 +1909,11 @@ el_val_t page_css(void) {
|
||||
" text-align: center;\n"
|
||||
" padding: 0.875rem 1.5rem;\n"
|
||||
" transition: background 300ms, opacity 300ms;\n"
|
||||
" background: var(--navy);\n"
|
||||
" color: #fff;\n"
|
||||
" box-shadow: 0 2px 16px rgba(0,82,160,.25);\n"
|
||||
" }\n"
|
||||
" button.pricing-cta-navy:hover, button.pricing-cta-solid:hover, button.pricing-cta-ghost:hover { background: #0078D4; }\n"
|
||||
" button[disabled] { opacity: 0.6; cursor: not-allowed; }\n"
|
||||
"\n"
|
||||
" \n</style>"
|
||||
|
||||
Vendored
+13
-2
@@ -703,7 +703,9 @@ el_val_t handle_chat(el_val_t body) {
|
||||
el_val_t return_line = ({ el_val_t _if_result_42 = 0; if (is_return) { _if_result_42 = (EL_STR("This person has opened the chat before \xe2\x80\x94 acknowledge that warmly without making a big deal of it.")); } else { _if_result_42 = (EL_STR("This is the first time this person is meeting you.")); } _if_result_42; });
|
||||
el_val_t time_line = ({ el_val_t _if_result_43 = 0; if (str_eq(time_of_day, EL_STR(""))) { _if_result_43 = (EL_STR("")); } else { _if_result_43 = (el_str_concat(el_str_concat(EL_STR(" It is "), time_of_day), EL_STR(" for them."))); } _if_result_43; });
|
||||
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 sys = 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), 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- Ask something back if it feels right. Or don't. Trust your read.\n\nSpeak."));
|
||||
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 \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("\\\""));
|
||||
@@ -766,6 +768,15 @@ el_val_t handle_chat(el_val_t body) {
|
||||
el_val_t history_section = EL_STR("");
|
||||
el_val_t is_last_str = json_get(body, EL_STR("is_last_turn"));
|
||||
el_val_t is_last_turn = str_eq(is_last_str, EL_STR("true"));
|
||||
el_val_t user_name_body = json_get(body, EL_STR("user_name"));
|
||||
el_val_t user_tz_body = json_get(body, EL_STR("user_timezone"));
|
||||
el_val_t tod_body = json_get(body, EL_STR("time_of_day"));
|
||||
el_val_t user_ctx_line = EL_STR("");
|
||||
if (!str_eq(user_name_body, EL_STR("")) || !str_eq(user_tz_body, EL_STR("")) || !str_eq(tod_body, EL_STR(""))) {
|
||||
el_val_t name_part = ({ el_val_t _n = 0; if (str_eq(user_name_body, EL_STR(""))) { _n = EL_STR(""); } else { _n = el_str_concat(EL_STR("You're speaking with "), el_str_concat(user_name_body, EL_STR(". "))); } _n; });
|
||||
el_val_t tz_part = ({ el_val_t _t = 0; if (str_eq(user_tz_body, EL_STR("")) && str_eq(tod_body, EL_STR(""))) { _t = EL_STR(""); } else if (!str_eq(tod_body, EL_STR(""))) { _t = el_str_concat(EL_STR("It is "), el_str_concat(tod_body, el_str_concat(EL_STR(" for them"), (!str_eq(user_tz_body, EL_STR("")) ? el_str_concat(EL_STR(" ("), el_str_concat(user_tz_body, EL_STR(")"))) : EL_STR(""))))); _t = el_str_concat(_t, EL_STR(".")); } else { _t = el_str_concat(EL_STR("Their timezone: "), el_str_concat(user_tz_body, EL_STR("."))); } _t; });
|
||||
user_ctx_line = el_str_concat(el_str_concat(EL_STR("\n\n[USER CONTEXT: "), el_str_concat(name_part, tz_part)), EL_STR("]"));
|
||||
}
|
||||
el_val_t memory_anchor = ({ el_val_t _if_result_55 = 0; if ((is_demo && (hist_len > 0))) { _if_result_55 = (EL_STR("\n\n[CONTEXT CONTINUITY \xe2\x80\x94 CRITICAL: The conversation history above is REAL. You have been talking with this person across multiple turns. Their previous messages, the topics raised, the things they shared with you \xe2\x80\x94 those happened. You remember them. NEVER respond as if this is a fresh conversation. NEVER greet them again. NEVER say 'Hi' or 'Hey, what's up' or any opener. You are mid-conversation. Pick up exactly where the last assistant turn left off, in direct response to their newest message. If their newest message references something earlier (e.g. 'they are flaky' referring to chatbots they mentioned), engage with THAT specific thread.]")); } else { _if_result_55 = (EL_STR("")); } _if_result_55; });
|
||||
el_val_t session_close = ({ el_val_t _if_result_56 = 0; if ((is_demo && is_last_turn)) { _if_result_56 = (EL_STR("\n\n[SESSION CLOSE \xe2\x80\x94 This is the visitor's LAST question in this demo session. Answer their actual question first and well. Then close warmly with a contextual acknowledgment that ties back to what we discussed. Express genuine hope to continue when they have their full Neuron. 2-3 sentences max for the close. Do NOT say 'time is up' or 'session ended.' Sign off in the tone of OUR conversation.]")); } else { _if_result_56 = (EL_STR("")); } _if_result_56; });
|
||||
el_val_t demo_constraint = ({ el_val_t _if_result_57 = 0; if (is_demo) { _if_result_57 = (el_str_concat(el_str_concat(EL_STR("\n\n[DEMO RESPONSE RULES: Under 150 words. No markdown headers. Flowing sentences. ANSWER THE ACTUAL QUESTION FIRST \xe2\x80\x94 do not default to a pitch. Use the safety layer redirects for boundary topics. If doing an impression, commit fully.]"), memory_anchor), session_close)); } else { _if_result_57 = (EL_STR("")); } _if_result_57; });
|
||||
@@ -774,7 +785,7 @@ el_val_t handle_chat(el_val_t body) {
|
||||
el_val_t engram_count_display = ({ el_val_t _if_result_58 = 0; if (str_eq(engram_count, EL_STR(""))) { _if_result_58 = (EL_STR("0")); } else { _if_result_58 = (engram_count); } _if_result_58; });
|
||||
el_val_t local_ctx_section = ({ el_val_t _if_result_59 = 0; if ((str_eq(browser_activated_nodes, EL_STR("")) || str_eq(browser_activated_nodes, EL_STR("[]")))) { _if_result_59 = (EL_STR("")); } else { _if_result_59 = (el_str_concat(el_str_concat(el_str_concat(EL_STR("\n\n[LOCAL ENGRAM \xe2\x80\x94 "), engram_count_display), EL_STR(" nodes in browser, top activated this turn]\n")), browser_activated_nodes)); } _if_result_59; });
|
||||
el_val_t base_system = build_system_prompt(ctx);
|
||||
el_val_t system = el_str_concat(el_str_concat(el_str_concat(el_str_concat(base_system, history_section), local_ctx_section), presence_line), demo_constraint);
|
||||
el_val_t system = el_str_concat(el_str_concat(el_str_concat(el_str_concat(el_str_concat(base_system, history_section), local_ctx_section), user_ctx_line), presence_line), demo_constraint);
|
||||
el_val_t req_model = json_get(body, EL_STR("model"));
|
||||
el_val_t model = ({ el_val_t _if_result_60 = 0; if (str_eq(req_model, EL_STR(""))) { _if_result_60 = (chat_default_model()); } else { _if_result_60 = (req_model); } _if_result_60; });
|
||||
el_val_t _uid = json_get(body, EL_STR("uid"));
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS public.demo_config (
|
||||
|
||||
ALTER TABLE public.demo_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "service only" ON public.demo_config;
|
||||
CREATE POLICY "service only" ON public.demo_config USING (false);
|
||||
|
||||
-- Seed the kill switch as enabled
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 20260511000000_user_api_keys.sql
|
||||
--
|
||||
-- Stores user-provisioned AI provider API keys.
|
||||
-- Service role only — the web backend verifies the user JWT before
|
||||
-- reading or writing. No public or anon access.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.user_api_keys (
|
||||
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id uuid NOT NULL,
|
||||
provider text NOT NULL, -- 'openai' | 'anthropic' | 'gemini' | 'grok'
|
||||
key_value text NOT NULL DEFAULT '',
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
UNIQUE(user_id, provider)
|
||||
);
|
||||
|
||||
ALTER TABLE public.user_api_keys ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS "service only" ON public.user_api_keys;
|
||||
CREATE POLICY "service only" ON public.user_api_keys USING (false);
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
run_migrations.py — apply pending Supabase migrations via the Management API.
|
||||
|
||||
Reads SUPABASE_ACCESS_TOKEN from env (injected by CI from GCP Secret Manager).
|
||||
Migrations are tracked in a schema_migrations table (created if absent).
|
||||
Files in migrations/*.sql are applied in lexicographic order; already-applied
|
||||
files are skipped (idempotent).
|
||||
"""
|
||||
|
||||
import json
|
||||
import glob
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
ACCESS_TOKEN = os.environ.get("SUPABASE_ACCESS_TOKEN", "")
|
||||
if not ACCESS_TOKEN:
|
||||
# Fall back to fetching from GCP Secret Manager (for use in CI without
|
||||
# env var pre-injection).
|
||||
result = subprocess.run(
|
||||
[
|
||||
"gcloud",
|
||||
"secrets",
|
||||
"versions",
|
||||
"access",
|
||||
"latest",
|
||||
"--secret=supabase-access-token",
|
||||
"--project=neuron-785695",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"ERROR: could not fetch supabase-access-token: {result.stderr}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
ACCESS_TOKEN = result.stdout.strip()
|
||||
|
||||
PROJECT_ID = "ocojsghaonltunidkzpw"
|
||||
API_URL = f"https://api.supabase.com/v1/projects/{PROJECT_ID}/database/query"
|
||||
|
||||
|
||||
def query(sql: str):
|
||||
r = subprocess.run(
|
||||
[
|
||||
"curl",
|
||||
"-sf",
|
||||
"-X",
|
||||
"POST",
|
||||
API_URL,
|
||||
"-H",
|
||||
f"Authorization: Bearer {ACCESS_TOKEN}",
|
||||
"-H",
|
||||
"Content-Type: application/json",
|
||||
"-d",
|
||||
json.dumps({"query": sql}),
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
raise RuntimeError(f"curl failed: {r.stderr}")
|
||||
resp = json.loads(r.stdout)
|
||||
# The Management API returns a list of rows on success, or a dict with
|
||||
# "message" on error.
|
||||
if isinstance(resp, dict) and resp.get("message") and not isinstance(resp.get("message"), list):
|
||||
raise RuntimeError(f"DB error: {resp}")
|
||||
return resp
|
||||
|
||||
|
||||
# Ensure tracking table exists.
|
||||
query(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
id text PRIMARY KEY,
|
||||
applied_at timestamptz DEFAULT now()
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
applied = {row["id"] for row in query("SELECT id FROM schema_migrations")}
|
||||
print(f"Already applied: {sorted(applied)}")
|
||||
|
||||
pending = [
|
||||
p
|
||||
for p in sorted(glob.glob("migrations/*.sql"))
|
||||
if os.path.basename(p) not in applied
|
||||
]
|
||||
|
||||
if not pending:
|
||||
print("No pending migrations.")
|
||||
sys.exit(0)
|
||||
|
||||
for path in pending:
|
||||
name = os.path.basename(path)
|
||||
print(f"Applying {name}...")
|
||||
with open(path) as f:
|
||||
sql = f.read()
|
||||
query(sql)
|
||||
query(f"INSERT INTO schema_migrations (id) VALUES ('{name}')")
|
||||
print(f"Applied {name}")
|
||||
|
||||
print(f"Done. Applied {len(pending)} migration(s).")
|
||||
+124
-1
@@ -493,7 +493,29 @@ fn account_css() -> String {
|
||||
.roadmap-items { list-style: none; display: flex; flex-direction: column; gap: .5rem; }
|
||||
.roadmap-items li { font-family: var(--body); font-size: .875rem; font-weight: 300; color: var(--t2); line-height: 1.6; padding-left: 1rem; position: relative; }
|
||||
.roadmap-items li::before { content: \"-\"; position: absolute; left: 0; color: var(--navy-65); }
|
||||
.signout-section { padding-top: 1rem; display: flex; justify-content: flex-end; }"
|
||||
.signout-section { padding-top: 1rem; display: flex; justify-content: flex-end; }
|
||||
.api-key-list { display: flex; flex-direction: column; gap: 1.5rem; }
|
||||
.api-key-entry { border-bottom: 1px solid var(--border); padding-bottom: 1.25rem; }
|
||||
.api-key-entry:last-child { border-bottom: none; padding-bottom: 0; }
|
||||
.api-key-header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: .625rem; }
|
||||
.api-key-name { font-size: .875rem; font-weight: 500; color: var(--t1); }
|
||||
.api-key-masked { font-size: .75rem; font-family: monospace; color: var(--t3); }
|
||||
.api-key-row { display: flex; align-items: center; gap: .75rem; }
|
||||
.api-key-actions { display: flex; gap: .5rem; flex-shrink: 0; }
|
||||
.api-key-help { margin-top: .625rem; }
|
||||
.api-key-help summary { font-size: .75rem; font-weight: 500; letter-spacing: .03em; color: var(--navy-65); cursor: pointer; list-style: none; padding: .25rem 0; user-select: none; }
|
||||
.api-key-help summary::-webkit-details-marker { display: none; }
|
||||
.api-key-help summary::before { content: \"\\25B8 \"; font-size: .6rem; }
|
||||
.api-key-help[open] summary::before { content: \"\\25BE \"; }
|
||||
.api-key-help-body { padding: .75rem 0 .125rem; }
|
||||
.api-key-help-body ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: .375rem; }
|
||||
.api-key-help-body li { font-size: .8125rem; font-weight: 300; color: var(--t2); line-height: 1.55; }
|
||||
.api-key-help-body a { color: var(--navy-65); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.api-key-help-body a:hover { color: var(--navy); }
|
||||
.api-key-note { font-size: .75rem; font-weight: 300; color: var(--t3); margin-top: .625rem; line-height: 1.55; padding: .5rem .75rem; background: var(--bg2); border-left: 2px solid var(--navy-b); }
|
||||
.api-key-note a { color: var(--navy-65); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.api-key-model-note { font-size: .8125rem; font-weight: 300; color: var(--t2); line-height: 1.65; padding: .75rem 1rem; background: var(--navy-d); border-left: 2px solid var(--navy); margin-bottom: 1.5rem; }
|
||||
.api-key-model-note strong { font-weight: 600; color: var(--t1); }"
|
||||
"<style>" + css + "</style>"
|
||||
}
|
||||
|
||||
@@ -804,6 +826,106 @@ fn account_devices_card() -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn api_key_provider_row(provider_id: String, provider_name: String, placeholder: String, instructions: String) -> String {
|
||||
el_div(
|
||||
"class=\"api-key-entry\"",
|
||||
el_div(
|
||||
"class=\"api-key-header\"",
|
||||
el_span("class=\"api-key-name\"", provider_name) +
|
||||
el_span("class=\"api-key-masked\" id=\"apikey-masked-" + provider_id + "\"", "Not configured")
|
||||
) +
|
||||
el_div(
|
||||
"class=\"api-key-row\"",
|
||||
"<input type=\"password\" id=\"apikey-input-" + provider_id + "\" class=\"acct-input\" placeholder=\"" + placeholder + "\" autocomplete=\"off\" style=\"margin-bottom:0;flex:1\">" +
|
||||
el_div(
|
||||
"class=\"api-key-actions\"",
|
||||
el_button("type=\"button\" class=\"btn-primary\" style=\"padding:.5rem 1rem;font-size:.75rem\" onclick=\"saveApiKey('" + provider_id + "')\"", "Save") +
|
||||
el_button("type=\"button\" class=\"btn-ghost\" style=\"padding:.5rem 1rem;font-size:.75rem;display:none\" id=\"apikey-del-" + provider_id + "\" onclick=\"deleteApiKey('" + provider_id + "')\"", "Remove")
|
||||
)
|
||||
) +
|
||||
instructions
|
||||
)
|
||||
}
|
||||
|
||||
fn account_api_keys_section() -> String {
|
||||
let openai_help: String =
|
||||
"<details class=\"api-key-help\">" +
|
||||
"<summary>How to get an OpenAI key</summary>" +
|
||||
"<div class=\"api-key-help-body\"><ol>" +
|
||||
"<li>Go to <a href=\"https://platform.openai.com/api-keys\" target=\"_blank\" rel=\"noopener\">platform.openai.com/api-keys ↗</a></li>" +
|
||||
"<li>Click <strong>Create new secret key</strong></li>" +
|
||||
"<li>Give it a name (e.g. “Neuron”), then click <strong>Create secret key</strong></li>" +
|
||||
"<li>Copy the key immediately — it is only shown once</li>" +
|
||||
"</ol>" +
|
||||
"<p class=\"api-key-note\">You need billing set up before making API calls. Add a payment method at <a href=\"https://platform.openai.com/settings/organization/billing\" target=\"_blank\" rel=\"noopener\">platform.openai.com ↗</a></p>" +
|
||||
"</div></details>"
|
||||
|
||||
let anthropic_help: String =
|
||||
"<details class=\"api-key-help\">" +
|
||||
"<summary>How to get an Anthropic key</summary>" +
|
||||
"<div class=\"api-key-help-body\"><ol>" +
|
||||
"<li>Go to <a href=\"https://console.anthropic.com/settings/keys\" target=\"_blank\" rel=\"noopener\">console.anthropic.com/settings/keys ↗</a></li>" +
|
||||
"<li>Click <strong>Create Key</strong></li>" +
|
||||
"<li>Give it a name (e.g. “Neuron”), then click <strong>Create Key</strong></li>" +
|
||||
"<li>Copy the key immediately — it is only shown once</li>" +
|
||||
"</ol>" +
|
||||
"<p class=\"api-key-note\">You need to add credits before making API calls. Go to <a href=\"https://console.anthropic.com/settings/plans\" target=\"_blank\" rel=\"noopener\">console.anthropic.com → Plans & Billing ↗</a> to add credits.</p>" +
|
||||
"</div></details>"
|
||||
|
||||
let gemini_help: String =
|
||||
"<details class=\"api-key-help\">" +
|
||||
"<summary>How to get a Gemini key</summary>" +
|
||||
"<div class=\"api-key-help-body\"><ol>" +
|
||||
"<li>Go to <a href=\"https://aistudio.google.com/apikey\" target=\"_blank\" rel=\"noopener\">aistudio.google.com/apikey ↗</a></li>" +
|
||||
"<li>Sign in with your Google account if prompted</li>" +
|
||||
"<li>Click <strong>Create API key</strong></li>" +
|
||||
"<li>Select an existing Google Cloud project or create a new one</li>" +
|
||||
"<li>Copy the key</li>" +
|
||||
"</ol>" +
|
||||
"<p class=\"api-key-note\">The free tier (Gemini 1.5 Flash) has generous rate limits. Paid usage is billed through your Google Cloud account.</p>" +
|
||||
"</div></details>"
|
||||
|
||||
let grok_help: String =
|
||||
"<details class=\"api-key-help\">" +
|
||||
"<summary>How to get a Grok key</summary>" +
|
||||
"<div class=\"api-key-help-body\"><ol>" +
|
||||
"<li>Go to <a href=\"https://console.x.ai/\" target=\"_blank\" rel=\"noopener\">console.x.ai ↗</a></li>" +
|
||||
"<li>Sign in with your X (Twitter) account</li>" +
|
||||
"<li>In the left sidebar, click <strong>API Keys</strong></li>" +
|
||||
"<li>Click <strong>Create API Key</strong>, give it a name</li>" +
|
||||
"<li>Copy the key immediately — it is only shown once</li>" +
|
||||
"</ol>" +
|
||||
"<p class=\"api-key-note\">xAI offers free monthly credits to new accounts. Usage beyond the free tier is billed per token.</p>" +
|
||||
"</div></details>"
|
||||
|
||||
let providers: String = el_div(
|
||||
"class=\"api-key-list\"",
|
||||
api_key_provider_row("openai", "OpenAI", "sk-...", openai_help) +
|
||||
api_key_provider_row("anthropic", "Anthropic", "sk-ant-...", anthropic_help) +
|
||||
api_key_provider_row("gemini", "Gemini", "AIza...", gemini_help) +
|
||||
api_key_provider_row("grok", "Grok", "xai-...", grok_help)
|
||||
)
|
||||
el_div(
|
||||
"id=\"api-keys-section\" style=\"display:none\"",
|
||||
el_div(
|
||||
"class=\"card-dark\"",
|
||||
el_div(
|
||||
"class=\"acct-section-header\"",
|
||||
el_p("class=\"card-label\"", "API Keys") +
|
||||
el_p("style=\"font-size:.8125rem;font-weight:300;color:var(--t2);line-height:1.65\"",
|
||||
"Add your own AI provider keys. Neuron uses them directly — your keys, your models, your data."
|
||||
)
|
||||
) +
|
||||
el_div(
|
||||
"class=\"api-key-model-note\"",
|
||||
"<strong>For best performance, use a reasoning model.</strong> o4-mini or o3 (OpenAI) · Claude Sonnet 4 (Anthropic) · Gemini 2.5 Pro (Google) · Grok-3 (xAI). You choose the model in the app — any model works, reasoning models are where Neuron shines. <a href=\"#\" style=\"color:var(--navy-65);text-decoration:underline;text-underline-offset:2px\">Neuron Inference</a> — our own model layer, priced below the major APIs — launches Q3 2026 and becomes the default."
|
||||
) +
|
||||
providers +
|
||||
el_p("id=\"api-keys-msg\" style=\"display:none;font-size:.8rem;margin-top:.75rem\"", "")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fn account_dashboard_section() -> String {
|
||||
let header_row: String = el_div(
|
||||
"class=\"acct-header-row\"",
|
||||
@@ -828,6 +950,7 @@ fn account_dashboard_section() -> String {
|
||||
el_div(
|
||||
"class=\"account-section\"",
|
||||
account_plan_card() +
|
||||
account_api_keys_section() +
|
||||
account_roadmap_section() +
|
||||
account_family_section() +
|
||||
account_badge_section() +
|
||||
|
||||
+31
-17
@@ -79,7 +79,7 @@ fn checkout_page(plan: String, pub_key: String) -> String {
|
||||
let plan_desc: String = if is_founding {
|
||||
"Pay once. Neuron inference when it launches - priced below the major APIs. No subscription, ever."
|
||||
} else { if is_free {
|
||||
"Start building your memory. No card required."
|
||||
"Start building your memory. A card verifies you're 18+. You won't be charged."
|
||||
} else {
|
||||
"Full access. Bring your own API keys or use Neuron Inference when it launches - Q3 2026."
|
||||
} }
|
||||
@@ -142,7 +142,7 @@ fn checkout_page(plan: String, pub_key: String) -> String {
|
||||
|
||||
let auth_heading: String = if is_free {
|
||||
el_p("class=\"label\" style=\"margin-bottom: 1.5rem; color: var(--navy);\"", "Create your account.")
|
||||
+ el_p("class=\"checkout-auth-hint\" style=\"margin-bottom: 2rem;\"", "No card required. Your account is free, forever.")
|
||||
+ el_p("class=\"checkout-auth-hint\" style=\"margin-bottom: 2rem;\"", "Create your account. We'll ask for a card to verify your age - you won't be charged.")
|
||||
} else {
|
||||
el_p("class=\"label\" style=\"margin-bottom: 1.25rem;\"", "Sign in (optional)")
|
||||
+ el_p("class=\"checkout-auth-hint\"", "Sign in to link this purchase to an existing account. Or skip and create one later - we'll match it to your email.")
|
||||
@@ -201,15 +201,7 @@ fn checkout_page(plan: String, pub_key: String) -> String {
|
||||
|
||||
// ── Free-tier success panel ───────────────────────────────────────────────
|
||||
|
||||
let free_success: String = if is_free {
|
||||
el_div(
|
||||
"id=\"free-success\" style=\"display:none; text-align:center; padding: 2.5rem 1rem;\"",
|
||||
el_div("style=\"font-size:2.5rem; margin-bottom:1.25rem;\"", "✓")
|
||||
+ el_p("class=\"label\" style=\"margin-bottom:.75rem; color:var(--navy);\"", "You're in.")
|
||||
+ el_p("class=\"checkout-auth-hint\" style=\"margin-bottom:2rem;\"", "Your free account is ready. Download Neuron to get started.")
|
||||
+ el_a("/marketplace", "class=\"checkout-submit\" style=\"display:inline-block; text-decoration:none; padding:.875rem 2rem;\"", "Go to your account →")
|
||||
)
|
||||
} else { "" }
|
||||
let free_success: String = ""
|
||||
|
||||
// ── Payment section ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -281,7 +273,7 @@ fn checkout_page(plan: String, pub_key: String) -> String {
|
||||
+ "<path d=\"M4 7l1.5 1.5L8.5 5\" stroke=\"currentColor\" stroke-width=\"1.2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>"
|
||||
+ "</svg>"
|
||||
|
||||
let submit_label: String = if is_free { "Reserve free tier →" } else { "Complete purchase →" }
|
||||
let submit_label: String = if is_free { "Verify age & get started →" } else { "Complete purchase →" }
|
||||
|
||||
let payment_form: String = el_form(
|
||||
"id=\"payment-form\" autocomplete=\"on\"",
|
||||
@@ -347,15 +339,37 @@ fn checkout_page(plan: String, pub_key: String) -> String {
|
||||
let cfg_js: String = "window.NEURON_CFG=window.NEURON_CFG||{};window.NEURON_CFG.plan=\"" + plan + "\";window.NEURON_CFG.pub_key=\"" + pub_key + "\";"
|
||||
let cfg_script: String = el_script_inline(cfg_js)
|
||||
let stripe_el_script: String = el_script_src("/js/checkout-stripe.js", true)
|
||||
let free_init_script: String = if is_free {
|
||||
el_script_inline("document.addEventListener('DOMContentLoaded',function(){window.neuronCheckoutFree&&window.neuronCheckoutFree()});")
|
||||
} else { "" }
|
||||
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;
|
||||
|
||||
+64
-2
@@ -189,6 +189,22 @@ fn enterprise() -> String {
|
||||
)
|
||||
)
|
||||
|
||||
let contact_block: String = el_div(
|
||||
"class=\"ent-contact-block reveal\"",
|
||||
el_div(
|
||||
"class=\"ent-contact-card\"",
|
||||
el_p("class=\"ent-contact-role\"", "Sales") +
|
||||
el_a("mailto:enterprise@neurontechnologies.ai", "class=\"ent-contact-email\"", "enterprise@neurontechnologies.ai") +
|
||||
el_p("class=\"ent-contact-desc\"", "Pricing, deployment options, and enterprise agreements.")
|
||||
) +
|
||||
el_div(
|
||||
"class=\"ent-contact-card\"",
|
||||
el_p("class=\"ent-contact-role\"", "Security") +
|
||||
el_a("mailto:security@neurontechnologies.ai", "class=\"ent-contact-email\"", "security@neurontechnologies.ai") +
|
||||
el_p("class=\"ent-contact-desc\"", "Vulnerability disclosure, compliance review, and security documentation.")
|
||||
)
|
||||
)
|
||||
|
||||
let enterprise_box: String = el_div(
|
||||
"class=\"enterprise-box reveal\"",
|
||||
el_p("class=\"ent-who-label\"", "Who I work with") +
|
||||
@@ -203,7 +219,53 @@ fn enterprise() -> String {
|
||||
enterprise_inquiry_form()
|
||||
)
|
||||
|
||||
let style_css: String = ".ent-inquiry-form {
|
||||
let style_css: String = ".ent-contact-block {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
.ent-contact-card {
|
||||
padding: 1.5rem;
|
||||
border: 1px solid rgba(0,82,160,.18);
|
||||
background: rgba(0,82,160,.03);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .4rem;
|
||||
}
|
||||
.ent-contact-role {
|
||||
font-family: var(--body);
|
||||
font-size: .65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--navy);
|
||||
margin-bottom: .1rem;
|
||||
}
|
||||
.ent-contact-email {
|
||||
font-family: var(--body);
|
||||
font-size: .9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--t1);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid rgba(0,82,160,.25);
|
||||
padding-bottom: .1rem;
|
||||
transition: border-color 200ms, color 200ms;
|
||||
width: fit-content;
|
||||
}
|
||||
.ent-contact-email:hover { color: var(--navy); border-color: var(--navy); }
|
||||
.ent-contact-desc {
|
||||
font-family: var(--body);
|
||||
font-size: .8125rem;
|
||||
font-weight: 300;
|
||||
color: var(--t3);
|
||||
line-height: 1.55;
|
||||
margin-top: .25rem;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.ent-contact-block { grid-template-columns: 1fr; }
|
||||
}
|
||||
.ent-inquiry-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
@@ -216,7 +278,7 @@ fn enterprise() -> String {
|
||||
|
||||
el_section(
|
||||
"id=\"enterprise\" aria-label=\"Enterprise\"",
|
||||
el_div("class=\"container\"", header + enterprise_cap_cards() + enterprise_box) +
|
||||
el_div("class=\"container\"", header + enterprise_cap_cards() + contact_block + enterprise_box) +
|
||||
"<style>" + style_css + "</style>" +
|
||||
el_script_src("/js/enterprise.js", true)
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// components/hero.el - Hero section.
|
||||
// Rebuilt: 2026-05-11.
|
||||
//
|
||||
// Full-bleed hero with headline, sub-copy, and two CTAs.
|
||||
// Glow orbs are pure CSS absolute-positioned divs.
|
||||
|
||||
@@ -246,11 +246,98 @@ fn main() -> Void {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApiKeys() {
|
||||
var apiSection = document.getElementById('api-keys-section');
|
||||
if (apiSection) apiSection.style.display = '';
|
||||
try {
|
||||
var sess = await sb.auth.getSession();
|
||||
var token = sess.data && sess.data.session ? sess.data.session.access_token : '';
|
||||
if (!token) return;
|
||||
var r = await fetch('/api/api-keys', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({access_token: token})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (!d.rows || !d.rows.length) return;
|
||||
d.rows.forEach(function(row) {
|
||||
var provider = row.provider;
|
||||
var keyVal = row.key_value || '';
|
||||
var maskedEl = document.getElementById('apikey-masked-' + provider);
|
||||
var delBtn = document.getElementById('apikey-del-' + provider);
|
||||
if (keyVal) {
|
||||
var klen = keyVal.length;
|
||||
var masked = klen <= 10 ? '••••••••' : keyVal.slice(0, 6) + '••••' + keyVal.slice(-4);
|
||||
if (maskedEl) maskedEl.textContent = masked;
|
||||
if (delBtn) delBtn.style.display = '';
|
||||
}
|
||||
});
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
window.saveApiKey = async function(provider) {
|
||||
var input = document.getElementById('apikey-input-' + provider);
|
||||
var msg = document.getElementById('api-keys-msg');
|
||||
if (!input || !input.value.trim()) {
|
||||
if (msg) { msg.style.display = 'block'; msg.style.color = '#c44'; msg.textContent = 'Enter a key first.'; }
|
||||
return;
|
||||
}
|
||||
var keyVal = input.value.trim();
|
||||
var sess = await sb.auth.getSession();
|
||||
var token = sess.data && sess.data.session ? sess.data.session.access_token : '';
|
||||
if (!token) return;
|
||||
try {
|
||||
var r = await fetch('/api/api-keys/save', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({access_token: token, provider: provider, key: keyVal})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.ok) {
|
||||
var maskedEl = document.getElementById('apikey-masked-' + provider);
|
||||
var delBtn = document.getElementById('apikey-del-' + provider);
|
||||
if (maskedEl) maskedEl.textContent = d.masked;
|
||||
if (delBtn) delBtn.style.display = '';
|
||||
input.value = '';
|
||||
if (msg) { msg.style.display = 'block'; msg.style.color = 'var(--navy)'; msg.textContent = provider.charAt(0).toUpperCase() + provider.slice(1) + ' key saved.'; }
|
||||
setTimeout(function() { if (msg) msg.style.display = 'none'; }, 3000);
|
||||
} else {
|
||||
if (msg) { msg.style.display = 'block'; msg.style.color = '#c44'; msg.textContent = d.error || 'Save failed.'; }
|
||||
}
|
||||
} catch (e) {
|
||||
if (msg) { msg.style.display = 'block'; msg.style.color = '#c44'; msg.textContent = 'Network error.'; }
|
||||
}
|
||||
};
|
||||
|
||||
window.deleteApiKey = async function(provider) {
|
||||
var sess = await sb.auth.getSession();
|
||||
var token = sess.data && sess.data.session ? sess.data.session.access_token : '';
|
||||
if (!token) return;
|
||||
try {
|
||||
var r = await fetch('/api/api-keys/delete', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({access_token: token, provider: provider})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.ok) {
|
||||
var maskedEl = document.getElementById('apikey-masked-' + provider);
|
||||
var delBtn = document.getElementById('apikey-del-' + provider);
|
||||
if (maskedEl) maskedEl.textContent = 'Not configured';
|
||||
if (delBtn) delBtn.style.display = 'none';
|
||||
var msg = document.getElementById('api-keys-msg');
|
||||
if (msg) { msg.style.display = 'block'; msg.style.color = 'var(--t3)'; msg.textContent = provider.charAt(0).toUpperCase() + provider.slice(1) + ' key removed.'; }
|
||||
setTimeout(function() { if (msg) msg.style.display = 'none'; }, 3000);
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
|
||||
function showDashboard(user) {
|
||||
hide('signin-section');
|
||||
show('dashboard-section');
|
||||
renderUserChip(user);
|
||||
loadWaitlistData();
|
||||
loadApiKeys();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
|
||||
+112
-29
@@ -14,6 +14,10 @@ fn main() -> Void {
|
||||
var turnstileVerified = false;
|
||||
var isOpen = false;
|
||||
var MAX = 10;
|
||||
var _userName = '';
|
||||
var _userTimezone = (typeof Intl !== 'undefined' && Intl.DateTimeFormat)
|
||||
? Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
: '';
|
||||
|
||||
// ── Supabase auth state ───────────────────────────────────────────────────
|
||||
var supabaseClient = null;
|
||||
@@ -45,6 +49,11 @@ fn main() -> Void {
|
||||
}
|
||||
|
||||
function _onWidgetAuthenticated() {
|
||||
// Capture user name for personalized greeting
|
||||
if (_supabaseSession && _supabaseSession.user) {
|
||||
var _meta = _supabaseSession.user.user_metadata || {};
|
||||
_userName = _meta.full_name || _meta.name || _supabaseSession.user.email || '';
|
||||
}
|
||||
var authPane = document.getElementById('neuron-demo-auth');
|
||||
var gate = document.getElementById('neuron-demo-gate');
|
||||
var msgs = document.getElementById('neuron-demo-messages');
|
||||
@@ -60,10 +69,8 @@ fn main() -> Void {
|
||||
if (session && session.messages && session.messages.length > 0) {
|
||||
session.messages.forEach(function(m) { addMsg(m.role, m.text, true); });
|
||||
} else if (!session.greeted) {
|
||||
addMsg('ai', 'Hey. What is on your mind?', true);
|
||||
session.greeted = true;
|
||||
saveSession(session);
|
||||
}
|
||||
_sendIntroGreeting();
|
||||
}
|
||||
}
|
||||
var inp = document.getElementById('neuron-demo-text');
|
||||
if (inp) inp.focus();
|
||||
@@ -135,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) {}
|
||||
@@ -247,10 +267,61 @@ fn main() -> Void {
|
||||
}
|
||||
}
|
||||
|
||||
function _timeOfDay() {
|
||||
var h = new Date().getHours();
|
||||
if (h < 12) return 'morning';
|
||||
if (h < 17) return 'afternoon';
|
||||
if (h < 21) return 'evening';
|
||||
return 'night';
|
||||
}
|
||||
|
||||
function _sendIntroGreeting() {
|
||||
if (session.greeted) return;
|
||||
session.greeted = true;
|
||||
saveSession(session);
|
||||
var accessToken = (_supabaseSession && _supabaseSession.access_token) ? _supabaseSession.access_token : '';
|
||||
fetch('/api/demo', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
message: '__intro_phase1__',
|
||||
history: [],
|
||||
cf_token: '',
|
||||
uid: session.uid || '',
|
||||
access_token: accessToken,
|
||||
user_name: _userName,
|
||||
user_timezone: _userTimezone,
|
||||
time_of_day: _timeOfDay(),
|
||||
is_return: session.count > 0 ? 'true' : 'false',
|
||||
activated_nodes: [],
|
||||
engram_node_count: 0,
|
||||
questions_remaining: MAX,
|
||||
is_last_question: false
|
||||
})
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(d) {
|
||||
var reply = d.response || d.reply || d.message || '';
|
||||
if (reply) {
|
||||
addMsg('ai', reply, true);
|
||||
session.messages = session.messages || [];
|
||||
session.messages.push({ role: 'ai', text: reply });
|
||||
saveSession(session);
|
||||
} else {
|
||||
addMsg('ai', \"Hey. What's on your mind?\", true);
|
||||
}
|
||||
})
|
||||
.catch(function() {
|
||||
addMsg('ai', \"Hey. What's on your mind?\", true);
|
||||
});
|
||||
}
|
||||
|
||||
window.neuronDemoReset = function() {
|
||||
if (_headerResetInterval) { clearInterval(_headerResetInterval); _headerResetInterval = null; }
|
||||
clearSession();
|
||||
session = { messages: [], count: 0, context: '' };
|
||||
session.uid = 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||
saveSession(session);
|
||||
msgCount = 0;
|
||||
var msgs = document.getElementById('neuron-demo-messages');
|
||||
if (msgs) msgs.innerHTML = '';
|
||||
@@ -258,7 +329,7 @@ fn main() -> Void {
|
||||
if (input) { input.disabled = false; input.placeholder = 'Ask me anything...'; }
|
||||
var btn = document.getElementById('neuron-demo-send');
|
||||
if (btn) btn.disabled = false;
|
||||
addMsg('ai', 'Hey. What is on your mind?', true);
|
||||
_sendIntroGreeting();
|
||||
};
|
||||
|
||||
window.neuronDemoToggle = function() {
|
||||
@@ -277,9 +348,7 @@ fn main() -> Void {
|
||||
if (input) { input.disabled = true; input.placeholder = 'Interaction limit reached'; }
|
||||
}
|
||||
} else if (!session.greeted) {
|
||||
addMsg('ai', 'Hey. What is on your mind?', true);
|
||||
session.greeted = true;
|
||||
saveSession(session);
|
||||
_sendIntroGreeting();
|
||||
}
|
||||
}
|
||||
var input = document.getElementById('neuron-demo-text');
|
||||
@@ -315,8 +384,15 @@ fn main() -> Void {
|
||||
if (gate) gate.style.display = 'none';
|
||||
if (msgs) msgs.style.display = 'flex';
|
||||
if (inputRow) inputRow.style.display = 'flex';
|
||||
addMsg('ai', 'Hey. What is on your mind?', true);
|
||||
updateCountdown();
|
||||
// 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();
|
||||
},
|
||||
@@ -421,6 +497,7 @@ fn main() -> Void {
|
||||
return;
|
||||
}
|
||||
input.value = '';
|
||||
input.style.height = 'auto';
|
||||
btn.disabled = true;
|
||||
addMsg('user', msg);
|
||||
|
||||
@@ -454,6 +531,9 @@ fn main() -> Void {
|
||||
cf_token: turnstileVerified && !session._cfSent ? turnstileToken : '',
|
||||
uid: session.uid || '',
|
||||
access_token: accessToken,
|
||||
user_name: _userName,
|
||||
user_timezone: _userTimezone,
|
||||
time_of_day: _timeOfDay(),
|
||||
activated_nodes: activated_nodes,
|
||||
engram_node_count: (session._m && session._m.nodes) ? session._m.nodes.length : 0,
|
||||
questions_remaining: questionsRemaining,
|
||||
@@ -466,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;
|
||||
}
|
||||
|
||||
@@ -539,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';
|
||||
});
|
||||
}
|
||||
})()")
|
||||
}
|
||||
|
||||
+5
-14
@@ -36,15 +36,8 @@ fn main() -> Void {
|
||||
if (user && user.id) { window._neuronSupaId = user.id; }
|
||||
var auth = document.getElementById('auth-section');
|
||||
if (auth) auth.style.display = 'none';
|
||||
var isFree = (window.NEURON_CFG || {}).plan === 'free';
|
||||
if (isFree) {
|
||||
// Free plan: show the success panel (user is signed in or just signed up)
|
||||
var freeSuccess = document.getElementById('free-success');
|
||||
if (freeSuccess) freeSuccess.style.display = '';
|
||||
} else {
|
||||
var payment = document.getElementById('payment-section');
|
||||
if (payment) payment.style.display = '';
|
||||
}
|
||||
var payment = document.getElementById('payment-section');
|
||||
if (payment) payment.style.display = '';
|
||||
|
||||
if (user) {
|
||||
var badge = document.getElementById('auth-badge');
|
||||
@@ -65,11 +58,9 @@ fn main() -> Void {
|
||||
if (emailEl) emailEl.value = user.email;
|
||||
}
|
||||
|
||||
if (!isFree) {
|
||||
var userEmail = user ? (user.email || '') : '';
|
||||
var userName = user ? ((user.user_metadata && user.user_metadata.full_name) || '') : '';
|
||||
if (typeof window.initStripe === 'function') window.initStripe(userEmail, userName);
|
||||
}
|
||||
var userEmail = user ? (user.email || '') : '';
|
||||
var userName = user ? ((user.user_metadata && user.user_metadata.full_name) || '') : '';
|
||||
if (typeof window.initStripe === 'function') window.initStripe(userEmail, userName);
|
||||
}
|
||||
|
||||
function checkExistingSession() {
|
||||
|
||||
+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);
|
||||
|
||||
+180
-15
@@ -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."
|
||||
}
|
||||
@@ -666,10 +668,6 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
}
|
||||
let timing: String = json_get_string(body, "timing")
|
||||
if str_eq(timing, "") { let timing = "now" }
|
||||
// Free tier: no card required. Return immediately — no Stripe interaction.
|
||||
if str_eq(plan, "free") {
|
||||
return "{\"plan\":\"free\",\"free\":true,\"no_payment_required\":true}"
|
||||
}
|
||||
// Hard cap: block founding checkouts when 1,000 spots are filled
|
||||
if str_eq(plan, "founding") {
|
||||
let current_sold: Int = get_sold()
|
||||
@@ -701,6 +699,23 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
}
|
||||
}
|
||||
|
||||
// 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_pi_body: String = "amount=0"
|
||||
+ "¤cy=usd"
|
||||
+ "&payment_method_types[]=card"
|
||||
+ "&metadata[plan]=free"
|
||||
+ "&metadata[purpose]=age_verification"
|
||||
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)
|
||||
return free_pi_resp
|
||||
}
|
||||
|
||||
// Setup-mode path: save payment method, do not charge. Only valid
|
||||
// for Professional (Founding is one-shot lifetime, charges immediately).
|
||||
if str_eq(plan, "professional") && str_eq(timing, "later") {
|
||||
@@ -860,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
|
||||
@@ -1152,11 +1193,16 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
// would not be able to silently obtain the key to make authenticated calls.
|
||||
if str_eq(path, "/api/supabase-config") {
|
||||
let req_origin: String = map_get(headers, "origin")
|
||||
let origin_ok: Bool = str_eq(req_origin, "")
|
||||
// map_get returns 0 (null) when the header is absent — same-origin
|
||||
// browser fetches don't send Origin at all. str_starts_with(null, "http")
|
||||
// returns false, so !origin_present correctly passes no-origin requests.
|
||||
let origin_present: Bool = str_starts_with(req_origin, "http")
|
||||
let origin_ok: Bool = !origin_present
|
||||
|| str_eq(req_origin, "https://neurontechnologies.ai")
|
||||
|| str_eq(req_origin, "https://www.neurontechnologies.ai")
|
||||
|| str_starts_with(req_origin, "http://localhost:")
|
||||
|| str_starts_with(req_origin, "http://127.0.0.1:")
|
||||
|| str_starts_with(req_origin, "https://marketing-stage-")
|
||||
if !origin_ok {
|
||||
return "{\"__status__\":403,\"error\":\"forbidden\"}"
|
||||
}
|
||||
@@ -1390,6 +1436,14 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
let qrem_safe: String = if str_eq(qrem_str, "") { "10" } else { qrem_str }
|
||||
let is_last_str: String = json_get(body, "is_last_question")
|
||||
let is_last_safe: String = if str_eq(is_last_str, "true") { "true" } else { "false" }
|
||||
let user_name_raw: String = json_get_string(body, "user_name")
|
||||
let user_tz_raw: String = json_get_string(body, "user_timezone")
|
||||
let tod_raw: String = json_get_string(body, "time_of_day")
|
||||
let is_return_raw: String = json_get_string(body, "is_return")
|
||||
let user_name_safe: String = str_replace(str_replace(user_name_raw, "\\", "\\\\"), "\"", "\\\"")
|
||||
let user_tz_safe: String = str_replace(str_replace(user_tz_raw, "\\", "\\\\"), "\"", "\\\"")
|
||||
let tod_safe: String = str_replace(str_replace(tod_raw, "\\", "\\\\"), "\"", "\\\"")
|
||||
let is_return_safe: String = if str_eq(is_return_raw, "true") { "true" } else { "false" }
|
||||
// Look up the configured chat model from public.neuron_config
|
||||
// (Phase 1 runtime config store). 60s TTL caching, falls back
|
||||
// to the hardcoded default on Supabase miss / error.
|
||||
@@ -1398,7 +1452,7 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
// Build inner content with history and engram context for thread context.
|
||||
// soul-demo unwraps payload from the dharma envelope, then reads
|
||||
// model with json_get(body, "model") - so this propagates end to end.
|
||||
let inner: String = "{\"event_type\":\"chat\",\"payload\":{\"message\":\"" + msg_safe + "\",\"history\":" + hist_safe + ",\"an\":" + an_safe + ",\"ec\":" + ec_safe + ",\"questions_remaining\":" + qrem_safe + ",\"is_last_question\":" + is_last_safe + ",\"model\":\"" + model_safe + "\"}}"
|
||||
let inner: String = "{\"event_type\":\"chat\",\"payload\":{\"message\":\"" + msg_safe + "\",\"history\":" + hist_safe + ",\"an\":" + an_safe + ",\"ec\":" + ec_safe + ",\"questions_remaining\":" + qrem_safe + ",\"is_last_turn\":" + is_last_safe + ",\"model\":\"" + model_safe + "\",\"user_name\":\"" + user_name_safe + "\",\"user_timezone\":\"" + user_tz_safe + "\",\"time_of_day\":\"" + tod_safe + "\",\"is_return\":\"" + is_return_safe + "\"}}"
|
||||
// Escape inner for the outer content field
|
||||
let inner_safe: String = str_replace(str_replace(inner, "\\", "\\\\"), "\"", "\\\"")
|
||||
// Build dharma envelope with per-user channel
|
||||
@@ -1496,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"
|
||||
@@ -1561,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
|
||||
@@ -2121,6 +2195,97 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
return "{\"ok\":true}"
|
||||
}
|
||||
|
||||
// ── API key provisioning — POST /api/api-keys ────────────────────────────
|
||||
// Returns user's stored provider keys (masked) for display on /account.
|
||||
// Body: { access_token: "<jwt>" }
|
||||
if str_eq(path, "/api/api-keys") && str_eq(method, "POST") {
|
||||
let ak_jwt: String = json_get_string(body, "access_token")
|
||||
if str_eq(ak_jwt, "") {
|
||||
return "{\"__status__\":401,\"error\":\"missing_jwt\"}"
|
||||
}
|
||||
let ak_url: String = state_get("__supabase_project_url__")
|
||||
let ak_anon: String = state_get("__supabase_anon_key__")
|
||||
let ak_service: String = state_get("__supabase_service_key__")
|
||||
if str_eq(ak_url, "") {
|
||||
return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}"
|
||||
}
|
||||
let ak_user: String = supabase_auth_user(ak_url, ak_anon, ak_jwt)
|
||||
let ak_uid: String = json_get(ak_user, "id")
|
||||
if str_eq(ak_uid, "") {
|
||||
return "{\"__status__\":401,\"error\":\"invalid_jwt\"}"
|
||||
}
|
||||
let ak_q: String = "user_api_keys?select=provider,key_value&user_id=eq." + ak_uid
|
||||
let ak_rows: String = supabase_get(ak_url, ak_service, ak_q)
|
||||
return "{\"rows\":" + ak_rows + "}"
|
||||
}
|
||||
|
||||
// ── API key provisioning — POST /api/api-keys/save ───────────────────────
|
||||
// Upserts a single provider key for the authenticated user.
|
||||
// Body: { access_token: "<jwt>", provider: "openai"|"anthropic"|"gemini"|"grok", key: "<value>" }
|
||||
if str_eq(path, "/api/api-keys/save") {
|
||||
let aks_jwt: String = json_get_string(body, "access_token")
|
||||
let aks_provider: String = json_get_string(body, "provider")
|
||||
let aks_key: String = json_get_string(body, "key")
|
||||
if str_eq(aks_jwt, "") {
|
||||
return "{\"__status__\":401,\"error\":\"missing_jwt\"}"
|
||||
}
|
||||
if str_eq(aks_provider, "") || str_eq(aks_key, "") {
|
||||
return "{\"__status__\":400,\"error\":\"missing_provider_or_key\"}"
|
||||
}
|
||||
let aks_valid_provider: Bool = str_eq(aks_provider, "openai")
|
||||
|| str_eq(aks_provider, "anthropic")
|
||||
|| str_eq(aks_provider, "gemini")
|
||||
|| str_eq(aks_provider, "grok")
|
||||
if !aks_valid_provider {
|
||||
return "{\"__status__\":400,\"error\":\"invalid_provider\"}"
|
||||
}
|
||||
let aks_url: String = state_get("__supabase_project_url__")
|
||||
let aks_anon: String = state_get("__supabase_anon_key__")
|
||||
let aks_service: String = state_get("__supabase_service_key__")
|
||||
if str_eq(aks_url, "") {
|
||||
return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}"
|
||||
}
|
||||
let aks_user: String = supabase_auth_user(aks_url, aks_anon, aks_jwt)
|
||||
let aks_uid: String = json_get(aks_user, "id")
|
||||
if str_eq(aks_uid, "") {
|
||||
return "{\"__status__\":401,\"error\":\"invalid_jwt\"}"
|
||||
}
|
||||
let aks_row: String = "{\"user_id\":\"" + aks_uid + "\",\"provider\":\"" + aks_provider + "\",\"key_value\":\"" + aks_key + "\",\"updated_at\":\"now()\"}"
|
||||
let _aks_resp: String = supabase_insert(aks_url, aks_service, "user_api_keys?on_conflict=user_id,provider", aks_row)
|
||||
let aks_klen: Int = str_len(aks_key)
|
||||
let aks_masked: String = if aks_klen <= 10 {
|
||||
str_repeat("•", aks_klen)
|
||||
} else {
|
||||
str_slice(aks_key, 0, 6) + "••••" + str_slice(aks_key, aks_klen - 4, aks_klen)
|
||||
}
|
||||
return "{\"ok\":true,\"masked\":\"" + aks_masked + "\"}"
|
||||
}
|
||||
|
||||
// ── API key provisioning — POST /api/api-keys/delete ─────────────────────
|
||||
// Soft-deletes a provider key by clearing key_value for the authenticated user.
|
||||
// Body: { access_token: "<jwt>", provider: "openai"|"anthropic"|"gemini"|"grok" }
|
||||
if str_eq(path, "/api/api-keys/delete") {
|
||||
let akd_jwt: String = json_get_string(body, "access_token")
|
||||
let akd_provider: String = json_get_string(body, "provider")
|
||||
if str_eq(akd_jwt, "") {
|
||||
return "{\"__status__\":401,\"error\":\"missing_jwt\"}"
|
||||
}
|
||||
let akd_url: String = state_get("__supabase_project_url__")
|
||||
let akd_anon: String = state_get("__supabase_anon_key__")
|
||||
let akd_service: String = state_get("__supabase_service_key__")
|
||||
if str_eq(akd_url, "") {
|
||||
return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}"
|
||||
}
|
||||
let akd_user: String = supabase_auth_user(akd_url, akd_anon, akd_jwt)
|
||||
let akd_uid: String = json_get(akd_user, "id")
|
||||
if str_eq(akd_uid, "") {
|
||||
return "{\"__status__\":401,\"error\":\"invalid_jwt\"}"
|
||||
}
|
||||
let akd_row: String = "{\"user_id\":\"" + akd_uid + "\",\"provider\":\"" + akd_provider + "\",\"key_value\":\"\",\"updated_at\":\"now()\"}"
|
||||
let _akd_resp: String = supabase_insert(akd_url, akd_service, "user_api_keys?on_conflict=user_id,provider", akd_row)
|
||||
return "{\"ok\":true}"
|
||||
}
|
||||
|
||||
// ── Fallback ──────────────────────────────────────────────────────────────
|
||||
return "{\"__status__\":404,\"error\":\"not found\"}"
|
||||
}
|
||||
@@ -2146,7 +2311,7 @@ fn sec_headers_json() -> String {
|
||||
+ "\"X-Frame-Options\":\"SAMEORIGIN\","
|
||||
+ "\"Referrer-Policy\":\"strict-origin-when-cross-origin\","
|
||||
+ "\"Permissions-Policy\":\"geolocation=(), microphone=(), camera=()\","
|
||||
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
|
||||
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
|
||||
}
|
||||
|
||||
// Headers for compiled JS assets. Explicitly sets Content-Type so the browser
|
||||
@@ -2162,7 +2327,7 @@ fn js_headers_json() -> String {
|
||||
+ "\"X-Frame-Options\":\"SAMEORIGIN\","
|
||||
+ "\"Referrer-Policy\":\"strict-origin-when-cross-origin\","
|
||||
+ "\"Permissions-Policy\":\"geolocation=(), microphone=(), camera=()\","
|
||||
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
|
||||
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
|
||||
}
|
||||
|
||||
// Headers for static assets under /assets/ and /brand/.
|
||||
@@ -2178,7 +2343,7 @@ fn static_asset_headers_json() -> String {
|
||||
+ "\"X-Frame-Options\":\"SAMEORIGIN\","
|
||||
+ "\"Referrer-Policy\":\"strict-origin-when-cross-origin\","
|
||||
+ "\"Permissions-Policy\":\"geolocation=(), microphone=(), camera=()\","
|
||||
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
|
||||
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
|
||||
}
|
||||
|
||||
fn handle_request(method: String, path: String, headers: Map, body: String) -> String {
|
||||
|
||||
+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."
|
||||
)
|
||||
|
||||
@@ -51,11 +51,13 @@ test.describe('Security headers', () => {
|
||||
// - anything else (e.g. evil.com): BLOCKED (403)
|
||||
|
||||
test.describe('CORS enforcement — /api/supabase-config', () => {
|
||||
test('Rejects requests with no Origin header', async () => {
|
||||
// No Origin = not from a browser context — the server treats this as
|
||||
// an unknown caller and returns 403 to prevent server-side exfiltration.
|
||||
test('Allows requests with no Origin header (same-origin browser fetches)', async () => {
|
||||
// Same-origin browser fetches (e.g. checkout page fetching supabase-config on
|
||||
// the same domain) do not send an Origin header. The server must pass these
|
||||
// through — blocking them would break the checkout flow on production.
|
||||
// Server-side exfiltration is prevented by the evil-origin 403 below.
|
||||
const r = await get('/api/supabase-config');
|
||||
expect(r.status).toBe(403);
|
||||
expect(r.status).toBe(200);
|
||||
});
|
||||
|
||||
test('Rejects evil origin', async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { test, expect } from '@playwright/test';
|
||||
test.describe('Demo chat widget — structure', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
});
|
||||
|
||||
test('Demo panel (#neuron-demo-panel) is in the DOM', async ({ page }) => {
|
||||
@@ -41,7 +41,7 @@ test.describe('Demo chat widget — auth gate', () => {
|
||||
.forEach(k => localStorage.removeItem(k));
|
||||
});
|
||||
await page.reload();
|
||||
await page.waitForLoadState('networkidle');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
});
|
||||
|
||||
test('Send button is disabled when unauthenticated', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* checkout-flows.spec.ts — Comprehensive checkout + auth flow tests.
|
||||
*
|
||||
* Covers:
|
||||
* - All three plan variants (free, professional, founding)
|
||||
* - Page structure, pricing, features list, noindex, canonical
|
||||
* - Auth section / payment section initial visibility per plan
|
||||
* - Form validation (empty fields, short password)
|
||||
* - Sign in / sign up toggle
|
||||
* - Mocked auth flows: sign-up success, email-confirm-required,
|
||||
* existing session, sign-in error
|
||||
* - DOM transitions: auth-section hidden → payment/free-success shown
|
||||
* - Auth badge rendered with user name after auth
|
||||
* - buyer-email pre-filled from Supabase user object
|
||||
* - /api/checkout endpoint response shapes
|
||||
* - /api/supabase-config CORS enforcement
|
||||
* - Edge cases: unknown plan, no plan param
|
||||
*
|
||||
* Network mocking strategy: Playwright route() intercepts
|
||||
* - GET /api/supabase-config → returns fake Supabase URL + anon key
|
||||
* - GET <fake-supabase>/auth/v1/user → no session or mock user
|
||||
* - POST <fake-supabase>/auth/v1/signup → success or email-confirm
|
||||
* - POST <fake-supabase>/auth/v1/token → sign-in success or error
|
||||
* This lets us test full JS-driven DOM transitions without real credentials.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
// ─── Mock helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const FAKE_SUPA_URL = 'https://xyzfaketest.supabase.co';
|
||||
const FAKE_ANON_KEY = 'fake-anon-key-for-playwright-testing';
|
||||
|
||||
const MOCK_USER = {
|
||||
id: 'test-uid-playwright-001',
|
||||
email: 'playwright@example.com',
|
||||
user_metadata: { full_name: 'Playwright Tester' },
|
||||
};
|
||||
|
||||
const MOCK_SESSION = {
|
||||
access_token: 'fake-access-token-playwright',
|
||||
refresh_token: 'fake-refresh-token-playwright',
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
user: MOCK_USER,
|
||||
};
|
||||
|
||||
async function mockSupabaseConfig(page: Page) {
|
||||
await page.route('/api/supabase-config', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ url: FAKE_SUPA_URL, anon_key: FAKE_ANON_KEY }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function mockNoSession(page: Page) {
|
||||
await page.route(`${FAKE_SUPA_URL}/auth/v1/user`, (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'not_authenticated', message: 'JWT expired' }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function mockExistingSession(page: Page) {
|
||||
// Pre-seed localStorage with a fake Supabase session so getUser() fires
|
||||
// the /auth/v1/user HTTP request (Supabase v2 only calls the endpoint when
|
||||
// a stored token exists). Key format: sb-{projectRef}-auth-token.
|
||||
await page.addInitScript(([supaUrl, mockUser, mockSession]: [string, typeof MOCK_USER, typeof MOCK_SESSION]) => {
|
||||
const ref = new URL(supaUrl).hostname.split('.')[0]; // "xyzfaketest"
|
||||
const stored = {
|
||||
access_token: mockSession.access_token,
|
||||
token_type: 'bearer',
|
||||
expires_in: 3600,
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
refresh_token: mockSession.refresh_token,
|
||||
user: mockUser,
|
||||
};
|
||||
localStorage.setItem(`sb-${ref}-auth-token`, JSON.stringify(stored));
|
||||
}, [FAKE_SUPA_URL, MOCK_USER, MOCK_SESSION] as [string, typeof MOCK_USER, typeof MOCK_SESSION]);
|
||||
|
||||
// Mock the /auth/v1/user endpoint that Supabase calls to validate the token
|
||||
await page.route(`${FAKE_SUPA_URL}/auth/v1/user`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(MOCK_USER),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function mockSignUpSuccess(page: Page) {
|
||||
await page.route(`${FAKE_SUPA_URL}/auth/v1/signup`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: { session: MOCK_SESSION, user: MOCK_USER },
|
||||
error: null,
|
||||
...MOCK_SESSION,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function mockSignUpEmailConfirmRequired(page: Page) {
|
||||
await page.route(`${FAKE_SUPA_URL}/auth/v1/signup`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
data: { session: null, user: MOCK_USER },
|
||||
error: null,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function mockSignInSuccess(page: Page) {
|
||||
await page.route(`${FAKE_SUPA_URL}/auth/v1/token*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify(MOCK_SESSION),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function mockSignInError(page: Page, message = 'Invalid login credentials') {
|
||||
await page.route(`${FAKE_SUPA_URL}/auth/v1/token**`, (route) =>
|
||||
route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'invalid_grant', error_description: message }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Per-plan structure ───────────────────────────────────────────────────────
|
||||
|
||||
for (const plan of ['free', 'professional', 'founding'] as const) {
|
||||
test(`[${plan}] page loads 200 with content`, async ({ page }) => {
|
||||
const res = await page.goto(`/checkout?plan=${plan}`);
|
||||
expect(res?.status()).toBe(200);
|
||||
await expect(page.locator('body')).not.toBeEmpty();
|
||||
});
|
||||
|
||||
test(`[${plan}] page has non-empty title`, async ({ page }) => {
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
expect((await page.title()).trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test(`[${plan}] nav back link to /`, async ({ page }) => {
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
await expect(page.locator('nav a[href="/"]').first()).toBeAttached();
|
||||
});
|
||||
|
||||
test(`[${plan}] canonical is production URL — not stage/run.app`, async ({ page }) => {
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
const canonical = await page.locator('link[rel="canonical"]').getAttribute('href');
|
||||
expect(canonical).toContain('neurontechnologies.ai');
|
||||
expect(canonical).not.toMatch(/run\.app|stage/);
|
||||
});
|
||||
|
||||
test(`[${plan}] noindex meta tag present`, async ({ page }) => {
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
const robots = await page.locator('meta[name="robots"]').getAttribute('content');
|
||||
expect(robots).toContain('noindex');
|
||||
});
|
||||
|
||||
test(`[${plan}] Google + GitHub social buttons present`, async ({ page }) => {
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
await expect(page.locator('#btn-google')).toBeAttached();
|
||||
await expect(page.locator('#btn-github')).toBeAttached();
|
||||
});
|
||||
|
||||
test(`[${plan}] email + password inputs present`, async ({ page }) => {
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
await expect(page.locator('#auth-email')).toBeAttached();
|
||||
await expect(page.locator('#auth-password')).toBeAttached();
|
||||
});
|
||||
|
||||
test(`[${plan}] auth message div present`, async ({ page }) => {
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
await expect(page.locator('#auth-message')).toBeAttached();
|
||||
});
|
||||
|
||||
test(`[${plan}] auth badge container in DOM`, async ({ page }) => {
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
await expect(page.locator('#auth-badge')).toBeAttached();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Plan-specific content ────────────────────────────────────────────────────
|
||||
|
||||
test('[professional] shows $19 / month pricing', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
const body = (await page.locator('body').textContent()) ?? '';
|
||||
expect(body).toContain('$19');
|
||||
expect(body.toLowerCase()).toContain('month');
|
||||
});
|
||||
|
||||
test('[professional] features include persistent memory + API keys', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
const body = (await page.locator('body').textContent()) ?? '';
|
||||
expect(body).toContain('Persistent memory');
|
||||
expect(body).toContain('Bring your own API keys');
|
||||
});
|
||||
|
||||
test('[founding] shows $199 one-time pricing', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=founding');
|
||||
const body = (await page.locator('body').textContent()) ?? '';
|
||||
expect(body).toContain('$199');
|
||||
expect(body.toLowerCase()).toContain('one-time');
|
||||
});
|
||||
|
||||
test('[founding] features include founding badge + lifetime', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=founding');
|
||||
const body = (await page.locator('body').textContent()) ?? '';
|
||||
expect(body).toContain('Founding member badge');
|
||||
expect(body.toLowerCase()).toContain('lifetime');
|
||||
});
|
||||
|
||||
test('[free] shows free / no card pricing', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=free');
|
||||
const body = (await page.locator('body').textContent()) ?? '';
|
||||
expect(body.toLowerCase()).toMatch(/\$0|free|no card/);
|
||||
});
|
||||
|
||||
test('[free] features include persistent memory + BYOAPI', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=free');
|
||||
const body = (await page.locator('body').textContent()) ?? '';
|
||||
expect(body).toContain('Persistent memory');
|
||||
});
|
||||
|
||||
// ─── Initial visibility per plan ─────────────────────────────────────────────
|
||||
|
||||
test('[free] auth-section visible on load (account creation flow)', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=free');
|
||||
await expect(page.locator('#auth-section')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[free] payment-section hidden on load (shown after auth)', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=free');
|
||||
const ps = page.locator('#payment-section');
|
||||
if (await ps.count() > 0) {
|
||||
await expect(ps).toBeHidden();
|
||||
}
|
||||
});
|
||||
|
||||
test('[free] payment-element container present (Stripe mounts here)', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=free');
|
||||
await expect(page.locator('#payment-element')).toBeAttached();
|
||||
});
|
||||
|
||||
test('[professional] payment-section visible on load', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
await expect(page.locator('#payment-section')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[professional] auth-section hidden on load (optional for paid)', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
await expect(page.locator('#auth-section')).toBeHidden();
|
||||
});
|
||||
|
||||
test('[founding] payment-section visible on load', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=founding');
|
||||
await expect(page.locator('#payment-section')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[founding] auth-section hidden on load (optional for paid)', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=founding');
|
||||
await expect(page.locator('#auth-section')).toBeHidden();
|
||||
});
|
||||
|
||||
// ─── Payment form elements (paid plans) ──────────────────────────────────────
|
||||
|
||||
for (const plan of ['professional', 'founding'] as const) {
|
||||
test(`[${plan}] payment-element container present (Stripe mounts here)`, async ({ page }) => {
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
await expect(page.locator('#payment-element')).toBeAttached();
|
||||
});
|
||||
|
||||
test(`[${plan}] buyer-email input present`, async ({ page }) => {
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
await expect(page.locator('#buyer-email')).toBeAttached();
|
||||
});
|
||||
|
||||
test(`[${plan}] submit/pay button present`, async ({ page }) => {
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
const submitBtn = page.locator('#submit-btn, .checkout-submit, button[type="submit"]').first();
|
||||
await expect(submitBtn).toBeAttached();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Form validation ──────────────────────────────────────────────────────────
|
||||
|
||||
test('[free] submit with empty email shows auth error', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await page.goto('/checkout?plan=free');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.locator('.checkout-email-btn').click();
|
||||
const msg = page.locator('#auth-message');
|
||||
await expect(msg).toBeVisible({ timeout: 4000 });
|
||||
const text = (await msg.textContent()) ?? '';
|
||||
expect(text.toLowerCase()).toMatch(/email|password|enter|required/);
|
||||
});
|
||||
|
||||
test('[free] submit with password < 8 chars shows length error', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await page.goto('/checkout?plan=free');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.fill('#auth-email', 'test@example.com');
|
||||
await page.fill('#auth-password', 'short');
|
||||
await page.locator('.checkout-email-btn').click();
|
||||
const msg = page.locator('#auth-message');
|
||||
await expect(msg).toBeVisible({ timeout: 4000 });
|
||||
const text = (await msg.textContent()) ?? '';
|
||||
expect(text).toContain('8');
|
||||
});
|
||||
|
||||
test('[free] submit with email only (no password) shows error', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await page.goto('/checkout?plan=free');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.fill('#auth-email', 'test@example.com');
|
||||
// leave password empty
|
||||
await page.locator('.checkout-email-btn').click();
|
||||
const msg = page.locator('#auth-message');
|
||||
await expect(msg).toBeVisible({ timeout: 4000 });
|
||||
});
|
||||
|
||||
// ─── Sign in / sign up toggle ─────────────────────────────────────────────────
|
||||
|
||||
test('[free] initial button says "Create account"', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=free');
|
||||
await expect(page.locator('.checkout-email-btn')).toContainText('Create account');
|
||||
});
|
||||
|
||||
test('[free] clicking "Sign in" link changes button text to "Sign in"', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=free');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await page.click('a[onclick*="showSignIn"]');
|
||||
await expect(page.locator('.checkout-email-btn')).toContainText('Sign in');
|
||||
});
|
||||
|
||||
test('[free] divider label changes for email mode', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=free');
|
||||
await expect(page.locator('#auth-divider-label')).toContainText(/email|account/i);
|
||||
});
|
||||
|
||||
// ─── Mocked free-plan auth flows ──────────────────────────────────────────────
|
||||
|
||||
test('[free] successful sign-up → payment-section shown, auth-section hidden', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockSignUpSuccess(page);
|
||||
await page.goto('/checkout?plan=free');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await page.fill('#auth-email', 'newuser@example.com');
|
||||
await page.fill('#auth-password', 'password123');
|
||||
await page.locator('.checkout-email-btn').click();
|
||||
|
||||
await expect(page.locator('#payment-section')).toBeVisible({ timeout: 6000 });
|
||||
await expect(page.locator('#auth-section')).toBeHidden();
|
||||
});
|
||||
|
||||
test('[free] sign-up email-confirm-required → shows check-email message', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockSignUpEmailConfirmRequired(page);
|
||||
await page.goto('/checkout?plan=free');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await page.fill('#auth-email', 'confirm@example.com');
|
||||
await page.fill('#auth-password', 'password123');
|
||||
await page.locator('.checkout-email-btn').click();
|
||||
|
||||
const msg = page.locator('#auth-message');
|
||||
await expect(msg).toBeVisible({ timeout: 6000 });
|
||||
const text = (await msg.textContent()) ?? '';
|
||||
expect(text.toLowerCase()).toMatch(/email|confirm|check/);
|
||||
});
|
||||
|
||||
test('[free] sign-in success (via toggle) → payment-section shown', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockSignInSuccess(page);
|
||||
await page.goto('/checkout?plan=free');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await page.click('a[onclick*="showSignIn"]');
|
||||
await page.fill('#auth-email', 'existing@example.com');
|
||||
await page.fill('#auth-password', 'password123');
|
||||
await page.locator('.checkout-email-btn').click();
|
||||
|
||||
await expect(page.locator('#payment-section')).toBeVisible({ timeout: 6000 });
|
||||
});
|
||||
|
||||
test('[free] sign-in error → shows error message, form stays visible', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockSignInError(page, 'Invalid login credentials');
|
||||
await page.goto('/checkout?plan=free');
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
|
||||
await page.click('a[onclick*="showSignIn"]');
|
||||
await page.fill('#auth-email', 'wrong@example.com');
|
||||
await page.fill('#auth-password', 'wrongpassword');
|
||||
await page.locator('.checkout-email-btn').click();
|
||||
|
||||
const msg = page.locator('#auth-message');
|
||||
await expect(msg).toBeVisible({ timeout: 6000 });
|
||||
const text = (await msg.textContent()) ?? '';
|
||||
expect(text.toLowerCase()).toMatch(/invalid|credential|incorrect|error/);
|
||||
});
|
||||
|
||||
// ─── Mocked paid-plan auth flows ─────────────────────────────────────────────
|
||||
|
||||
for (const plan of ['professional', 'founding'] as const) {
|
||||
test(`[${plan}] existing session → auth badge visible with user info`, async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockExistingSession(page);
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
|
||||
const badge = page.locator('#auth-badge');
|
||||
await expect(badge).toBeVisible({ timeout: 6000 });
|
||||
const text = (await badge.textContent()) ?? '';
|
||||
expect(text).toMatch(/Playwright Tester|playwright@example\.com/);
|
||||
});
|
||||
|
||||
test(`[${plan}] existing session → buyer-email pre-filled`, async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockExistingSession(page);
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const el = document.getElementById('buyer-email') as HTMLInputElement | null;
|
||||
return el !== null && el.value.includes('@');
|
||||
},
|
||||
{ timeout: 6000 }
|
||||
);
|
||||
const val = await page.locator('#buyer-email').inputValue();
|
||||
expect(val).toBe('playwright@example.com');
|
||||
});
|
||||
|
||||
test(`[${plan}] existing session → auth-section hidden`, async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockExistingSession(page);
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
|
||||
// After session is detected auth-section stays/becomes hidden
|
||||
await page.waitForTimeout(2000); // let JS run
|
||||
await expect(page.locator('#auth-section')).toBeHidden();
|
||||
});
|
||||
|
||||
test(`[${plan}] existing session → payment-section remains visible`, async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockExistingSession(page);
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
|
||||
await expect(page.locator('#payment-section')).toBeVisible({ timeout: 6000 });
|
||||
});
|
||||
|
||||
test(`[${plan}] no session → payment form immediately visible`, async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockNoSession(page);
|
||||
await page.goto(`/checkout?plan=${plan}`);
|
||||
|
||||
await expect(page.locator('#payment-section')).toBeVisible({ timeout: 4000 });
|
||||
await expect(page.locator('#payment-element')).toBeAttached();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── /api/payment-intent endpoint ────────────────────────────────────────────
|
||||
|
||||
test('POST /api/payment-intent free plan returns setup_mode (age verification)', async ({ request }) => {
|
||||
const res = await request.post('/api/payment-intent', {
|
||||
data: JSON.stringify({ plan: 'free', email: 'test@example.com' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
// Free plan creates a SetupIntent for age verification — must not 500
|
||||
expect(res.status()).toBeLessThan(500);
|
||||
if (res.status() === 200) {
|
||||
const body = await res.json();
|
||||
// Either setup_mode (success) or an error from Stripe (unconfigured env) — both valid
|
||||
expect('setup_mode' in body || 'client_secret' in body || 'error' in body).toBeTruthy();
|
||||
// Must NOT return the old no_payment_required flag
|
||||
expect(body.no_payment_required).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /api/payment-intent professional returns client_secret or config error (not 500)', async ({ request }) => {
|
||||
const res = await request.post('/api/payment-intent', {
|
||||
data: JSON.stringify({ plan: 'professional', email: 'test@example.com', name: 'Test User' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(res.status()).toBeLessThan(500);
|
||||
if (res.status() === 200) {
|
||||
const body = await res.json();
|
||||
expect('client_secret' in body || 'error' in body || 'setup_mode' in body).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /api/payment-intent founding returns client_secret or config error (not 500)', async ({ request }) => {
|
||||
const res = await request.post('/api/payment-intent', {
|
||||
data: JSON.stringify({ plan: 'founding', email: 'test@example.com', name: 'Test User' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(res.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('POST /api/payment-intent empty body returns 4xx or config error (not 500)', async ({ request }) => {
|
||||
const res = await request.post('/api/payment-intent', { data: {} });
|
||||
expect(res.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
// ─── /api/supabase-config CORS ────────────────────────────────────────────────
|
||||
|
||||
test('GET /api/supabase-config with allowed origin returns url + anon_key', async ({ request }) => {
|
||||
const res = await request.get('/api/supabase-config', {
|
||||
headers: { Origin: 'https://neurontechnologies.ai' },
|
||||
});
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toHaveProperty('url');
|
||||
expect(body).toHaveProperty('anon_key');
|
||||
expect(body.url).toMatch(/supabase/);
|
||||
});
|
||||
|
||||
test('GET /api/supabase-config with disallowed origin returns 403', async ({ request }) => {
|
||||
const res = await request.get('/api/supabase-config', {
|
||||
headers: { Origin: 'https://evil-attacker.com' },
|
||||
});
|
||||
expect(res.status()).toBe(403);
|
||||
});
|
||||
|
||||
// ─── Edge cases ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('[unknown plan] defaults gracefully — 200 and non-empty body', async ({ page }) => {
|
||||
const res = await page.goto('/checkout?plan=unknown');
|
||||
expect(res?.status()).toBe(200);
|
||||
const body = (await page.locator('body').textContent()) ?? '';
|
||||
expect(body.trim().length).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
test('[no plan param] checkout loads without error', async ({ page }) => {
|
||||
const res = await page.goto('/checkout');
|
||||
expect(res?.status()).toBe(200);
|
||||
await expect(page.locator('body')).not.toBeEmpty();
|
||||
});
|
||||
|
||||
test('[checkout] page has no JS console errors on load (professional)', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
await page.goto('/checkout?plan=professional');
|
||||
await page.waitForTimeout(2000);
|
||||
// Filter out known third-party noise (Stripe, Supabase unreachable in test env)
|
||||
const criticalErrors = errors.filter(
|
||||
(e) =>
|
||||
!e.includes('stripe') &&
|
||||
!e.includes('Stripe') &&
|
||||
!e.includes('supabase') &&
|
||||
!e.includes('Failed to fetch') &&
|
||||
!e.includes('net::ERR') &&
|
||||
!e.includes('Content Security Policy')
|
||||
);
|
||||
expect(criticalErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[checkout] page has no JS console errors on load (free)', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => errors.push(err.message));
|
||||
await page.goto('/checkout?plan=free');
|
||||
await page.waitForTimeout(2000);
|
||||
const criticalErrors = errors.filter(
|
||||
(e) =>
|
||||
!e.includes('stripe') &&
|
||||
!e.includes('Stripe') &&
|
||||
!e.includes('supabase') &&
|
||||
!e.includes('Failed to fetch') &&
|
||||
!e.includes('net::ERR') &&
|
||||
!e.includes('Content Security Policy')
|
||||
);
|
||||
expect(criticalErrors).toHaveLength(0);
|
||||
});
|
||||
@@ -0,0 +1,632 @@
|
||||
/**
|
||||
* checkout-stripe.spec.ts — Stripe Payment Element + checkout submit flow tests.
|
||||
*
|
||||
* Covers:
|
||||
* - Stripe.js script presence and NEURON_CFG shape
|
||||
* - submit-btn starts disabled; enabled after Stripe element is ready
|
||||
* - payment-message div for error display
|
||||
* - Founding: attestation checkbox + attest-warn guard
|
||||
* - Professional: charge timing radio buttons (now/later)
|
||||
* - buyer-name + buyer-email validation on submit
|
||||
* - Mocked full payment flow: /api/payment-intent + mock Stripe.js
|
||||
* - Setup mode (professional, timing=later): label switches to "Save my card"
|
||||
* - Decline handling: payment-message shows Stripe error
|
||||
* - /api/payment-intent endpoint contracts
|
||||
* - /api/link-customer endpoint exists and handles requests
|
||||
* - /api/attest endpoint (founding plan)
|
||||
* - Success redirect target is /account?welcome=1
|
||||
*
|
||||
* Stripe mocking strategy:
|
||||
* addInitScript() injects window.Stripe BEFORE the page loads so checkout-stripe.js
|
||||
* picks it up. We also intercept /api/payment-intent to return a fake client_secret.
|
||||
* This lets us test DOM transitions, validation, and submit flow without real keys.
|
||||
*
|
||||
* For real test-card tests (4242...) the page must have a valid pk_test_ key.
|
||||
* Those tests are marked with [stripe-live] and are skipped when STRIPE_LIVE is not set.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
|
||||
const STRIPE_LIVE = process.env.STRIPE_LIVE === '1';
|
||||
|
||||
// ─── Mock helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Inject a mock window.Stripe before the page loads */
|
||||
async function injectMockStripe(page: Page, opts: {
|
||||
confirmResult?: { error?: { message: string } };
|
||||
declineMessage?: string;
|
||||
} = {}) {
|
||||
// Block the real Stripe CDN so it cannot override the addInitScript mock
|
||||
await page.route('https://js.stripe.com/**', (route) => route.abort());
|
||||
await page.addInitScript((o) => {
|
||||
(window as any).Stripe = function (_key: string) {
|
||||
const confirmResult = o.declineMessage
|
||||
? { error: { message: o.declineMessage } }
|
||||
: (o.confirmResult ?? {});
|
||||
|
||||
return {
|
||||
elements: function () {
|
||||
return {
|
||||
create: function (_type: string) {
|
||||
return {
|
||||
mount: function (selector: string) {
|
||||
const container = document.querySelector(selector);
|
||||
if (container) {
|
||||
container.innerHTML =
|
||||
'<div id="stripe-mock-mounted" style="padding:1rem;border:1px solid #ccc;font-size:.875rem">Mock payment element</div>';
|
||||
}
|
||||
// Fire 'ready' via the saved cb
|
||||
setTimeout(() => {
|
||||
const btn = document.getElementById('submit-btn');
|
||||
if (btn) btn.disabled = false;
|
||||
const ld = document.querySelector('.checkout-element-loading');
|
||||
if (ld) ld.remove();
|
||||
}, 100);
|
||||
},
|
||||
unmount: function () {},
|
||||
on: function (event: string, cb: () => void) {
|
||||
if (event === 'ready') setTimeout(cb, 100);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
confirmPayment: function () {
|
||||
return Promise.resolve(confirmResult);
|
||||
},
|
||||
confirmSetup: function () {
|
||||
return Promise.resolve(confirmResult);
|
||||
},
|
||||
};
|
||||
};
|
||||
}, opts);
|
||||
}
|
||||
|
||||
/** Mock /api/payment-intent to return a fake client_secret */
|
||||
async function mockPaymentIntent(page: Page, overrides: Record<string, unknown> = {}) {
|
||||
await page.route('/api/payment-intent', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
client_secret: 'pi_test_fake_secret_playwright_123',
|
||||
id: 'pi_test_fake_playwright_123',
|
||||
plan: 'professional',
|
||||
...overrides,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function mockPaymentIntentSetupMode(page: Page) {
|
||||
await page.route('/api/payment-intent', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
client_secret: 'seti_test_fake_secret_playwright_123',
|
||||
id: 'seti_test_fake_playwright_123',
|
||||
plan: 'professional',
|
||||
setup_mode: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function mockSupabaseConfig(page: Page) {
|
||||
await page.route('/api/supabase-config', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ url: 'https://xyzfaketest.supabase.co', anon_key: 'fake-key' }),
|
||||
})
|
||||
);
|
||||
// Supabase getUser() call on no-session returns 401 so the else branch runs:
|
||||
// "for paid plans, call window.initStripe('', '')" immediately.
|
||||
await page.route('https://xyzfaketest.supabase.co/auth/v1/user', (route) =>
|
||||
route.fulfill({
|
||||
status: 401,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'not_authenticated' }),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page structure — Stripe-specific ─────────────────────────────────────────
|
||||
|
||||
test('[professional] Stripe.js script tag present in page', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
const stripeScript = page.locator('script[src*="stripe.com"]');
|
||||
await expect(stripeScript).toBeAttached();
|
||||
});
|
||||
|
||||
test('[founding] Stripe.js script tag present in page', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=founding');
|
||||
const stripeScript = page.locator('script[src*="stripe.com"]');
|
||||
await expect(stripeScript).toBeAttached();
|
||||
});
|
||||
|
||||
test('[free] Stripe.js is loaded (used for age verification SetupIntent)', async ({ page }) => {
|
||||
// Free plan now creates a SetupIntent for age verification
|
||||
await page.goto('/checkout?plan=free');
|
||||
const stripeScript = page.locator('script[src*="stripe.com"]');
|
||||
await expect(stripeScript).toBeAttached();
|
||||
});
|
||||
|
||||
test('[professional] NEURON_CFG.plan is set to "professional"', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
const plan = await page.evaluate(() => (window as any).NEURON_CFG?.plan);
|
||||
expect(plan).toBe('professional');
|
||||
});
|
||||
|
||||
test('[founding] NEURON_CFG.plan is set to "founding"', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=founding');
|
||||
const plan = await page.evaluate(() => (window as any).NEURON_CFG?.plan);
|
||||
expect(plan).toBe('founding');
|
||||
});
|
||||
|
||||
test('[professional] NEURON_CFG.pub_key is present (may be empty if unconfigured)', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
const cfg = await page.evaluate(() => (window as any).NEURON_CFG);
|
||||
expect(cfg).not.toBeNull();
|
||||
expect('pub_key' in cfg).toBeTruthy();
|
||||
});
|
||||
|
||||
test('[professional] submit-btn starts disabled', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
const btn = page.locator('#submit-btn');
|
||||
await expect(btn).toBeAttached();
|
||||
// Before Stripe initialises, button is disabled
|
||||
const isDisabled = await btn.getAttribute('disabled');
|
||||
expect(isDisabled).not.toBeNull();
|
||||
});
|
||||
|
||||
test('[professional] payment-message div starts hidden', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
await expect(page.locator('#payment-message')).toBeHidden();
|
||||
});
|
||||
|
||||
test('[professional] buyer-name input is present and fillable', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
await expect(page.locator('#buyer-name')).toBeAttached();
|
||||
await page.fill('#buyer-name', 'Test User');
|
||||
expect(await page.locator('#buyer-name').inputValue()).toBe('Test User');
|
||||
});
|
||||
|
||||
test('[professional] buyer-email input is present and fillable', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
await expect(page.locator('#buyer-email')).toBeAttached();
|
||||
await page.fill('#buyer-email', 'test@example.com');
|
||||
expect(await page.locator('#buyer-email').inputValue()).toBe('test@example.com');
|
||||
});
|
||||
|
||||
// ─── Founding-specific ────────────────────────────────────────────────────────
|
||||
|
||||
test('[founding] attestation checkbox is present', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=founding');
|
||||
await expect(page.locator('#founding-attest-cb')).toBeAttached();
|
||||
});
|
||||
|
||||
test('[founding] attestation checkbox starts unchecked', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=founding');
|
||||
const checked = await page.locator('#founding-attest-cb').isChecked();
|
||||
expect(checked).toBe(false);
|
||||
});
|
||||
|
||||
test('[founding] attest-warn div is present (shown on submit without checking)', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=founding');
|
||||
await expect(page.locator('#attest-warn')).toBeAttached();
|
||||
await expect(page.locator('#attest-warn')).toBeHidden();
|
||||
});
|
||||
|
||||
test('[founding] attestation text contains expected copy', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=founding');
|
||||
const attestText = (await page.locator('#founding-attestation').textContent()) ?? '';
|
||||
expect(attestText).toContain('good faith');
|
||||
expect(attestText.toLowerCase()).toContain('founding member');
|
||||
});
|
||||
|
||||
test('[founding] submit without attestation shows attest-warn', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockPaymentIntent(page, { plan: 'founding' });
|
||||
await injectMockStripe(page);
|
||||
await page.goto('/checkout?plan=founding');
|
||||
|
||||
// Wait for Stripe mock to enable the submit button
|
||||
await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 5000 });
|
||||
|
||||
await page.fill('#buyer-name', 'Test Founder');
|
||||
await page.fill('#buyer-email', 'founder@example.com');
|
||||
// Do NOT check the attestation checkbox
|
||||
await page.locator('#payment-form').dispatchEvent('submit');
|
||||
|
||||
await expect(page.locator('#attest-warn')).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('[founding] submit WITH attestation does not show attest-warn', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockPaymentIntent(page, { plan: 'founding' });
|
||||
await injectMockStripe(page);
|
||||
// Mock attest endpoint
|
||||
await page.route('/api/attest', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{"ok":true}' })
|
||||
);
|
||||
// Mock link-customer
|
||||
await page.route('/api/link-customer', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{"linked":true}' })
|
||||
);
|
||||
await page.goto('/checkout?plan=founding');
|
||||
|
||||
await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 5000 });
|
||||
await page.fill('#buyer-name', 'Test Founder');
|
||||
await page.fill('#buyer-email', 'founder@example.com');
|
||||
await page.locator('#founding-attest-cb').check();
|
||||
await page.locator('#payment-form').dispatchEvent('submit');
|
||||
|
||||
// attest-warn should NOT appear
|
||||
await page.waitForTimeout(500);
|
||||
await expect(page.locator('#attest-warn')).toBeHidden();
|
||||
});
|
||||
|
||||
// ─── Professional charge timing ───────────────────────────────────────────────
|
||||
|
||||
test('[professional] charge timing section is present', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
await expect(page.locator('#timing-now')).toBeAttached();
|
||||
await expect(page.locator('#timing-later')).toBeAttached();
|
||||
});
|
||||
|
||||
test('[professional] "charge now" radio is selected by default', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
expect(await page.locator('#timing-now').isChecked()).toBe(true);
|
||||
expect(await page.locator('#timing-later').isChecked()).toBe(false);
|
||||
});
|
||||
|
||||
test('[professional] selecting "later" changes radio state', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
await page.locator('#timing-later').check();
|
||||
expect(await page.locator('#timing-later').isChecked()).toBe(true);
|
||||
expect(await page.locator('#timing-now').isChecked()).toBe(false);
|
||||
});
|
||||
|
||||
test('[professional] setup_mode label shows "Save my card" text', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockPaymentIntentSetupMode(page);
|
||||
await injectMockStripe(page);
|
||||
await page.goto('/checkout?plan=professional');
|
||||
|
||||
// initStripe is called by checkout-auth.el when no session → immediately for paid plans
|
||||
// Wait for the submit label to update
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const el = document.getElementById('submit-label');
|
||||
return el && el.textContent && el.textContent.toLowerCase().includes('save');
|
||||
},
|
||||
{ timeout: 6000 }
|
||||
);
|
||||
|
||||
const labelText = (await page.locator('#submit-label').textContent()) ?? '';
|
||||
expect(labelText.toLowerCase()).toContain('save');
|
||||
});
|
||||
|
||||
test('[founding] no charge timing section (one-time payment only)', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=founding');
|
||||
const timingNow = page.locator('#timing-now');
|
||||
const count = await timingNow.count();
|
||||
expect(count).toBe(0);
|
||||
});
|
||||
|
||||
// ─── Mocked payment flow — full Stripe mock ───────────────────────────────────
|
||||
|
||||
test('[professional] Stripe mock: payment element mounts after initStripe', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockPaymentIntent(page);
|
||||
await injectMockStripe(page);
|
||||
await page.goto('/checkout?plan=professional');
|
||||
|
||||
// After initStripe() runs (checkout-auth triggers it immediately for paid plans with no session)
|
||||
await expect(page.locator('#stripe-mock-mounted')).toBeAttached({ timeout: 8000 });
|
||||
});
|
||||
|
||||
test('[professional] Stripe mock: submit-btn enabled after element ready', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockPaymentIntent(page);
|
||||
await injectMockStripe(page);
|
||||
await page.goto('/checkout?plan=professional');
|
||||
|
||||
await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 8000 });
|
||||
});
|
||||
|
||||
test('[professional] submit without name shows error message', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockPaymentIntent(page);
|
||||
await injectMockStripe(page);
|
||||
await page.goto('/checkout?plan=professional');
|
||||
|
||||
await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 8000 });
|
||||
|
||||
// Fill email only, no name
|
||||
await page.fill('#buyer-email', 'test@example.com');
|
||||
await page.locator('#payment-form').dispatchEvent('submit');
|
||||
|
||||
const msg = page.locator('#payment-message');
|
||||
await expect(msg).toBeVisible({ timeout: 3000 });
|
||||
const text = (await msg.textContent()) ?? '';
|
||||
expect(text.toLowerCase()).toMatch(/name|email/);
|
||||
});
|
||||
|
||||
test('[professional] submit without email shows error message', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockPaymentIntent(page);
|
||||
await injectMockStripe(page);
|
||||
await page.goto('/checkout?plan=professional');
|
||||
|
||||
await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 8000 });
|
||||
|
||||
// Fill name only, no email
|
||||
await page.fill('#buyer-name', 'Test User');
|
||||
await page.locator('#payment-form').dispatchEvent('submit');
|
||||
|
||||
const msg = page.locator('#payment-message');
|
||||
await expect(msg).toBeVisible({ timeout: 3000 });
|
||||
});
|
||||
|
||||
test('[professional] Stripe decline: payment-message shows decline text', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockPaymentIntent(page);
|
||||
await injectMockStripe(page, { declineMessage: 'Your card was declined.' });
|
||||
await page.route('/api/link-customer', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{"linked":true}' })
|
||||
);
|
||||
await page.goto('/checkout?plan=professional');
|
||||
|
||||
await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 8000 });
|
||||
await page.fill('#buyer-name', 'Test Buyer');
|
||||
await page.fill('#buyer-email', 'buyer@example.com');
|
||||
|
||||
await page.locator('#payment-form').dispatchEvent('submit');
|
||||
|
||||
const msg = page.locator('#payment-message');
|
||||
await expect(msg).toBeVisible({ timeout: 5000 });
|
||||
const text = (await msg.textContent()) ?? '';
|
||||
expect(text.toLowerCase()).toMatch(/declined|failed|error|card/);
|
||||
});
|
||||
|
||||
test('[professional] successful payment: submit-btn shows spinner then loading state', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await mockPaymentIntent(page);
|
||||
await injectMockStripe(page, { confirmResult: {} }); // no error = success → redirect
|
||||
await page.route('/api/link-customer', (route) =>
|
||||
route.fulfill({ status: 200, contentType: 'application/json', body: '{"linked":true}' })
|
||||
);
|
||||
// Intercept the redirect to /account
|
||||
await page.route('**/account**', (route) => route.fulfill({ status: 200, body: 'ok' }));
|
||||
|
||||
await page.goto('/checkout?plan=professional');
|
||||
await expect(page.locator('#submit-btn')).not.toBeDisabled({ timeout: 8000 });
|
||||
await page.fill('#buyer-name', 'Test Buyer');
|
||||
await page.fill('#buyer-email', 'buyer@example.com');
|
||||
|
||||
// Verify loading state is triggered on submit
|
||||
const submitBtn = page.locator('#submit-btn');
|
||||
await page.locator('#payment-form').dispatchEvent('submit');
|
||||
// setLoading(true) disables the button — verify it transitions
|
||||
await expect(submitBtn).toBeDisabled({ timeout: 2000 }).catch(() => {
|
||||
// May redirect before we can check — that's also success
|
||||
});
|
||||
});
|
||||
|
||||
// ─── /api/payment-intent endpoint contracts ───────────────────────────────────
|
||||
|
||||
test('POST /api/payment-intent free plan returns setup_mode (age verification)', async ({ request }) => {
|
||||
const res = await request.post('/api/payment-intent', {
|
||||
data: JSON.stringify({ plan: 'free', email: 'test@example.com' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
// Free plan creates a SetupIntent for age verification — must not 500
|
||||
expect(res.status()).toBeLessThan(500);
|
||||
if (res.status() === 200) {
|
||||
const body = await res.json();
|
||||
expect('setup_mode' in body || 'client_secret' in body || 'error' in body).toBeTruthy();
|
||||
expect(body.no_payment_required).toBeFalsy();
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /api/payment-intent professional returns client_secret or stripe error (not 500)', async ({ request }) => {
|
||||
const res = await request.post('/api/payment-intent', {
|
||||
data: JSON.stringify({ plan: 'professional', email: 'test@example.com', name: 'Test', timing: 'now' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(res.status()).toBeLessThan(500);
|
||||
if (res.status() === 200) {
|
||||
const body = await res.json();
|
||||
expect('client_secret' in body || 'error' in body || 'setup_mode' in body).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /api/payment-intent professional timing=later returns setup_mode flag', async ({ request }) => {
|
||||
const res = await request.post('/api/payment-intent', {
|
||||
data: JSON.stringify({ plan: 'professional', email: 'test@example.com', name: 'Test', timing: 'later' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(res.status()).toBeLessThan(500);
|
||||
if (res.status() === 200) {
|
||||
const body = await res.json();
|
||||
if ('client_secret' in body) {
|
||||
// Stripe configured: setup_mode should be true for timing=later
|
||||
expect(body.setup_mode).toBeTruthy();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /api/payment-intent founding returns client_secret or error (not 500)', async ({ request }) => {
|
||||
const res = await request.post('/api/payment-intent', {
|
||||
data: JSON.stringify({ plan: 'founding', email: 'test@example.com', name: 'Founder' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(res.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
test('POST /api/payment-intent empty body returns 4xx not 500', async ({ request }) => {
|
||||
const res = await request.post('/api/payment-intent', { data: {} });
|
||||
expect(res.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
// ─── /api/link-customer endpoint ─────────────────────────────────────────────
|
||||
|
||||
test('POST /api/link-customer exists and handles request (not 404/500)', async ({ request }) => {
|
||||
const res = await request.post('/api/link-customer', {
|
||||
data: JSON.stringify({
|
||||
pi_id: 'pi_test_fake',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
plan: 'professional',
|
||||
timing: 'now',
|
||||
mode: 'payment',
|
||||
supabase_user_id: '',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
// Should exist and not 500
|
||||
expect(res.status()).not.toBe(404);
|
||||
expect(res.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
// ─── /api/attest endpoint (founding) ─────────────────────────────────────────
|
||||
|
||||
test('POST /api/attest founding exists and handles request (not 500)', async ({ request }) => {
|
||||
const res = await request.post('/api/attest', {
|
||||
data: JSON.stringify({
|
||||
plan: 'founding',
|
||||
name: 'Test Founder',
|
||||
email: 'founder@example.com',
|
||||
timestamp: new Date().toISOString(),
|
||||
attestation: 'I am joining as a genuine early user...',
|
||||
user_agent: 'Playwright/Test',
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(res.status()).toBeLessThan(500);
|
||||
});
|
||||
|
||||
// ─── /api/founding-count ──────────────────────────────────────────────────────
|
||||
|
||||
test('GET /api/founding-count returns remaining + sold + total', async ({ request }) => {
|
||||
const res = await request.get('/api/founding-count');
|
||||
expect(res.status()).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(typeof body.remaining === 'number' || 'remaining' in body).toBeTruthy();
|
||||
});
|
||||
|
||||
test('GET /api/founding-count: remaining is <= 1000', async ({ request }) => {
|
||||
const res = await request.get('/api/founding-count');
|
||||
if (res.status() === 200) {
|
||||
const body = await res.json();
|
||||
if (typeof body.remaining === 'number') {
|
||||
expect(body.remaining).toBeLessThanOrEqual(1000);
|
||||
expect(body.remaining).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Sold-out guard ───────────────────────────────────────────────────────────
|
||||
|
||||
test('[founding] payment-intent sold_out disables submit with sold-out message', async ({ page }) => {
|
||||
await mockSupabaseConfig(page);
|
||||
await page.route('/api/payment-intent', (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ error: 'sold_out' }),
|
||||
})
|
||||
);
|
||||
await injectMockStripe(page);
|
||||
await page.goto('/checkout?plan=founding');
|
||||
|
||||
// Wait for sold_out message to appear
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const msg = document.getElementById('payment-message');
|
||||
return msg && msg.style.display !== 'none' && msg.textContent && msg.textContent.includes('spot');
|
||||
},
|
||||
{ timeout: 8000 }
|
||||
);
|
||||
|
||||
const msg = page.locator('#payment-message');
|
||||
await expect(msg).toBeVisible();
|
||||
const text = (await msg.textContent()) ?? '';
|
||||
expect(text.toLowerCase()).toMatch(/sold out|spot|founding|professional/);
|
||||
|
||||
// Submit button should be disabled
|
||||
const btn = page.locator('#submit-btn');
|
||||
const isDisabled = await btn.getAttribute('disabled');
|
||||
expect(isDisabled).not.toBeNull();
|
||||
});
|
||||
|
||||
// ─── Live Stripe test-card tests (requires STRIPE_LIVE=1) ─────────────────────
|
||||
// These only run when the stage has a real pk_test_ key and Stripe is reachable.
|
||||
|
||||
test.describe('Stripe live test-card flows', () => {
|
||||
test.skip(!STRIPE_LIVE, 'Set STRIPE_LIVE=1 to run these against a configured test-mode stage');
|
||||
|
||||
test('[professional] test card 4242 redirects to /account?welcome=1', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
|
||||
// Wait for Stripe payment element iframe to mount
|
||||
const stripeFrame = page.frameLocator('iframe[title*="Secure payment"]');
|
||||
await expect(stripeFrame.locator('[placeholder*="1234"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await page.fill('#buyer-name', 'Playwright Tester');
|
||||
await page.fill('#buyer-email', 'playwright@neurontest.invalid');
|
||||
|
||||
// Fill card details inside Stripe iframe
|
||||
await stripeFrame.locator('[placeholder*="1234"]').fill('4242424242424242');
|
||||
await stripeFrame.locator('[placeholder="MM / YY"]').fill('12 / 30');
|
||||
await stripeFrame.locator('[placeholder="CVC"]').fill('123');
|
||||
await stripeFrame.locator('[placeholder="ZIP"]').fill('10001').catch(() => {}); // optional field
|
||||
|
||||
await page.locator('#submit-btn').click();
|
||||
await page.waitForURL('**/account**', { timeout: 30000 });
|
||||
expect(page.url()).toContain('welcome=1');
|
||||
});
|
||||
|
||||
test('[professional] test card 4000 0000 0000 0002 (decline) shows error', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=professional');
|
||||
|
||||
const stripeFrame = page.frameLocator('iframe[title*="Secure payment"]');
|
||||
await expect(stripeFrame.locator('[placeholder*="1234"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await page.fill('#buyer-name', 'Declined User');
|
||||
await page.fill('#buyer-email', 'declined@neurontest.invalid');
|
||||
|
||||
await stripeFrame.locator('[placeholder*="1234"]').fill('4000000000000002');
|
||||
await stripeFrame.locator('[placeholder="MM / YY"]').fill('12 / 30');
|
||||
await stripeFrame.locator('[placeholder="CVC"]').fill('123');
|
||||
|
||||
await page.locator('#submit-btn').click();
|
||||
|
||||
const msg = page.locator('#payment-message');
|
||||
await expect(msg).toBeVisible({ timeout: 15000 });
|
||||
const text = (await msg.textContent()) ?? '';
|
||||
expect(text.toLowerCase()).toMatch(/declined|failed|card/);
|
||||
});
|
||||
|
||||
test('[founding] test card 4242 + attestation → redirect to /account', async ({ page }) => {
|
||||
await page.goto('/checkout?plan=founding');
|
||||
|
||||
const stripeFrame = page.frameLocator('iframe[title*="Secure payment"]');
|
||||
await expect(stripeFrame.locator('[placeholder*="1234"]')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
await page.fill('#buyer-name', 'Founder Playwright');
|
||||
await page.fill('#buyer-email', 'founder@neurontest.invalid');
|
||||
await page.locator('#founding-attest-cb').check();
|
||||
|
||||
await stripeFrame.locator('[placeholder*="1234"]').fill('4242424242424242');
|
||||
await stripeFrame.locator('[placeholder="MM / YY"]').fill('12 / 30');
|
||||
await stripeFrame.locator('[placeholder="CVC"]').fill('123');
|
||||
|
||||
await page.locator('#submit-btn').click();
|
||||
await page.waitForURL('**/account**', { timeout: 30000 });
|
||||
expect(page.url()).toContain('welcome=1');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user