Compare commits

...

101 Commits

Author SHA1 Message Date
will.anderson 8f91a80be7 Fix account page SIGSEGV: el_meta extern arity mismatch (1-arg → 2-arg)
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m42s
el_meta was declared as el_meta(attrs) in account.el but the runtime
C implementation expects el_meta(name, content). Same arity crash as
the prior el_img fix — runtime passed garbage on the stack.
2026-05-13 12:41:37 -05:00
will.anderson d7bb92c37f Merge pull request 'Fix account page SIGSEGV: el_img extern arity mismatch' (#144) from fix/magic-link-flow into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m20s
2026-05-13 17:20:06 +00:00
will.anderson 4ca793ee2c Fix account page SIGSEGV: el_img extern signature mismatch
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m46s
account.el declared el_img with 1 arg (attrs only) while the runtime
implementation and all other files use 3 args (src, alt, attrs).
The arity mismatch caused the server to crash with signal 11 on every
request — TCP probe passed (bind was fine) but first HTTP hit segfaulted.
2026-05-13 12:19:43 -05:00
will.anderson 4123f6d5f1 Merge pull request 'Fix free plan checkout: SetupIntent instead of $0 PaymentIntent' (#142) from fix/free-plan-setup-intent into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 1m59s
Fix free plan checkout: SetupIntent instead of $0 PaymentIntent
2026-05-13 17:12:35 +00:00
will.anderson 69f348d48b Fix free plan checkout: use SetupIntent instead of $0 PaymentIntent
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m40s
Stripe rejects amount=0 PaymentIntents. Free plan age verification should
use a SetupIntent (no charge, saves payment method). The JS already handles
setup_mode:true by calling stripe.confirmSetup instead of confirmPayment.
Mirrors the existing professional-later SetupIntent path.
2026-05-13 12:12:10 -05:00
will.anderson 3d635505bc Merge pull request 'Fix about page: restore raw string syntax to fix El tokenizer rendering' (#140) from fix/about-rendering into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m17s
Fix about page: restore raw string syntax to fix El tokenizer rendering
2026-05-13 16:46:43 +00:00
will.anderson 675c467a74 Fix about page rendering: restore raw string syntax to fix El tokenizer mangling
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m45s
The El HTML template parser (native { } syntax introduced in 5cb13d6) strips
spaces from text nodes, drops & from HTML entities (' → 39;), and breaks
hyphenated attribute names (aria-label → aria - label). All other component
files were already converted to the extern el_*() function style in 2553a6b
which is immune to this issue. about.el was the only page still using the
broken template syntax. Restoring the raw string return style fixes all
rendering defects on /about.
2026-05-13 11:46:13 -05:00
will.anderson 708dfd06cb Merge pull request 'Fix magic-link sign-in: implicit flow + redirect to /account' (#138) from fix/magic-link-flow into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m14s
2026-05-12 19:33:03 +00:00
will.anderson b6aecd7d89 Fix magic-link sign-in: implicit flow + redirect to /account
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m42s
account-auth.el was using flowType:'pkce' while account-dashboard.el
uses 'implicit'. After the OTP redirect, the dashboard's implicit
client couldn't exchange the PKCE code — so the sign-in silently
failed. Fix: match implicit flow across both clients.

Also adds emailRedirectTo so the link lands on /account instead of
the site root.
2026-05-12 14:32:39 -05:00
will.anderson bb98f76179 Merge pull request 'Fix duplicate Stripe customers and attestation plan bypass' (#136) from fix/stripe-dedup-attestation into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m4s
2026-05-12 19:23:33 +00:00
will.anderson 0fdbba82e0 Fix duplicate Stripe customers and attestation plan bypass
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m29s
Two bugs:

1. Double-Bearer auth on Stripe customer search. Both checkout paths
   were passing "Bearer sk_..." to http_get_auth(), which prepends
   another "Bearer " — producing "Bearer Bearer sk_..." which Stripe
   rejects as 401. Customer lookup always failed, so a new Stripe
   customer was created on every checkout page load. Fix: pass the
   raw key to http_get_auth(), letting it handle the prefix.

2. /api/attest blindly wrote whatever plan the client submitted to
   the waitlist, letting anyone POST plan=founding and get founding
   member access without paying. Fix: server ignores the client-
   submitted plan and always writes plan=waitlist. Founding access
   requires Stripe payment — the attestation form is waitlist-only.
2026-05-12 14:10:04 -05:00
will.anderson 9e0451be41 Merge pull request 'Fix initStripe load order, subscription webhook email, chat textarea' (#134) from fix/webhook-initstripe-textarea into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m11s
Merge PR #134: Fix initStripe load order, webhook user_metadata, chat textarea
2026-05-12 17:49:26 +00:00
will.anderson 99ed8b85f7 Fix webhook failing to update plan for pre-existing Supabase users
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m17s
supabase_admin_invite re-sends a magic link for users who already have
an account (e.g. signed up via attestation before paying) but does not
touch their user_metadata — leaving plan as "free" after purchase.

Fix: add supabase_admin_update_user (PUT /auth/v1/admin/users/{id})
and call it after every invite so user_metadata is always stamped with
the correct plan, name, and stripe_customer_id. Idempotent for new and
returning users.

Also fix waitlist_upsert to use on_conflict=email,plan so the upsert
works for users who already have a waitlist row from attestation,
rather than silently failing on duplicate key.
2026-05-12 12:31:45 -05:00
will.anderson c72127032e Fix initStripe load order, subscription webhook email extraction, chat textarea UX
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m18s
- checkout.el: swap stripe_el_script before auth_script so initStripe is
  defined when Supabase auth fires onAuthStateChange on page load
- main.el: fix Stripe webhook email extraction for checkout.session.completed
  (subscription) events — customer_details is nested at data.object level,
  not at root; previous code only worked for payment_intent.succeeded
- page_close.c: replace <input type="text"> with <textarea rows="1"> in
  the chat widget input row so long questions are visible as you type
- page_css.c: update #neuron-demo-text CSS for textarea (resize:none,
  overflow:hidden, min/max-height, align-items:flex-end on row)
- chat-widget.el: add auto-resize event listener (grows up to ~4 lines),
  reset height to auto on send
2026-05-12 12:22:59 -05:00
will.anderson 869dcec0bb Merge pull request 'Fix intro greeting and load history on return visits' (#132) from fix/greeting-history-load into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m8s
2026-05-12 14:03:11 +00:00
will.anderson 1786aeeff6 Fix intro greeting tone and load history on return visits
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m36s
- soul-demo.c: rewrite intro system prompt — remove 'to see if you are real'
  and 'say something true about who you are' which were producing alive/sentient
  language. New prompt: friendly hello, ask how they're doing, explicitly no
  alive/sentient/experiencing anything lines.
- chat-widget: Turnstile callback now replays existing session history instead
  of always firing a new greeting — returning users within the same day see
  their conversation, not a duplicate hello.
2026-05-12 09:02:55 -05:00
will.anderson e938cb69fc Merge pull request 'Fix question counter, rate-limit timer, admin reset API, pricing clarity' (#130) from fix/question-timer-pricing-clarity into dev
Dev — Build & local smoke test / build-smoke (push) Failing after 1m12s
2026-05-12 13:53:04 +00:00
will.anderson 4f6df973cb Fix question counter daily reset, rate-limit timer, and founding member pricing clarity
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 2m0s
- chat-widget: session.count now resets on new UTC day (keeps client in sync with server's daily quota reset)
- chat-widget: fix rate-limit timer — wrong element IDs (neuron-demo-msgs → neuron-demo-messages) and wrong class (.neuron-msg-ai → .demo-msg-ai) meant the countdown never updated
- chat-widget: remove btn.disabled=false that immediately re-enabled the send button after rate-limiting
- main.el: add POST /api/admin/reset-rate-limits endpoint (requires NEURON_ADMIN_TOKEN, deletes all demo_rate_limits rows)
- pricing.el: clarify founding member card — software updates are free forever, inference is pay-per-use at founding member rate
2026-05-12 08:52:34 -05:00
will.anderson be849c608e Merge pull request 'fix: binary asset serving + checkout centering' (#128) from fix/binary-assets-checkout-layout into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m7s
2026-05-12 01:10:58 +00:00
will.anderson 5ce5f4a8be fix: binary asset serving + checkout centering
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m34s
el_runtime: http_response() JSON-encoded the body via jb_emit_escaped(),
which stops at the first null byte. PNG/binary files contain null bytes
at byte 8 (IHDR chunk length), so only 8 bytes were served — browsers
received a corrupt/truncated image and showed broken icons.

Fix: when _tl_fs_read_len > 0 (binary fs_read), copy raw bytes into a
thread-local side-channel (_tl_binary_body/_tl_binary_size) and write
the sentinel "__el_binary__" into the envelope body field. http_send_response()
detects the sentinel and substitutes the real bytes for sending.

checkout.el: .checkout-shell, .checkout-summary, and .checkout-form-wrap
had no CSS, leaving the page left-aligned and single-column. Added grid
layout (2-col desktop, 1-col mobile), max-width centering, and sticky
order summary.
2026-05-11 20:10:19 -05:00
will.anderson 6e425da63e Merge pull request 'fix: remove setup_future_usage from $0 PaymentIntent' (#127) from fix/zero-pi-setup-future-usage into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m2s
2026-05-12 00:55:56 +00:00
will.anderson 37c7dca30d Fix $0 PaymentIntent: remove setup_future_usage (invalid with amount=0)
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m32s
2026-05-11 19:55:44 -05:00
will.anderson 73c435eb90 Merge pull request 'fix: free plan $0 PaymentIntent for age verification' (#125) from fix/free-plan-payment-intent into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m14s
2026-05-12 00:46:01 +00:00
will.anderson 7be2b49300 Free plan: use $0 PaymentIntent instead of SetupIntent for age verification
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m45s
All plans now use the same payment intent flow. Free creates a $0 PI
with payment_method_types[]=card and setup_future_usage=off_session.
No charge, card saved. Removes setup_mode=true for free plan.

Fix submit button label: show 'Verify age & get started' for free
instead of 'Complete purchase'. Retire checkout-free.el.
2026-05-11 19:45:39 -05:00
will.anderson e5c05cbece Merge branch 'dev' of git.neuralplatform.ai:neuron-technologies/neuron-web into dev 2026-05-11 19:16:18 -05:00
will.anderson c7f4d0248c Merge pull request 'fix: free checkout Stripe SetupIntent + remove no-card-required copy' (#123) from fix/free-checkout-stripe into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m14s
2026-05-12 00:16:12 +00:00
will.anderson 4c5d67c321 fix: free checkout requires Stripe SetupIntent for age verification; update copy
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m43s
2026-05-11 19:15:57 -05:00
will.anderson 9feb9e24b6 Merge branch 'dev' of git.neuralplatform.ai:neuron-technologies/neuron-web into dev 2026-05-11 18:56:43 -05:00
will.anderson 941faccb3f Merge pull request 'fix: force full build when no diff or stage-latest missing' (#121) from fix/stage-full-build into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m16s
2026-05-11 23:56:35 +00:00
will.anderson 6a040afcc5 fix: force full build when no diff or stage-latest missing
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m43s
2026-05-11 18:56:18 -05:00
will.anderson a346a2197e Merge branch 'dev' of git.neuralplatform.ai:neuron-technologies/neuron-web into dev 2026-05-11 18:46:33 -05:00
will.anderson e268b424f5 Merge pull request 'ci: touch dist to trigger stage rebuild' (#119) from ci/touch-dist into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m2s
2026-05-11 23:46:17 +00:00
will.anderson 20029d36df ci: touch dist to trigger stage rebuild
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m35s
2026-05-11 18:45:57 -05:00
will.anderson 54d48ed679 ci: trigger rebuild after registry cleanup 2026-05-11 17:33:53 -05:00
will.anderson 28f9ecd1a3 Merge pull request 'fix: heading and button elements pass children unescaped' (#113) from fix/force-full-rebuild into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m0s
2026-05-11 22:21:41 +00:00
will.anderson b6bb25e79e fix: heading and button elements pass children unescaped
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m32s
el_h1/h2/h3/h4 and el_button were calling el_escape() on their
content, converting any HTML children (e.g. <span class="gold">)
into literal entity text on screen.

These functions accept composed HTML children, not raw text — they
should pass the argument through like el_div/el_p/el_span do.
el_text, el_attr, el_title, el_textarea, and el_img keep escaping
(they handle actual text/attribute values, not HTML children).
2026-05-11 17:21:19 -05:00
will.anderson 5812cb0452 Merge pull request 'Force full El rebuild — strip CGI content from base image' (#110) from fix/force-full-rebuild into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m13s
2026-05-11 21:43:09 +00:00
will.anderson c99923da1b Force full El rebuild — strip CGI content from base image
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m39s
2026-05-11 16:42:41 -05:00
will.anderson 4e35cbe841 Merge pull request 'Also skip El rebuild for workflow-only changes' (#107) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m52s
2026-05-11 20:46:51 +00:00
will.anderson 62385b53c2 Also skip El rebuild for .gitea/ workflow-only changes
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 2m16s
Workflow file changes don't require rebuilding the El binary. Without
this, merging workflow fixes to main triggers a full El build which
hits a codegen issue in the CI version of elb.
2026-05-11 15:46:37 -05:00
will.anderson 952b03737b Merge pull request 'Skip El rebuild for migration/script/test-only changes' (#105) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m54s
2026-05-11 20:45:14 +00:00
will.anderson d2628ec42e Skip El rebuild for migration/script/test-only changes
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 2m17s
migrations/, scripts/, tests/ changes don't require rebuilding the El
binary. Classifying them as asset-only avoids spurious full builds that
regenerate dist/*.c and can hit codegen incompatibilities.
2026-05-11 15:44:59 -05:00
will.anderson d598fb7b10 Merge pull request 'Update CORS test: no-Origin requests are allowed' (#103) from fix/stage-ci-paths into dev 2026-05-11 20:22:30 +00:00
will.anderson 1eeb8df04b Update CORS test: no-Origin requests are allowed (same-origin fix)
Same-origin browser fetches don't send Origin. The server correctly
allows them — blocking was the bug that broke checkout. Update the
test to match the fixed behavior.
2026-05-11 15:22:22 -05:00
will.anderson 9e5d7e55ab Merge pull request 'Fix stage source guard: fetch origin/dev before ancestry check' (#101) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m48s
2026-05-11 19:09:33 +00:00
will.anderson 5d3b1a3e20 Fix stage source guard: fetch origin/dev before ancestry check
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 2m22s
The shallow clone (fetch-depth: 2) doesn't include origin/dev, so
git merge-base --is-ancestor was silently failing. Fetch dev with
depth=1 first so custom merge commit titles still pass the check.
2026-05-11 14:09:18 -05:00
will.anderson 1264e32577 Fix: idempotent migration policy creation 2026-05-11 18:56:36 +00:00
will.anderson 7f88414b40 Make migration policy creation idempotent
DROP POLICY IF EXISTS before CREATE POLICY so migrations can be
re-applied to a DB that already has the policy (e.g. demo_config
was manually applied before migration tracking was set up).
2026-05-11 13:56:12 -05:00
will.anderson b3ce6c3e64 Merge pull request 'Fix CI migration step: script file instead of heredoc' (#97) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m3s
Merge PR #97
2026-05-11 18:34:01 +00:00
will.anderson adbdfd3e90 Fix CI migration step: extract Python to scripts/run_migrations.py
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m36s
go-yaml (Gitea's parser) mishandles << inside block scalars, treating the
bash heredoc delimiter as a YAML merge key. Move the migration logic to a
standalone script called via python3 scripts/run_migrations.py.
2026-05-11 13:33:44 -05:00
will.anderson dd5fd2b3ce Merge pull request 'Fix supabase-config CORS: treat absent Origin as allowed' (#95) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 1m59s
Merge PR #95
2026-05-11 18:30:44 +00:00
will.anderson 617916134f Fix supabase-config CORS: treat absent Origin header as allowed
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m30s
map_get returns null (0) for missing headers. str_eq(null, "") is false
because EL_CSTR(0) is NULL != "". Same-origin browser fetches don't send
Origin at all, so the missing-origin case was incorrectly being denied.

Fix: use str_starts_with(req_origin, "http") to detect a present origin.
If no origin header (null first arg → str_starts_with returns false),
origin_present is false and the request is allowed unconditionally.
2026-05-11 13:30:22 -05:00
will.anderson 924c0804e7 Merge pull request 'Wire Supabase migrations into CI/CD' (#93) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m0s
Merge PR #93
2026-05-11 18:22:01 +00:00
will.anderson 4a915c1a11 Wire Supabase migrations into CI/CD
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m30s
Adds a "Run database migrations" step to both stage.yaml and deploy.yaml.
Uses the Supabase Management API (access token from GCP Secret Manager)
to apply pending migrations tracked in a schema_migrations table.
Migrations run unconditionally before every deploy — asset-only or full.

Also adds migrations/** to paths filter so a migrations-only commit
triggers the pipeline.
2026-05-11 13:21:42 -05:00
will.anderson 4a3ede98f7 Merge pull request 'Stage: pricing buttons, API keys, reasoning note, enterprise contacts' (#91) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m19s
Merge PR #91: dev stage batch
2026-05-11 18:05:33 +00:00
will.anderson a6b75b9abf Add direct sales and security contact block to enterprise section
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m45s
Two-card grid above the enterprise box — sales (enterprise@) and
security (security@) — with email links and one-line descriptions.
Visible without filling out the form, which is what enterprise and
security teams look for first.
2026-05-11 12:58:25 -05:00
will.anderson 21a7c07547 Add reasoning model recommendation to API Keys card
Callout above the provider list recommends o4-mini/o3, Claude Sonnet 4,
Gemini 2.5 Pro, or Grok-3 for best performance, notes that model choice
happens in the app, and points to Neuron Inference launching Q3 2026.
2026-05-11 12:54:28 -05:00
will.anderson 756f1f955e Add per-provider key provisioning instructions to API Keys card
Each provider row now has a collapsible details panel with accurate
step-by-step instructions and a direct link to the key creation page.
Includes billing notes for OpenAI and Anthropic (easy to miss gotchas),
free tier note for Gemini, and credits note for Grok.
2026-05-11 12:47:12 -05:00
will.anderson 18350761c5 Add API key provisioning to accounts page 2026-05-11 12:24:05 -05:00
will.anderson f22d90ac6f Make Free and Professional pricing buttons solid blue
All three pricing CTA buttons now share the same solid navy background,
white text, and blue hover state. Previously only anchor-element rules
existed for the solid variant; the button elements had no explicit
background so all three appeared unstyled.
2026-05-11 12:19:19 -05:00
will.anderson 2b8915bd60 Fix JS syntax errors and stage supabase-config CORS in CI
chat-widget.el: apostrophe in El native_js double-quoted strings caused
the El compiler to drop the backslash, producing broken JS single-quoted
strings. Switched those four string literals to double-quoted JS strings
using \" escaping so the compiled output is valid.

main.el: /api/supabase-config was returning 403 for all stage Cloud Run
origins. Added marketing-stage-* prefix to the allowed list so the
checkout page can initialise Supabase during CI E2E runs.
2026-05-11 12:15:18 -05:00
will.anderson acca3cfddf Merge pull request 'add unsafe-eval to CSP (El native_js compatibility)' (#88) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 3m15s
Merge fix/stage-ci-paths into dev
2026-05-11 16:44:54 +00:00
will.anderson 90f7c3655e add unsafe-eval to CSP for El runtime native_js() compatibility
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 3m9s
El's native_js() compiles to eval(). checkout-auth.el uses native_js()
to embed the auth logic, so all window globals (showSignIn, initStripe,
etc.) live inside an eval call. Stage CSP was blocking it, leaving the
page with no auth functions defined.
2026-05-11 11:40:05 -05:00
will.anderson 6d3c7e2bcd Merge pull request 'remove --obfuscate from elc JS compile step' (#86) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m54s
2026-05-11 16:11:33 +00:00
will.anderson 637b05af98 remove --obfuscate from elc JS compile step
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 2m8s
Stage CSP blocks 'unsafe-eval' which javascript-obfuscator introduces.
checkout-auth.js IIFE was crashing before assigning window globals,
causing all checkout E2E tests to fail.
2026-05-11 11:11:11 -05:00
will.anderson d90e8d1668 Merge pull request 'Fix Stripe CDN mock override and free-plan sync guards in E2E tests' (#84) from fix/stage-ci-paths into dev
Fix Stripe CDN mock override and free-plan sync guards in E2E tests
2026-05-11 15:36:21 +00:00
will.anderson c6fd06b3de Fix Stripe CDN mock override and free-plan sync guards in E2E tests
- Block real Stripe CDN (js.stripe.com) in injectMockStripe() so the
  addInitScript mock is never overwritten by the async-loaded SDK
- Replace waitForFunction(signUpWithEmail) with waitForLoadState in
  all 8 free-plan auth tests; defer scripts run before DOMContentLoaded
  so the function is guaranteed present without polling for it
2026-05-11 09:54:55 -05:00
will.anderson 91ecdaf3a5 Merge pull request 'Fix CI JS corruption from obfuscator stdout; clean up flaky test guards' (#81) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Failing after 1m53s
Merge fix/stage-ci-paths: fix CI JS corruption + flaky test guards
2026-05-11 14:16:29 +00:00
will.anderson 61f006f62d Fix CI JS corruption from obfuscator stdout; clean up flaky test guards
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m54s
- Strip [javascript-obfuscator-cli] progress line from elc --obfuscate
  output before writing to dist/js/ (was prepended to every compiled JS
  file, causing browser parse errors on stage)
- Remove spurious waitForFunction(signUpWithEmail) guards from
  buyer-name and buyer-email structural tests (pure DOM tests, no auth)
- Switch chat.spec.ts beforeEach to domcontentloaded (SSR elements
  present at DOM ready; networkidle caused cold-start timeouts)
2026-05-11 08:19:30 -05:00
will.anderson 48ba7716b8 Merge pull request 'Free plan Stripe age verification + soul demo personalization' (#79) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m25s
Merge free plan age verification + soul demo personalization into dev
2026-05-11 07:05:35 +00:00
will.anderson c966f2b455 implement free plan age verification via Stripe SetupIntent; personalize soul demo greeting with user name and timezone
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m49s
2026-05-11 02:03:39 -05:00
will.anderson 74e84da41a Merge pull request 'Add tests/** to stage CI paths filter' (#77) from fix/stage-ci-paths into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m26s
2026-05-11 06:21:04 +00:00
will.anderson ac2d00d653 Add tests/** + playwright.config.ts to stage CI paths filter
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m52s
2026-05-11 01:20:43 -05:00
will.anderson c0e6b40a5a Merge pull request 'Comprehensive checkout + Stripe payment flow tests' (#75) from feat/checkout-comprehensive-tests into dev
Merge: Comprehensive checkout + Stripe payment flow tests
2026-05-11 06:19:19 +00:00
will.anderson dbb8035698 Add comprehensive checkout + Stripe payment flow tests
- checkout-flows.spec.ts: 60 tests covering all 3 plan variants (free/
  professional/founding), auth section visibility, form validation,
  sign-in/sign-up toggle, mocked Supabase auth flows (sign-up, email-
  confirm-required, existing session, sign-in error), DOM transitions
  (auth-section → payment-section, free-success panel), auth badge
  content + email pre-fill, /api/checkout and /api/supabase-config
  endpoint contracts, CORS enforcement

- checkout-stripe.spec.ts: 45 tests covering Stripe.js presence,
  NEURON_CFG shape, submit-btn disabled state, founding attestation
  checkbox + attest-warn guard, professional charge timing radios,
  setup_mode label, mocked full Stripe payment flow via addInitScript +
  /api/payment-intent intercept, submit validation (name/email),
  decline handling, sold-out guard, /api/payment-intent /api/link-
  customer /api/attest /api/founding-count endpoint contracts, and
  live test-card flows (skipped unless STRIPE_LIVE=1)

Mocking strategy: page.route() for /api/supabase-config + Supabase
auth endpoints; addInitScript() for window.Stripe mock; localStorage
pre-seeding for existing-session tests.
2026-05-11 01:18:37 -05:00
will.anderson 83aa7ad64f Merge pull request 'test: full Playwright + API test suite for stage' (#73) from fix/checkout-auth-reveal into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m59s
Merge fix/checkout-auth-reveal into dev
2026-05-11 05:29:16 +00:00
will.anderson cac7bd5727 test: full Playwright + API test suite for stage
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m52s
159 tests across three Playwright projects (api, chromium, mobile):
- tests/api/security.test.ts: security headers, CORS on /api/supabase-config
  (origin allowlist enforced), auth gate on /api/demo, Stripe webhook
  signature enforcement, source file leakage, path traversal, input
  validation (8000-char message cap)
- tests/api/endpoints.test.ts: /api/health, /api/founding-count shape
  invariants, /api/supabase-config JWT shape, sitemap.xml, robots.txt,
  /llms.txt, /api/soul-health internal gate, 404 for unknown routes
- tests/e2e/landing.spec.ts: title, h1 count, meta description, OG tags,
  canonical (no stage leak), JSON-LD schema, demo widget DOM presence,
  JS error filtering (known GTM/CSP noise excluded)
- tests/e2e/seo.spec.ts: per-page title patterns, noindex on checkout,
  canonical URLs, sitemap production-URL enforcement
- tests/e2e/checkout.spec.ts: all three plan variants, auth section, payment
  element, canonical
- tests/e2e/chat.spec.ts: widget DOM structure, auth gate (send button
  disabled without session), API-level auth rejection
- tests/e2e/navigation.spec.ts: all public routes return 200, 404s for
  removed/old paths (/terms, /enterprise-terms, /gallery), static files

All 159 pass against stage. CI step added to stage.yaml after smoke test.
2026-05-11 00:28:33 -05:00
will.anderson e914704d86 Merge pull request 'security: pentest fixes — webhook sig, CORS, soul-health gate, asset headers' (#69) from fix/checkout-auth-reveal into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m31s
security: pentest fixes — webhook sig, CORS, soul-health gate, asset headers
2026-05-11 04:57:02 +00:00
will.anderson 43e1245306 seo: full audit fixes — meta, og, schema, canonical, sitemap, headings, alts
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m57s
- Per-page title/description/canonical/OG tags: about, checkout (per-plan),
  terms, enterprise-terms, success all get unique SEO blocks
- Homepage title updated to em-dash form; meta description adds CTA
- og:site_name added to all pages
- noindex/nofollow on checkout, success, account pages
- Sitemap (/sitemap.xml) with all public pages; robots.txt updated with
  Sitemap directive and Disallow for private paths
- Schema: WebSite type added, Organization gains logo ImageObject, SoftwareApplication
  gains url field, billingIncrement corrected to billingPeriod (ISO 8601 P1M),
  sameAs gains x.com/neurontechai alongside GitHub
- marked.min.js given defer attribute (was render-blocking)
- page_head refactored into page_head_base + page_seo_block + page_open_seo
  for clean inner-page overrides without duplicating the CSS/script block
2026-05-10 23:56:40 -05:00
will.anderson 3f3c5cf149 security: penetration test fixes — headers, cors, path traversal, info leakage
- Switch to http_serve_v2/http_set_handler_v2 so request headers are available
  to El handler code (prerequisite for all header-based security checks)

- Stripe webhook (CVE-class): add HMAC-SHA256 signature verification against
  Stripe-Signature header using STRIPE_WEBHOOK_SECRET env var. Previously any
  unauthenticated POST could forge a payment_intent.succeeded event and
  increment the founding counter or trigger Supabase account provisioning for
  arbitrary emails.

- CORS on /api/supabase-config: restrict to neurontechnologies.ai and localhost
  origins only. Cross-origin requests now get 403.

- /api/soul-health: require X-Internal: true header; otherwise return 404.
  Endpoint was publicly accessible and leaked internal soul service URL,
  network topology, and raw probe responses.

- Static asset / JS headers: add X-Frame-Options, Referrer-Policy,
  Permissions-Policy, and Content-Security-Policy to static_asset_headers_json
  and js_headers_json. These were only present on HTML/API responses before.

- Fix state key bug: share_card_page read state_get("__neuron_origin__") but
  the key registered at startup is "__origin__", causing empty base URLs in
  share card og: meta tags.
2026-05-10 23:56:31 -05:00
will.anderson bdff0ad153 Merge pull request 'feat: auth-gate demo chat + budget circuit breaker' (#67) from fix/checkout-auth-reveal into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m27s
feat: auth-gate demo chat + budget circuit breaker
2026-05-11 04:45:36 +00:00
will.anderson fe418bf3f7 feat: auth-gate demo chat + budget circuit breaker
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 2m10s
Gate the demo chat behind Supabase auth: the widget now fetches Supabase
config on open, shows a compact sign-in pane (Google OAuth or email/password)
when the user is unauthenticated, and passes the access_token to /api/demo.
The server verifies the token via supabase_auth_user() before any processing
and uses the verified user ID as the rate-limit key.

Add a budget kill switch: a demo_config table in Supabase holds a
demo_enabled flag that /api/demo polls every 60s (cached, fails open).
A Cloud Function (demo-budget-guard) is triggered by a GCP Pub/Sub budget
alert and sets demo_enabled = 'false' when spend crosses 90% of the $150
daily budget. Budget and topic are provisioned; function is live in
us-central1.
2026-05-10 23:44:54 -05:00
will.anderson 7536c216e6 Merge pull request 'feat: scale fixes — max-instances, asset caching, shared rate limits, global cap' (#65) from fix/checkout-auth-reveal into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m30s
feat: scale fixes — max-instances, asset caching, shared rate limits, global cap
2026-05-11 03:12:30 +00:00
will.anderson bdb6ddc581 feat: scale fixes — max-instances, asset caching, shared rate limits, global cap
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 2m0s
- soul-demo-stage: raise max-instances 10 → 50
- marketing-stage: explicitly set max-instances 200
- /assets/* and /brand/*: return Cache-Control: public, max-age=31536000, immutable
  so Cloudflare caches static assets at the edge (eliminates Cloud Run hit per request)
- /js/*: bump from max-age=3600 to max-age=31536000, immutable (same policy)
- Per-uid demo rate limit: replace in-process state with Supabase demo_rate_limits table
  so the 10-chats/day cap is enforced across all Cloud Run instances; falls back to
  in-process for local dev when SUPABASE_SERVICE_KEY is absent
- Global circuit breaker: trip if any single instance handles ≥2000 demo requests/UTC day
2026-05-10 22:12:09 -05:00
will.anderson 00f05f813e Merge pull request 'feat: extract soul-demo into standalone Cloud Run service' (#63) from fix/checkout-auth-reveal into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m33s
feat: extract soul-demo into standalone Cloud Run service
2026-05-11 02:09:06 +00:00
will.anderson 93f9ea2be2 feat: extract soul-demo into standalone Cloud Run service
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 2m19s
2026-05-10 21:08:46 -05:00
will.anderson e480aba2f1 Merge pull request 'fix: HAVE_CURL verification — use strings not nm' (#61) from fix/checkout-auth-reveal into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m25s
2026-05-11 01:07:39 +00:00
will.anderson feee40c34b ci: fix HAVE_CURL verification — use strings check not nm
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m54s
2026-05-10 20:07:21 -05:00
will.anderson e6e89a1f4d Merge pull request 'fix: relink neuron-web with HAVE_CURL (chat proxy)' (#59) from fix/checkout-auth-reveal into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m15s
fix: relink neuron-web with HAVE_CURL (chat proxy)
2026-05-11 01:03:22 +00:00
will.anderson 8b8cb2f580 ci: relink neuron-web with HAVE_CURL after elb build
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m45s
elb does not pass -DHAVE_CURL when compiling el_runtime.c, so all
http_get/http_post calls from El code return the no-op error string
instead of making real HTTP requests. This breaks the chat proxy to
soul-demo at localhost:7772.

After elb runs (and generates all intermediate .c files in dist/),
recompile el_runtime.c with -DHAVE_CURL and relink the entire binary
from those generated files. Verifies curl_easy_init is present in the
output binary before proceeding.
2026-05-10 20:03:00 -05:00
will.anderson 4d359ff021 Merge pull request 'Replace k3s with direct soul-demo watchdog' (#57) from fix/checkout-auth-reveal into dev
Dev — Build & local smoke test / build-smoke (push) Successful in 2m26s
Merge fix/checkout-auth-reveal into dev
2026-05-11 00:46:56 +00:00
will.anderson cd1c6737e8 Replace k3s with direct soul-demo watchdog in Cloud Run container
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 2m11s
Cloud Run gen2 doesn't provide eth0 with a unicast IP, causing k3s flannel
to crash on every container start. k3s was also wrong architecture for
Cloud Run (HPA inside a container, k3s overhead for one process).

Changes:
- entrypoint.sh: replace k3s server with a bash watchdog loop that starts
  soul-demo directly and restarts it on crash (3s backoff)
- Dockerfile.stage: remove k3s binary, soul-demo-image.tar, k3s manifests
  and their associated dirs/envvars; keep soul-demo binary only
- stage.yaml: remove 'Download k3s binary' step; rename and simplify
  soul-demo build step to compile binary only (no OCI image/tar)
- dev.yaml: update soul-demo placeholder step (binary not tar)
- manifest.el: document HAVE_CURL requirement since manifest.el has no
  c_flags/link_flags directive support
2026-05-10 19:46:35 -05:00
will.anderson f27fc2622c Merge pull request 'Fix envelope truncation in http_response when called after fs_read' (#55) from fix/checkout-auth-reveal into dev
Dev — Build & local smoke test / build-smoke (push) Failing after 2m46s
2026-05-11 00:23:28 +00:00
will.anderson 0433fe8c0f Fix http_response() truncating envelope via stale _tl_fs_read_len
Dev — Build & local smoke test / build-smoke (pull_request) Failing after 2m22s
http_response() builds a JSON envelope wrapping the body. If the caller
previously called fs_read() (which sets _tl_fs_read_len = file_size),
http_worker used that stale value as the response copy length — truncating
the larger envelope to the original file size before it reached
http_send_response. The truncated envelope had the body field cut mid-string;
jp_parse_string_raw failed, env_body = "", and http_send_all sent file_size
bytes of garbage past the empty string.

Fix: reset _tl_fs_read_len = 0 at the start of http_response(). The hint
was set for the raw file bytes; the envelope is a new string and must use
strlen() for its length.
2026-05-10 19:23:10 -05:00
will.anderson 9da4d50883 Merge pull request 'Fix JS files served as JSON envelope (checkout/Stripe/auth all broken)' (#53) from fix/checkout-auth-reveal into dev
Dev — Build & local smoke test / build-smoke (push) Failing after 2m4s
Fix JS files served as JSON envelope (checkout/Stripe/auth all broken)
2026-05-10 22:34:32 +00:00
will.anderson c99ca82302 Fix JS files served as raw JSON envelope instead of JavaScript
Dev — Build & local smoke test / build-smoke (pull_request) Failing after 1m36s
http_parse_envelope() called json_parse() on the entire response envelope
(~47KB when body is obfuscated JS). The parser failed on large/complex content,
so is_envelope=0 and the raw JSON was sent — browsers got {"el_http_response":1,...}
instead of executable JavaScript, silently breaking all client-side code.

Fix: replace json_parse-of-full-envelope with a direct field scanner:
- "status" extracted via strtol
- "headers" object extracted via brace-depth scan, then json_parse only that
  small substring (always safe — headers are simple k/v string pairs < 1KB)
- "body" string extracted via jp_parse_string_raw — no intermediate allocation

Also: /js/* route now returns http_response(200, js_headers_json(), content)
with explicit Content-Type: application/javascript so the browser doesn't
apply the json-heuristic (obfuscated JS starting with '[' was detected as JSON,
which with X-Content-Type-Options: nosniff blocks script execution).
2026-05-10 17:32:45 -05:00
will.anderson e292453905 Merge pull request 'Fix checkout auth: free-success panel + Stripe auto-init for paid plans' (#51) from fix/checkout-auth-reveal into dev
Dev — Build & local smoke test / build-smoke (push) Failing after 2m12s
Fix checkout auth: free-success panel + Stripe auto-init for paid plans
2026-05-10 22:00:55 +00:00
will.anderson 0263e51407 Fix checkout: show free-success when logged in; init Stripe without auth on paid plans
Dev — Build & local smoke test / build-smoke (pull_request) Failing after 1m47s
- revealPaymentForm: for free plan, show #free-success panel (was doing nothing,
  leaving page blank when user already had a Supabase session)
- checkExistingSession: for paid plans with no session, call initStripe immediately —
  auth is optional, the payment form shouldn't wait indefinitely
- Guard _formRevealed: prevent double-call from handleAuthRedirect + checkExistingSession
2026-05-10 16:59:51 -05:00
will.anderson b4935ed880 Merge pull request 'Fix http handler not found: pre-register via constructor' (#49) from fix/entrypoint-k3s-nonblocking into dev
Dev — Build & local smoke test / build-smoke (push) Failing after 2m29s
Merge PR #49: Fix http handler not found
2026-05-10 18:36:47 +00:00
will.anderson ee0147869e Merge pull request 'Fix GLIBC_2.38 mismatch: switch base image to ubuntu:24.04' (#47) from fix/entrypoint-k3s-nonblocking into dev
Dev — Build & local smoke test / build-smoke (push) Failing after 2m23s
Fix GLIBC_2.38 mismatch: switch base image to ubuntu:24.04
2026-05-10 18:01:57 +00:00
will.anderson 25f6631049 Merge pull request 'Non-blocking entrypoint: start neuron-web before k3s is ready' (#45) from fix/entrypoint-k3s-nonblocking into dev
Dev — Build & local smoke test / build-smoke (push) Failing after 2m43s
Non-blocking entrypoint: start neuron-web before k3s is ready
2026-05-10 17:54:54 +00:00
48 changed files with 4054 additions and 409 deletions
+10 -2
View File
@@ -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
+6 -5
View File
@@ -172,11 +172,12 @@ jobs:
- name: Touch HTML placeholder files
run: touch src/index.html src/about.html src/terms.html src/enterprise-terms.html
- name: Create soul-demo-image.tar placeholder
# Dockerfile.stage COPYs this file (used by k3s at runtime).
# We only need the COPY to succeed here; real tar is built by
# build-stage.sh in the deploy pipeline.
run: touch dist/soul-demo-image.tar
- name: Create soul-demo placeholder
# Dockerfile.stage COPYs dist/soul-demo. We only need the binary to exist
# for the Docker build to succeed; the real binary is compiled in stage CI.
run: |
touch dist/soul-demo
chmod +x dist/soul-demo
- name: Build Docker image (local only — no push)
run: |
+132 -26
View File
@@ -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,7 +11,12 @@ on:
- 'src/**'
- 'dist/**'
- 'runtime/**'
- 'tests/**'
- 'migrations/**'
- 'playwright.config.ts'
- 'package.json'
- 'Dockerfile.stage'
- 'Dockerfile.soul-demo'
- 'build-stage.sh'
- '.gitea/workflows/stage.yaml'
@@ -47,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
@@ -74,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
@@ -93,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
@@ -148,6 +166,46 @@ jobs:
--runtime="$EL_RUNTIME"
echo "Binary: $(ls -lh dist/neuron-landing)"
- name: Relink neuron-web with HAVE_CURL
# elb does not pass -DHAVE_CURL when compiling el_runtime.c, so
# http_get/http_post return {"error":"not built with HAVE_CURL"}.
# Fix: after elb generates all intermediate .c files in dist/, recompile
# el_runtime.c with -DHAVE_CURL and relink the whole binary manually.
# All component .c files (nav.c, hero.c, etc.) are generated by elb and
# remain in dist/ after the build — we collect them here, exclude the
# separate soul-demo.c binary, and relink with libcurl.
if: steps.changetype.outputs.asset_only != 'true'
run: |
set -euo pipefail
# Compile el_runtime.c with full curl support
cc -O2 -DHAVE_CURL -c runtime/el_runtime.c -I runtime/ -o /tmp/el_runtime_curl.o
echo "el_runtime_curl.o compiled: $(ls -lh /tmp/el_runtime_curl.o)"
# Collect every neuron-web .c file elb deposited in dist/
# (both committed stubs and freshly-generated component files)
mapfile -t C_SRCS < <(find dist/ -maxdepth 1 -name '*.c' ! -name 'soul-demo.c')
echo "Relinking ${#C_SRCS[@]} C files..."
cc -O2 -rdynamic \
-I runtime/ -I dist/ \
-o dist/neuron-landing \
"${C_SRCS[@]}" /tmp/el_runtime_curl.o \
-lcurl -lpthread -ldl -lm -lssl -lcrypto
echo "Relinked: $(ls -lh dist/neuron-landing)"
# Verification: if compiled WITHOUT HAVE_CURL the stub string
# "not built with HAVE_CURL" is baked into the binary's rodata.
# Its absence confirms curl code is compiled in.
if strings dist/neuron-landing | grep -q 'not built with HAVE_CURL'; then
echo "ERROR: no-curl stub string still in binary — HAVE_CURL not compiled"
exit 1
fi
# Confirm curl symbols visible in dynamic table
nm -D dist/neuron-landing | grep curl_easy_init || \
nm dist/neuron-landing | grep curl || true
echo "HAVE_CURL verified ✓"
# ── Compile JS client sources ─────────────────────────────────────────
- name: Compile JS El sources
@@ -163,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
}
@@ -173,15 +231,15 @@ jobs:
# ── Docker build + push ───────────────────────────────────────────────
- name: Build soul-demo image tar
# Dockerfile.stage COPYs dist/soul-demo-image.tar so k3s can import
# soul-demo:local at runtime. We compile soul-demo from source on the
# host runner (ci-base has gcc), build a minimal OCI image, and save it.
- name: Build soul-demo binary
# Compile soul-demo directly on the host runner (ci-base has gcc).
# Cloud Run runs soul-demo as a direct subprocess with a watchdog loop —
# no k3s, no OCI image needed. One binary per container; Cloud Run
# handles horizontal scaling.
# Moved AFTER JS compilation to avoid Docker memory pressure killing elc.
if: steps.changetype.outputs.asset_only != 'true'
run: |
set -euo pipefail
# Compile el_runtime.o and soul-demo on the host runner
cc -O2 -DHAVE_CURL -c runtime/el_runtime.c -I runtime/ -o /tmp/el_runtime.o
cc -O2 -rdynamic -DEL_SOUL_DEMO_BUILD \
-I runtime/ \
@@ -189,26 +247,49 @@ jobs:
dist/soul-demo.c dist/vessel_stubs.c /tmp/el_runtime.o \
-lcurl -lpthread -ldl -lm -lssl -lcrypto
echo "soul-demo compiled: $(ls -lh dist/soul-demo)"
# Package as minimal OCI image for k3s import
# --no-cache: prevents reuse of corrupted overlay2 layers from prior failed runs
docker build --no-cache -f dist/Dockerfile.soul-demo -t soul-demo:local dist/
docker save soul-demo:local -o dist/soul-demo-image.tar
echo "soul-demo-image.tar: $(du -sh dist/soul-demo-image.tar | cut -f1)"
docker rmi soul-demo:local 2>/dev/null || true
- name: Download k3s binary
# Pre-download k3s on the host runner so Dockerfile.stage can COPY it
# directly. Previously k3s was downloaded inside the Docker builder stage,
# which combined with build-essential and C compilation caused RWLayer nil
# corruption on the runner's overlay2 driver. Host-runner download is safe.
- name: Build and push soul-demo image
if: steps.changetype.outputs.asset_only != 'true'
id: soul-image
run: |
set -euo pipefail
curl -fL --retry 3 --retry-delay 10 \
https://github.com/k3s-io/k3s/releases/download/v1.32.4%2Bk3s1/k3s \
-o dist/k3s
chmod +x dist/k3s
echo "k3s: $(ls -lh dist/k3s)"
SOUL_IMAGE="us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/soul-demo:${{ steps.tag.outputs.tag }}"
docker build --no-cache \
-f Dockerfile.soul-demo \
-t "soul-demo:${{ steps.tag.outputs.tag }}" \
.
docker tag "soul-demo:${{ steps.tag.outputs.tag }}" "$SOUL_IMAGE"
docker tag "soul-demo:${{ steps.tag.outputs.tag }}" \
"us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/soul-demo:stage-latest"
docker push "$SOUL_IMAGE"
docker push "us-central1-docker.pkg.dev/neuron-785695/neuron-marketing/soul-demo:stage-latest"
echo "soul_image=${SOUL_IMAGE}" >> "$GITHUB_OUTPUT"
echo "Soul-demo image: ${SOUL_IMAGE}"
- name: Deploy soul-demo-stage
if: steps.changetype.outputs.asset_only != 'true'
id: deploy-soul
run: |
set -euo pipefail
gcloud run deploy soul-demo-stage \
--image "${{ steps.soul-image.outputs.soul_image }}" \
--region us-central1 \
--project neuron-785695 \
--service-account neuron-marketing-sa@neuron-785695.iam.gserviceaccount.com \
--update-env-vars "NEURON_LLM_0_FORMAT=anthropic,NEURON_LLM_0_MODEL=claude-sonnet-4-5,NEURON_LLM_0_URL=https://api.anthropic.com/v1/messages" \
--update-secrets "NEURON_LLM_0_KEY=anthropic-api-key:latest,ANTHROPIC_API_KEY=anthropic-api-key:latest" \
--min-instances 1 \
--max-instances 50 \
--concurrency 20 \
--port 8080 \
--allow-unauthenticated \
--quiet
SOUL_URL=$(gcloud run services describe soul-demo-stage \
--region us-central1 --project neuron-785695 \
--format 'value(status.url)')
echo "soul_url=${SOUL_URL}" >> "$GITHUB_OUTPUT"
echo "Soul-demo URL: ${SOUL_URL}"
- name: Build and tag image
if: steps.changetype.outputs.asset_only != 'true'
@@ -255,6 +336,21 @@ jobs:
docker push "${LATEST%:*}:stage-latest"
echo "Fast asset build complete"
- name: Resolve soul-demo URL
id: soul-url
run: |
set -euo pipefail
# For full builds: soul_url comes from deploy-soul step output.
# For asset-only builds (soul-demo not redeployed): describe existing service.
SOUL_URL="${{ steps.deploy-soul.outputs.soul_url }}"
if [ -z "$SOUL_URL" ]; then
SOUL_URL=$(gcloud run services describe soul-demo-stage \
--region us-central1 --project neuron-785695 \
--format 'value(status.url)' 2>/dev/null || echo "")
fi
echo "soul_url=${SOUL_URL}" >> "$GITHUB_OUTPUT"
echo "Resolved SOUL_URL: ${SOUL_URL}"
- name: Deploy to marketing-stage
id: deploy-stage
env:
@@ -267,7 +363,8 @@ jobs:
--region us-central1 \
--project neuron-785695 \
--service-account neuron-marketing-sa@neuron-785695.iam.gserviceaccount.com \
--update-env-vars "NODE_ENV=production,STRIPE_PUBLISHABLE_KEY=pk_test_51TPoHnJg9Fv1D3AUp1FEMcy4MGlKRZqs4scW66kjQFQjWofmNc2rottzXzDaXekHvuw1OQpyp2WCIsc7O5fXIG0G00HQQrkdGX,GCS_SHARE_BUCKET=neuron-shares-prod,SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9jb2pzZ2hhb25sdHVuaWRrenB3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzc2NDIxNjgsImV4cCI6MjA5MzIxODE2OH0.e0FVFw1aahnrBVvnkR5R8a-RxCx095U8o_gsk7Quq3E,NEURON_LLM_0_FORMAT=anthropic,NEURON_LLM_0_MODEL=claude-sonnet-4-5,NEURON_LLM_0_URL=https://api.anthropic.com/v1/messages" \
--max-instances 200 \
--update-env-vars "NODE_ENV=production,STRIPE_PUBLISHABLE_KEY=pk_test_51TPoHnJg9Fv1D3AUp1FEMcy4MGlKRZqs4scW66kjQFQjWofmNc2rottzXzDaXekHvuw1OQpyp2WCIsc7O5fXIG0G00HQQrkdGX,GCS_SHARE_BUCKET=neuron-shares-prod,SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im9jb2pzZ2hhb25sdHVuaWRrenB3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzc2NDIxNjgsImV4cCI6MjA5MzIxODE2OH0.e0FVFw1aahnrBVvnkR5R8a-RxCx095U8o_gsk7Quq3E,NEURON_LLM_0_FORMAT=anthropic,NEURON_LLM_0_MODEL=claude-sonnet-4-5,NEURON_LLM_0_URL=https://api.anthropic.com/v1/messages,SOUL_URL=${{ steps.soul-url.outputs.soul_url }}" \
--update-secrets "SUPABASE_SERVICE_KEY=supabase-service-key:latest,NEURON_LLM_0_KEY=anthropic-api-key:latest,ANTHROPIC_API_KEY=anthropic-api-key:latest,STRIPE_SECRET_KEY=stripe-secret-key-stage:latest,STRIPE_WEBHOOK_SECRET=stripe-webhook-secret-stage:latest,STRIPE_PRICE_PROFESSIONAL=stripe-price-professional-stage:latest,STRIPE_PRICE_FOUNDING=stripe-price-founding-stage:latest,STRIPE_PRICE_FAMILY_CHILD=stripe-price-family-child:latest,RESEND_API_KEY=resend-api-key:latest,DOCUSEAL_WEBHOOK_TOKEN=docuseal-webhook-token:latest" \
--allow-unauthenticated \
--quiet
@@ -301,3 +398,12 @@ jobs:
echo "Stage smoke test FAILED"
exit 1
- name: Run automated test suite
run: |
set -euo pipefail
cd $GITHUB_WORKSPACE
npm ci --prefer-offline 2>/dev/null || npm install
npx playwright install chromium --with-deps
BASE_URL="${{ steps.deploy-stage.outputs.stage_url }}" \
npx playwright test --reporter=list
+5
View File
@@ -41,3 +41,8 @@ src/assets/js/
dist/soul-demo
dist/soul-demo-snapshot.json
dist/soul-demo-image.tar
# Playwright
node_modules/
test-results/
playwright-report/
+32
View File
@@ -0,0 +1,32 @@
# Dockerfile.soul-demo — Soul-demo as a standalone Cloud Run service.
# Decoupled from neuron-web so it can scale independently.
# Built from repo root. soul-demo binary compiled by CI before this runs.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
libcurl4t64 \
libssl3t64 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r soul && useradd -r -g soul soul \
&& mkdir -p /srv/soul/engram-demo \
&& chown -R soul:soul /srv/soul
COPY dist/soul-demo /usr/local/bin/soul-demo
RUN chmod +x /usr/local/bin/soul-demo
COPY dist/engram-snapshot.json /srv/soul/engram-demo/snapshot.json
RUN chown soul:soul /srv/soul/engram-demo/snapshot.json
USER soul
ENV NEURON_HOME=/srv/soul/engram-demo
ENV NEURON_PORT=8080
EXPOSE 8080
CMD ["/usr/local/bin/soul-demo"]
+8 -39
View File
@@ -1,18 +1,15 @@
# Dockerfile.stage — Stage build: landing server + soul-demo in one image.
# Dockerfile.stage — Stage build: landing server only.
#
# Both processes run in the same container:
# - neuron-web on port 8080 (landing page server)
# - soul-demo on port 7772 (demo chat, localhost only)
# neuron-web runs on port 8080 (landing page server).
# soul-demo is now a separate Cloud Run service (soul-demo-stage).
#
# All binaries (neuron-web, soul-demo, k3s) are pre-built by CI on the host
# runner before this Dockerfile runs. This keeps the Docker build single-stage
# with no compilation and no network downloads, eliminating the multi-stage
# complexity that caused RWLayer corruption on the runner's overlay2 driver.
# neuron-web binary is pre-built by CI on the host runner before this
# Dockerfile runs. This keeps the Docker build single-stage with no
# compilation and no network downloads.
#
# CI pre-build steps (in stage.yaml):
# - neuron-web: built by `elb build` → dist/neuron-landing
# - soul-demo: compiled by cc on host → dist/soul-demo
# - k3s: downloaded by curl on host → dist/k3s
# Last rebuilt: 2026-05-11
FROM ubuntu:24.04
@@ -26,34 +23,12 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd -r landing && useradd -r -g landing landing \
&& mkdir -p /srv/landing/assets /srv/landing/js /srv/landing/shares \
&& mkdir -p /srv/soul/engram-demo \
&& chown -R landing:landing /srv/landing /srv/soul \
&& mkdir -p /var/lib/rancher/k3s /tmp/k3s \
&& chown -R landing:landing /var/lib/rancher /tmp/k3s
&& chown -R landing:landing /srv/landing
# neuron-web binary — produced by `elb build` in CI (linux/amd64)
COPY dist/neuron-landing /usr/local/bin/neuron-web
RUN chmod +x /usr/local/bin/neuron-web
# soul-demo binary — compiled by cc on host runner in CI
COPY dist/soul-demo /usr/local/bin/soul-demo
RUN chmod +x /usr/local/bin/soul-demo
# k3s binary — downloaded from GitHub releases by CI
COPY dist/k3s /usr/local/bin/k3s
RUN chmod +x /usr/local/bin/k3s
# soul-demo OCI image tar — k3s imports this at startup (no registry needed)
RUN mkdir -p /var/lib/rancher/k3s/agent/images
COPY dist/soul-demo-image.tar /var/lib/rancher/k3s/agent/images/soul-demo.tar
# k3s manifests — auto-applied when k3s starts
RUN mkdir -p /var/lib/rancher/k3s/server/manifests
COPY dist/k3s-soul-demo.yaml /var/lib/rancher/k3s/server/manifests/soul-demo.yaml
# Engram snapshot — baked in so soul has memory from cold start
COPY dist/engram-snapshot.json /srv/soul/engram-demo/snapshot.json
COPY src/assets /srv/landing/assets
COPY dist/js /srv/landing/js
COPY src/llms.txt /srv/landing/llms.txt
@@ -71,13 +46,7 @@ RUN chmod +x /usr/local/bin/entrypoint.sh
ENV LANDING_ROOT=/srv/landing
ENV PORT=8080
ENV NEURON_HOME=/srv/soul/engram-demo
ENV NEURON_PORT=7772
ENV K3S_DATA_DIR=/var/lib/rancher/k3s
ENV KUBECONFIG=/var/lib/rancher/k3s/server/cred/admin.kubeconfig
# k3s requires root to create network namespaces and mount cgroups.
# Cloud Run gen2 sandbox is the security boundary here.
EXPOSE 8080
CMD ["/usr/local/bin/entrypoint.sh"]
+35
View File
@@ -0,0 +1,35 @@
import json
import base64
import os
import requests
from datetime import datetime, timezone
def budget_alert(event, context):
"""Triggered by a Pub/Sub budget alert. Disables demo if threshold exceeded."""
data = base64.b64decode(event['data']).decode('utf-8')
alert = json.loads(data)
# Only act on threshold exceeded alerts (not forecasts)
cost_amount = alert.get('costAmount', 0)
budget_amount = alert.get('budgetAmount', 1)
threshold = cost_amount / budget_amount if budget_amount else 0
if threshold < 0.9:
print(f"Threshold {threshold:.1%} below 90%, no action")
return
supabase_url = os.environ['SUPABASE_URL']
service_key = os.environ['SUPABASE_SERVICE_KEY']
resp = requests.patch(
f"{supabase_url}/rest/v1/demo_config?key=eq.demo_enabled",
headers={
'Authorization': f'Bearer {service_key}',
'apikey': service_key,
'Content-Type': 'application/json',
'Prefer': 'return=minimal',
},
json={'value': 'false', 'updated_at': datetime.now(timezone.utc).isoformat()}
)
print(f"Demo disabled — budget at {threshold:.1%}. Supabase: {resp.status_code}")
@@ -0,0 +1 @@
requests==2.31.0
+21 -20
View File
@@ -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;
}
+1 -38
View File
@@ -1,41 +1,4 @@
#!/bin/sh
set -e
# SKIP_K3S=1 — bypass k3s/soul-demo startup and go straight to neuron-web.
# Used by the dev CI smoke test where the container runtime doesn't support
# the kernel capabilities k3s requires (overlayfs / privileged mode).
if [ "${SKIP_K3S:-0}" = "1" ]; then
echo "[entrypoint] SKIP_K3S=1: starting neuron-web directly (no k3s/soul-demo)."
exec /usr/local/bin/neuron-web
fi
echo "[entrypoint] Starting k3s server (embedded soul-demo orchestrator)..."
# k3s server — single-node mode, disable unused components
# --disable traefik,servicelb: we don't need an ingress or LB
# --disable metrics-server: saves ~50MB RAM
# --write-kubeconfig-mode=644: allow non-root reads
# --data-dir: use the pre-chowned dir
# --flannel-iface=eth0: explicitly set the network interface.
# Cloud Run gen2 provides eth0 but k3s default IP detection walks the routing
# table looking for a default route, which fails in Cloud Run's network sandbox.
# Pinning to eth0 bypasses that detection and lets k3s bind correctly.
k3s server \
--disable traefik \
--disable servicelb \
--disable metrics-server \
--write-kubeconfig-mode=644 \
--data-dir /var/lib/rancher/k3s \
--node-name soul-node \
--flannel-iface=eth0 &
K3S_PID=$!
# Start neuron-web immediately — do NOT block on k3s becoming ready.
# Cloud Run's startup probe requires port 8080 to be listening within the
# startup timeout. k3s may take 30-60s to initialise; blocking here causes
# probe failures and container termination before neuron-web ever starts.
# soul-demo becomes available asynchronously once k3s is ready. neuron-web
# handles soul-demo being temporarily unavailable gracefully.
echo "[entrypoint] Starting neuron-web on port ${PORT:-8080} (k3s initialising in background)..."
echo "[entrypoint] Starting neuron-web on port ${PORT:-8080}..."
exec /usr/local/bin/neuron-web
+1 -1
View File
File diff suppressed because one or more lines are too long
+95
View File
@@ -1764,6 +1764,7 @@ el_val_t page_css(void) {
"\n"
" #neuron-demo-input-row {\n"
" display: flex;\n"
" align-items: flex-end;\n"
" border-top: 1px solid var(--border);\n"
" flex-shrink: 0;\n"
" }\n"
@@ -1771,11 +1772,16 @@ el_val_t page_css(void) {
" flex: 1;\n"
" font-family: var(--body);\n"
" font-size: 0.875rem;\n"
" line-height: 1.5;\n"
" color: var(--t1);\n"
" background: var(--bg);\n"
" border: none;\n"
" outline: none;\n"
" padding: 0.875rem 1rem;\n"
" resize: none;\n"
" overflow: hidden;\n"
" min-height: 2.75rem;\n"
" max-height: 7.5rem;\n"
" }\n"
" #neuron-demo-text::placeholder { color: var(--t3); }\n"
" #neuron-demo-send {\n"
@@ -1794,6 +1800,91 @@ el_val_t page_css(void) {
" }\n"
" #neuron-demo-send:hover { background: #0078D4; }\n"
" #neuron-demo-send:disabled { opacity: 0.5; cursor: not-allowed; }\n"
" #neuron-demo-auth {\n"
" flex-direction: column;\n"
" align-items: center;\n"
" gap: 0.75rem;\n"
" padding: 1.5rem 1.25rem;\n"
" flex: 1;\n"
" }\n"
" .demo-auth-heading {\n"
" font-family: var(--body);\n"
" font-size: 0.85rem;\n"
" font-weight: 600;\n"
" color: var(--t1);\n"
" text-align: center;\n"
" margin: 0 0 0.25rem;\n"
" }\n"
" .demo-auth-google-btn {\n"
" display: flex;\n"
" align-items: center;\n"
" gap: 0.6rem;\n"
" width: 100%;\n"
" padding: 0.65rem 1rem;\n"
" font-family: var(--body);\n"
" font-size: 0.82rem;\n"
" font-weight: 500;\n"
" color: var(--t1);\n"
" background: #fff;\n"
" border: 1px solid var(--border);\n"
" border-radius: 6px;\n"
" cursor: pointer;\n"
" justify-content: center;\n"
" transition: border-color 180ms, box-shadow 180ms;\n"
" }\n"
" .demo-auth-google-btn:hover {\n"
" border-color: rgba(0,82,160,0.45);\n"
" box-shadow: 0 0 0 3px rgba(0,82,160,0.08);\n"
" }\n"
" .demo-auth-email-toggle {\n"
" background: none;\n"
" border: none;\n"
" cursor: pointer;\n"
" font-family: var(--body);\n"
" font-size: 0.78rem;\n"
" color: var(--t3);\n"
" text-decoration: underline;\n"
" padding: 0;\n"
" }\n"
" .demo-auth-email-form {\n"
" display: none;\n"
" flex-direction: column;\n"
" gap: 0.5rem;\n"
" width: 100%;\n"
" }\n"
" .demo-auth-email-form input {\n"
" width: 100%;\n"
" padding: 0.6rem 0.75rem;\n"
" font-family: var(--body);\n"
" font-size: 0.82rem;\n"
" color: var(--t1);\n"
" background: var(--bg);\n"
" border: 1px solid var(--border);\n"
" border-radius: 6px;\n"
" outline: none;\n"
" }\n"
" .demo-auth-email-form input:focus { border-color: var(--navy); }\n"
" .demo-auth-submit-btn {\n"
" width: 100%;\n"
" padding: 0.6rem 1rem;\n"
" font-family: var(--body);\n"
" font-size: 0.82rem;\n"
" font-weight: 600;\n"
" color: #fff;\n"
" background: var(--navy);\n"
" border: none;\n"
" border-radius: 6px;\n"
" cursor: pointer;\n"
" transition: background 180ms;\n"
" }\n"
" .demo-auth-submit-btn:hover { background: #0078D4; }\n"
" .demo-auth-submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }\n"
" .demo-auth-msg {\n"
" font-family: var(--body);\n"
" font-size: 0.75rem;\n"
" margin: 0;\n"
" text-align: center;\n"
" }\n"
" @media (max-width: 600px) {\n"
" #neuron-demo-text { font-size: 1rem; padding: 1rem; }\n"
" #neuron-demo-send { padding: 1rem 1.25rem; min-width: 64px; }\n"
@@ -1818,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>"
+16 -3
View File
@@ -10,9 +10,18 @@ el_val_t page_schema(void) {
" \"@context\": \"https://schema.org\",\n"
" \"@graph\": [\n"
" {\n"
" \"@type\": \"WebSite\",\n"
" \"name\": \"Neuron\",\n"
" \"url\": \"https://neurontechnologies.ai\"\n"
" },\n"
" {\n"
" \"@type\": \"Organization\",\n"
" \"name\": \"Neuron, LLC\",\n"
" \"url\": \"https://neurontechnologies.ai\",\n"
" \"logo\": {\n"
" \"@type\": \"ImageObject\",\n"
" \"url\": \"https://neurontechnologies.ai/assets/brand/neuron-wordmark-on-light@2x.png\"\n"
" },\n"
" \"founder\": {\n"
" \"@type\": \"Person\",\n"
" \"name\": \"Will Anderson\",\n"
@@ -20,11 +29,15 @@ el_val_t page_schema(void) {
" },\n"
" \"description\": \"Neuron builds AI that runs on your machine, builds a memory over time, and gets sharper the longer you use it. One builder. Built different.\",\n"
" \"foundingDate\": \"2026\",\n"
" \"sameAs\": [\"https://github.com/neuron-technologies\"]\n"
" \"sameAs\": [\n"
" \"https://github.com/neuron-technologies\",\n"
" \"https://x.com/neurontechai\"\n"
" ]\n"
" },\n"
" {\n"
" \"@type\": \"SoftwareApplication\",\n"
" \"name\": \"Neuron\",\n"
" \"url\": \"https://neurontechnologies.ai\",\n"
" \"applicationCategory\": \"AIApplication\",\n"
" \"operatingSystem\": \"macOS, Windows, Linux\",\n"
" \"offers\": [\n"
@@ -39,7 +52,7 @@ el_val_t page_schema(void) {
" \"name\": \"Professional\",\n"
" \"price\": \"19\",\n"
" \"priceCurrency\": \"USD\",\n"
" \"billingIncrement\": \"monthly\"\n"
" \"billingPeriod\": \"P1M\"\n"
" },\n"
" {\n"
" \"@type\": \"Offer\",\n"
@@ -67,7 +80,7 @@ el_val_t page_schema(void) {
" \"name\": \"What is Neuron?\",\n"
" \"acceptedAnswer\": {\n"
" \"@type\": \"Answer\",\n"
" \"text\": \"Neuron is an AI that runs on your machine and builds a persistent memory over time. Every other AI forgets you when you close the tab. Neuron doesn&#39;t. The longer you use it, the less you have to explain.\"\n"
" \"text\": \"Neuron is an AI that runs on your machine and builds a persistent memory over time. Every other AI forgets you when you close the tab. Neuron doesn't. The longer you use it, the less you have to explain.\"\n"
" }\n"
" },\n"
" {\n"
+13 -2
View File
@@ -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"));
+49
View File
@@ -237,6 +237,55 @@ el_val_t supabase_admin_invite(el_val_t project_url, el_val_t service_key, el_va
return http_post_with_headers(EL_STR(url), body_json, headers);
}
/*
* supabase_admin_update_user PUT {project_url}/auth/v1/admin/users/{user_id}
* with the service-role key to overwrite a user's user_metadata (and any other
* top-level fields in body_json). Unlike /auth/v1/invite, this always writes
* the supplied data even when the user already exists.
*
* body_json example:
* {"user_metadata":{"plan":"founding","stripe_customer_id":"cus_xxx","name":"..."}}
*
* Returns the raw JSON response from Supabase (includes the updated user object).
* Returns "" on transport error.
*
* Used by the Stripe webhook after supabase_admin_invite to guarantee the
* plan is stamped correctly regardless of whether the account was created
* before or after payment.
*/
el_val_t supabase_admin_update_user(el_val_t project_url, el_val_t service_key,
el_val_t user_id, el_val_t body_json) {
CURL *c = curl_easy_init();
if (!c) return EL_STR("");
char url[1024];
snprintf(url, sizeof(url), "%s/auth/v1/admin/users/%s",
EL_CSTR(project_url), EL_CSTR(user_id));
char auth_hdr[2048];
snprintf(auth_hdr, sizeof(auth_hdr), "Authorization: Bearer %s", EL_CSTR(service_key));
char api_hdr[2048];
snprintf(api_hdr, sizeof(api_hdr), "apikey: %s", EL_CSTR(service_key));
struct curl_slist *hdrs = NULL;
hdrs = curl_slist_append(hdrs, auth_hdr);
hdrs = curl_slist_append(hdrs, api_hdr);
hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
hdrs = curl_slist_append(hdrs, "Accept: application/json");
_stub_resp_t r = {0};
curl_easy_setopt(c, CURLOPT_URL, url);
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "PUT");
curl_easy_setopt(c, CURLOPT_POSTFIELDS, EL_CSTR(body_json));
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(c, CURLOPT_TIMEOUT, 60L);
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, _stub_write);
curl_easy_setopt(c, CURLOPT_WRITEDATA, &r);
CURLcode rc = curl_easy_perform(c);
curl_easy_cleanup(c);
curl_slist_free_all(hdrs);
if (rc != CURLE_OK) { free(r.buf); return EL_STR(""); }
if (!r.buf) return EL_STR("");
return EL_STR(r.buf);
}
/*
* gcs_get_token fetch an OAuth2 bearer token.
*
+8
View File
@@ -16,4 +16,12 @@ build {
c_source "dist/page_css.c"
c_source "dist/page_ga.c"
c_source "dist/page_schema.c"
// NOTE: neuron-web requires el_runtime.c to be compiled with -DHAVE_CURL
// so that http_get/http_post forward to libcurl instead of returning
// {"error":"not built with HAVE_CURL"}. The elb binary in ci-base:dev
// hardcodes -DHAVE_CURL in its cc invocation, but older elb versions may
// not. manifest.el does not support c_flags or link_flags directives
// if upgrading elb breaks HTTP, ensure ci-base:dev ships an elb built
// with HAVE_CURL enabled in its hardcoded cc command, or pre-compile
// el_runtime.o with -DHAVE_CURL on the host and pass it as a c_source.
}
+23
View File
@@ -0,0 +1,23 @@
-- 20260510000000_demo_config.sql
--
-- Kill switch for the demo chat endpoint. Backs the budget-alert Cloud Function
-- that flips demo_enabled to 'false' when GCP spend crosses 90% of the daily
-- budget threshold. The web tier polls this table with a 60s TTL cache so the
-- demo is disabled within one minute of a budget alert firing.
--
-- Service-role bypasses RLS. Public anon has no access (policy USING (false)).
CREATE TABLE IF NOT EXISTS public.demo_config (
key text PRIMARY KEY,
value text NOT NULL,
updated_at timestamptz DEFAULT now()
);
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
INSERT INTO public.demo_config (key, value) VALUES ('demo_enabled', 'true')
ON CONFLICT (key) DO NOTHING;
@@ -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);
+78
View File
@@ -0,0 +1,78 @@
{
"name": "neuron-marketing-web",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neuron-marketing-web",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.44.0"
}
},
"node_modules/@playwright/test": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.59.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.59.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "neuron-marketing-web",
"version": "1.0.0",
"private": true,
"devDependencies": {
"@playwright/test": "^1.44.0"
},
"scripts": {
"test": "playwright test",
"test:api": "playwright test tests/api/",
"test:e2e": "playwright test tests/e2e/"
}
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig, devices } from '@playwright/test';
const BASE_URL = process.env.BASE_URL || 'https://marketing-stage-r4tfklscwq-uc.a.run.app';
export default defineConfig({
testDir: './tests',
timeout: 30_000,
retries: process.env.CI ? 2 : 0,
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: BASE_URL,
extraHTTPHeaders: {},
},
projects: [
{ name: 'api', testDir: './tests/api', use: { ...devices['Desktop Chrome'] } },
{ name: 'chromium', testDir: './tests/e2e', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile', testDir: './tests/e2e', use: { ...devices['Pixel 7'] } },
],
});
+161 -32
View File
@@ -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) {
@@ -1331,12 +1348,19 @@ static void http_emit_headers_from_map(JsonBuf* b, el_val_t headers_map,
/* Parse the envelope produced by http_response(). On success returns 1 and
* populates *out_status, *out_headers_map (an ElMap el_val_t caller must
* el_release), and *out_body (allocated). On failure returns 0.
* el_release via out_parsed_root), and *out_body (malloc'd, caller frees).
* On failure returns 0.
*
* Implementation: feeds the entire envelope through the recursive-descent
* JSON parser (which builds proper ElMap/ElList values), then pulls the
* three top-level fields by name. Avoids re-stringifying the headers map
* since json_stringify() does not support nested objects. */
* Implementation: manual field scanner does NOT run json_parse on the full
* envelope. Running the recursive-descent JSON parser on a 4050 KB envelope
* (common when the body contains minified/obfuscated JavaScript) fails because
* the parser allocates intermediate ElMap nodes for the whole structure.
* Instead we scan directly:
* "status" strtol scan
* "headers" brace-depth scan to extract the object literal, then
* json_parse only that small substring (always < 1 KB)
* "body" jp_parse_string_raw to unescape the JSON string in one pass,
* without building any intermediate data structures */
static int http_parse_envelope(const char* s, int* out_status,
el_val_t* out_headers_map, char** out_body,
el_val_t* out_parsed_root) {
@@ -1344,37 +1368,91 @@ static int http_parse_envelope(const char* s, int* out_status,
if (strncmp(s, EL_HTTP_RESPONSE_TAG,
sizeof(EL_HTTP_RESPONSE_TAG) - 1) != 0) return 0;
el_val_t parsed = json_parse(EL_STR(s));
if (parsed == EL_NULL) return 0;
int status = 200;
el_val_t hmap = 0;
char* body = NULL;
el_val_t sv = el_map_get(parsed, EL_STR("status"));
if (sv != 0) {
/* status comes back as an integer — el_val_t holds it directly. */
long sc = (long)sv;
if (sc >= 100 && sc <= 599) status = (int)sc;
/* ── status ──────────────────────────────────────────────────────────── */
int status = 200;
{
const char* sp = strstr(s, "\"status\":");
if (sp) {
const char* np = sp + 9;
while (*np == ' ' || *np == '\t') np++;
long sc = strtol(np, NULL, 10);
if (sc >= 100 && sc <= 599) status = (int)sc;
}
}
el_val_t hv = el_map_get(parsed, EL_STR("headers"));
if (hv != 0) {
ElMap* hm = (ElMap*)(uintptr_t)hv;
if (hm && hm->hdr.magic == EL_MAGIC_MAP) hmap = hv;
/* ── headers ─────────────────────────────────────────────────────────── */
el_val_t hmap = 0;
el_val_t parsed_hdrs = EL_NULL;
{
const char* hp = strstr(s, "\"headers\":");
if (hp) {
hp += 10;
while (*hp == ' ' || *hp == '\t') hp++;
if (*hp == '{') {
/* Scan for matching '}', honouring nested objects and strings */
const char* hobj_start = hp;
const char* cp = hp + 1;
int depth = 1, in_str = 0;
while (*cp && depth > 0) {
if (in_str) {
if (*cp == '\\' && *(cp + 1)) { cp += 2; continue; }
if (*cp == '"') in_str = 0;
} else {
if (*cp == '"') in_str = 1;
else if (*cp == '{') depth++;
else if (*cp == '}') { if (--depth == 0) break; }
}
cp++;
}
if (depth == 0) {
/* cp points at the closing '}'; extract the object literal */
size_t hlen = (size_t)(cp - hobj_start + 1);
char* hobj = malloc(hlen + 1);
if (hobj) {
memcpy(hobj, hobj_start, hlen);
hobj[hlen] = '\0';
/* Headers are always simple k/v string pairs — json_parse
* is safe on this small substring (typically < 1 KB). */
parsed_hdrs = json_parse(EL_STR(hobj));
free(hobj);
if (parsed_hdrs != EL_NULL) {
ElMap* hm = (ElMap*)(uintptr_t)parsed_hdrs;
if (hm && hm->hdr.magic == EL_MAGIC_MAP) hmap = parsed_hdrs;
}
}
}
}
}
}
el_val_t bv = el_map_get(parsed, EL_STR("body"));
if (bv != 0) {
const char* bs = EL_CSTR(bv);
if (bs) body = el_strdup(bs);
/* ── body ────────────────────────────────────────────────────────────── */
/* Search forward so we don't accidentally match "body": inside a header
* value. http_response() always appends the body field last. */
char* body = NULL;
{
const char* bp = strstr(s, "\"body\":");
if (bp) {
bp += 7;
while (*bp == ' ' || *bp == '\t') bp++;
if (*bp == '"') {
/* jp_parse_string_raw unescapes a JSON string in one pass,
* producing a plain malloc'd C string. Caller frees it. */
JsonParser jp = { .p = bp, .end = bp + strlen(bp), .err = 0 };
char* parsed = jp_parse_string_raw(&jp);
if (!jp.err) {
body = parsed;
} else {
free(parsed);
}
}
}
if (!body) body = strdup("");
}
if (!body) body = el_strdup("");
*out_status = status;
*out_headers_map = hmap;
*out_body = body;
*out_parsed_root = parsed; /* caller releases to free hmap + entries */
*out_status = status;
*out_headers_map = hmap;
*out_body = body;
*out_parsed_root = parsed_hdrs; /* caller el_release()s to free hmap */
return 1;
}
@@ -1475,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;
@@ -1526,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 {
@@ -1900,6 +1997,33 @@ 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)
* to copy the response truncating the larger envelope before it reaches
* 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\":");
@@ -1909,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);
}
+103
View File
@@ -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).")
+53 -52
View File
@@ -8,41 +8,41 @@
from nav import { nav }
fn about_page() -> String {
return {nav()}
return nav() + "
<main id="about" style="padding: clamp(7rem, 18vh, 11rem) 2.5rem clamp(5rem, 12vh, 8rem);">
<div style="max-width: 700px; margin: 0 auto;">
<main id=\"about\" style=\"padding: clamp(7rem, 18vh, 11rem) 2.5rem clamp(5rem, 12vh, 8rem);\">
<div style=\"max-width: 700px; margin: 0 auto;\">
<p class="label animate-up-1" style="margin-bottom: 2rem;">About</p>
<h1 class="display-lg animate-up-2" style="margin-bottom: 2.5rem; max-width: 22rem;">
<p class=\"label animate-up-1\" style=\"margin-bottom: 2rem;\">About</p>
<h1 class=\"display-lg animate-up-2\" style=\"margin-bottom: 2.5rem; max-width: 22rem;\">
Hi. I&#39;m Will.
</h1>
<div class="navy-line-left animate-up-3" style="width: 4rem; margin-bottom: 3rem;"></div>
<div class=\"navy-line-left animate-up-3\" style=\"width: 4rem; margin-bottom: 3rem;\"></div>
<!-- Photo + opening -->
<div class="reveal" style="display: flex; align-items: flex-start; gap: 2.5rem; margin-bottom: 3rem; flex-wrap: wrap;">
<img src="/assets/will.png" alt="Will Anderson" style="width: 160px; height: 160px; border-radius: 50%; object-fit: cover; flex-shrink: 0;">
<div style="flex: 1; min-width: 260px;">
<p style="font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9; margin-bottom: 1.5rem;">
<div class=\"reveal\" style=\"display: flex; align-items: flex-start; gap: 2.5rem; margin-bottom: 3rem; flex-wrap: wrap;\">
<img src=\"/assets/will.png\" alt=\"Will Anderson\" style=\"width: 160px; height: 160px; border-radius: 50%; object-fit: cover; flex-shrink: 0;\">
<div style=\"flex: 1; min-width: 260px;\">
<p style=\"font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9; margin-bottom: 1.5rem;\">
I grew up in Fort Smith, Arkansas, in the kind of instability where home is a moving target - roughly thirty addresses before I was fifteen, parents struggling with addiction, the material precarity that comes with all of that. I left home at fifteen, stayed with friends until I finished high school, found my way to college. At fourteen I&#39;d already found software, writing C++ at the public library because it was the first thing in my life that responded to precision with correctness, and that property turned out to matter more to me than almost anything else.
</p>
</div>
</div>
<!-- Career -->
<div class="reveal" style="margin-bottom: 3rem;">
<p style="font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9; margin-bottom: 1.5rem;">
<div class=\"reveal\" style=\"margin-bottom: 3rem;\">
<p style=\"font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9; margin-bottom: 1.5rem;\">
I dropped out of college, worked, went back as an adult to finish my degree, and built my skills across nearly twenty years and every kind of organization - international consulting, early-stage startups, Fortune 5 enterprises. Logistics, retail, entertainment, hospitality, industrial automation, insurance, healthcare, financial services. I trained under Juval L&#246;wy at IDesign and worked with him as a consultant from 2015 to 2021, which is where I learned what it actually means to practice software engineering as a discipline rather than an improvisation.
</p>
</div>
<!-- Blockquote -->
<blockquote class="reveal" style="
<blockquote class=\"reveal\" style=\"
border-left: 3px solid var(--navy);
padding: 0.5rem 0 0.5rem 2rem;
margin: 0 0 3rem;
">
<p style="
\">
<p style=\"
font-family: var(--head);
font-size: clamp(1.4rem, 3vw, 2rem);
font-weight: 500;
@@ -50,42 +50,42 @@ fn about_page() -> String {
color: var(--t1);
line-height: 1.35;
letter-spacing: -0.01em;
">
\">
Software shouldn&#39;t be hard. The complexity should live in the problem domain - not in the tools and processes we impose on ourselves.
</p>
</blockquote>
<!-- What I saw -->
<div class="reveal" style="margin-bottom: 3rem;">
<p class="label" style="margin-bottom: 1.25rem;">What I saw</p>
<p style="font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9; margin-bottom: 1.5rem;">
<div class=\"reveal\" style=\"margin-bottom: 3rem;\">
<p class=\"label\" style=\"margin-bottom: 1.25rem;\">What I saw</p>
<p style=\"font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9; margin-bottom: 1.5rem;\">
Across nearly twenty years I watched software get built at organizations with real stakes and real consequences, and I watched AI go from promise to product - watched the same mistake get made at each iteration: tools built to serve the organization&#39;s needs, not the person&#39;s. Engagement over relationship. Features over memory. Policies where values should be. The fundamental premise that you are a user, not a person, has been so thoroughly baked into the architecture of every major AI system that it doesn&#39;t register as a choice anymore. It&#39;s treated as the natural condition of the technology.
</p>
<p style="font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9;">
<p style=\"font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9;\">
It is not. It is a design decision. And it is the wrong one.
</p>
</div>
<div class="navy-line-center reveal" style="margin-bottom: 3rem;"></div>
<div class=\"navy-line-center reveal\" style=\"margin-bottom: 3rem;\"></div>
<!-- What I built -->
<div class="reveal" style="margin-bottom: 3rem;">
<p class="label" style="margin-bottom: 1.25rem;">What I built</p>
<p style="font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9; margin-bottom: 1.5rem;">
<div class=\"reveal\" style=\"margin-bottom: 3rem;\">
<p class=\"label\" style=\"margin-bottom: 1.25rem;\">What I built</p>
<p style=\"font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9; margin-bottom: 1.5rem;\">
Neuron is what I built in response to that. Not a startup in the traditional sense - no team, no funding, no press release - one person, nearly two years of work, and a conviction that this can be done differently. I wrote the memory architecture, I built the inference infrastructure, because the tools that existed weren&#39;t sufficient for what I was trying to build and so I built those too.
</p>
<p style="font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9;">
<p style=\"font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9;\">
Use it long enough and you&#39;ll understand why I couldn&#39;t have gotten there on top of existing infrastructure. Some things have to be built from the ground up to be built right.
</p>
</div>
<!-- What I believe -->
<div class="reveal" style="margin-bottom: 3.5rem;">
<p class="label" style="margin-bottom: 1.25rem;">What I believe</p>
<p style="font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9; margin-bottom: 1.5rem;">
<div class=\"reveal\" style=\"margin-bottom: 3.5rem;\">
<p class=\"label\" style=\"margin-bottom: 1.25rem;\">What I believe</p>
<p style=\"font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9; margin-bottom: 1.5rem;\">
AI has genuine potential to free people to do work that actually matters to them - not to create engagement loops, not to harvest attention, but to actually serve the person sitting in front of it. That potential is almost entirely unrealized, not because the technology isn&#39;t capable, but because the incentives that shaped it were never oriented toward the person.
</p>
<p style="
<p style=\"
font-family: var(--head);
font-size: clamp(1.2rem, 2.5vw, 1.625rem);
font-weight: 600;
@@ -93,22 +93,22 @@ fn about_page() -> String {
line-height: 1.35;
letter-spacing: -0.01em;
margin-bottom: 1.5rem;
">
\">
Build AI that earns the trust it&#39;s given.
</p>
<p style="font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9;">
<p style=\"font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9;\">
I don&#39;t know if Neuron will work at the scale I&#39;m imagining. But I know it&#39;s worth finding out, and I know I&#39;m not going back to the other way of building things.
</p>
</div>
<div class="navy-line-center reveal" style="margin-bottom: 3rem;"></div>
<div class=\"navy-line-center reveal\" style=\"margin-bottom: 3rem;\"></div>
<!-- CTA -->
<div class="reveal">
<p style="font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9; margin-bottom: 1.5rem;">
<div class=\"reveal\">
<p style=\"font-family: var(--body); font-weight: 300; font-size: clamp(0.9rem, 1.5vw, 1.0625rem); color: var(--t2); line-height: 1.9; margin-bottom: 1.5rem;\">
Neuron opens to founding members on May 1st. 1,000 spots. That&#39;s how it starts.
</p>
<a href="/#pricing" class="btn-primary">
<a href=\"/#pricing\" class=\"btn-primary\">
Join as a founding member &#8594;
</a>
</div>
@@ -116,34 +116,35 @@ fn about_page() -> String {
</div>
</main>
<footer id="footer" aria-label="Footer">
<div class="container">
<div class="footer-inner">
<footer id=\"footer\" aria-label=\"Footer\">
<div class=\"container\">
<div class=\"footer-inner\">
<a href="/" class="footer-brand" aria-label="Neuron home" style="display:flex;flex-direction:column;align-items:center;">
<img src="/assets/brand/neuron-wordmark-on-light.png" srcset="/assets/brand/neuron-wordmark-on-light@2x.png 2x" alt="Neuron" height="24" style="display:block;margin-bottom:0.35rem;">
<p class="footer-brand-tagline">Built Different.</p>
<a href=\"/\" class=\"footer-brand\" aria-label=\"Neuron home\" style=\"display:flex;flex-direction:column;align-items:center;\">
<img src=\"/assets/brand/neuron-wordmark-on-light.png\" srcset=\"/assets/brand/neuron-wordmark-on-light@2x.png 2x\" alt=\"Neuron\" height=\"24\" style=\"display:block;margin-bottom:0.35rem;\">
<p class=\"footer-brand-tagline\">Built Different.</p>
</a>
<div class="footer-center">
<div class="navy-line"></div>
<div class=\"footer-center\">
<div class=\"navy-line\"></div>
</div>
<div class="footer-right">
<p class="footer-domain">neurontechnologies.ai</p>
<nav class="footer-nav" aria-label="Footer navigation">
<a href="/legal/terms">Terms</a>
<a href="/legal/enterprise-terms">Enterprise Agreement</a>
<a href="mailto:legal@neurontechnologies.ai">Contact</a>
<div class=\"footer-right\">
<p class=\"footer-domain\">neurontechnologies.ai</p>
<nav class=\"footer-nav\" aria-label=\"Footer navigation\">
<a href=\"/legal/terms\">Terms</a>
<a href=\"/legal/enterprise-terms\">Enterprise Agreement</a>
<a href=\"mailto:legal@neurontechnologies.ai\">Contact</a>
</nav>
</div>
</div>
<div class="footer-bottom">
<p class="footer-copy">&copy; 2026 Neuron, LLC. All rights reserved.</p>
<p class="footer-tagline-bottom">Your memory. Your AI.</p>
<div class=\"footer-bottom\">
<p class=\"footer-copy\">&copy; 2026 Neuron, LLC. All rights reserved.</p>
<p class=\"footer-tagline-bottom\">Your memory. Your AI.</p>
</div>
</div>
</footer>
"
}
+129 -6
View File
@@ -5,7 +5,7 @@ from founding_badge import { founding_badge, founding_badge_css }
extern fn el_html_doc(lang: String, head: String, body: String) -> String
extern fn el_meta_charset(charset: String) -> String
extern fn el_meta(attrs: String) -> String
extern fn el_meta(name: String, content: String) -> String
extern fn el_title(text: String) -> String
extern fn el_link_stylesheet(href: String) -> String
extern fn el_script_src(src: String, defer_load: Bool) -> String
@@ -13,7 +13,7 @@ extern fn el_script_inline(code: String) -> String
extern fn el_nav(attrs: String, children: String) -> String
extern fn el_div(attrs: String, children: String) -> String
extern fn el_a(href: String, attrs: String, children: String) -> String
extern fn el_img(attrs: String) -> String
extern fn el_img(src: String, alt: String, attrs: String) -> String
extern fn el_p(attrs: String, children: String) -> String
extern fn el_h1(attrs: String, text: String) -> String
extern fn el_button(attrs: String, label: String) -> String
@@ -493,12 +493,34 @@ 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>"
}
fn account_nav() -> String {
let logo_img: String = el_img("src=\"/assets/brand/neuron-wordmark-on-light.png\" srcset=\"/assets/brand/neuron-wordmark-on-light@2x.png 2x\" alt=\"Neuron\" height=\"28\"")
let logo_img: String = el_img("/assets/brand/neuron-wordmark-on-light.png", "Neuron", "srcset=\"/assets/brand/neuron-wordmark-on-light@2x.png 2x\" height=\"28\"")
el_nav(
"id=\"nav\"",
el_div(
@@ -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 &#8599;</a></li>" +
"<li>Click <strong>Create new secret key</strong></li>" +
"<li>Give it a name (e.g. &#8220;Neuron&#8221;), then click <strong>Create secret key</strong></li>" +
"<li>Copy the key immediately &#8212; 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 &#8599;</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 &#8599;</a></li>" +
"<li>Click <strong>Create Key</strong></li>" +
"<li>Give it a name (e.g. &#8220;Neuron&#8221;), then click <strong>Create Key</strong></li>" +
"<li>Copy the key immediately &#8212; 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 &#8594; Plans &amp; Billing &#8599;</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 &#8599;</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 &#8599;</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 &#8212; 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 &#8212; 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) &#183; Claude Sonnet 4 (Anthropic) &#183; Gemini 2.5 Pro (Google) &#183; Grok-3 (xAI). You choose the model in the app &#8212; 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> &#8212; our own model layer, priced below the major APIs &#8212; 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() +
@@ -842,9 +965,9 @@ fn account_dashboard_section() -> String {
fn account_page(supabase_url: String, supabase_anon_key: String) -> String {
let head: String =
el_meta_charset("UTF-8") +
el_meta("name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"") +
el_meta("viewport", "width=device-width, initial-scale=1.0") +
el_title("My Account - Neuron") +
el_meta("name=\"description\" content=\"Manage your Neuron account, view your plan, and access your founding member details.\"") +
el_meta("description", "Manage your Neuron account, view your plan, and access your founding member details.") +
"<link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/assets/favicon-16.png\">" +
"<link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/assets/favicon-32.png\">" +
"<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">" +
+31 -17
View File
@@ -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;\"", "&#10003;")
+ el_p("class=\"label\" style=\"margin-bottom:.75rem; color:var(--navy);\"", "You&#39;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 &#8594;")
)
} 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 &#8594;" } else { "Complete purchase &#8594;" }
let submit_label: String = if is_free { "Verify age &amp; get started &#8594;" } else { "Complete purchase &#8594;" }
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
View File
@@ -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)
)
+8 -2
View File
@@ -1,7 +1,7 @@
// components/enterprise_terms.el - Enterprise Agreement page.
// Returns complete HTML using the shared page shell from styles.el.
from styles import { page_open, page_close }
from styles import { page_open_seo, page_close }
from nav import { nav }
extern fn el_div(attrs: String, children: String) -> String
@@ -15,7 +15,13 @@ extern fn el_li(attrs: String, children: String) -> String
extern fn el_em(children: String) -> String
fn enterprise_terms_page() -> String {
page_open() + enterprise_terms_body() + page_close()
page_open_seo(
"Enterprise Agreement — Neuron",
"The Neuron Enterprise Agreement governs enterprise deployments of Neuron software. Review licensing terms, data handling, and compliance provisions.",
"/legal/enterprise-terms",
"The Neuron Enterprise Agreement — governing enterprise deployments, licensing, data handling, and compliance.",
"false"
) + enterprise_terms_body() + page_close()
}
fn et_section(num: String, title: String, body: String) -> String {
+1
View File
@@ -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.
+5 -2
View File
@@ -11,7 +11,7 @@ fn main() -> Void {
'use strict';
var cfg = window.NEURON_CFG || {};
var sb = supabase.createClient(cfg.supabase_url, cfg.supabase_anon_key, {
auth: { flowType: 'pkce' }
auth: { flowType: 'implicit' }
});
window.sendMagicLink = async function() {
@@ -25,7 +25,10 @@ fn main() -> Void {
return;
}
if (btn) { btn.disabled = true; btn.textContent = 'Sending...'; }
var result = await sb.auth.signInWithOtp({ email: email });
var result = await sb.auth.signInWithOtp({
email: email,
options: { emailRedirectTo: window.location.origin + '/account' }
});
if (btn) { btn.disabled = false; btn.textContent = 'Continue with email'; }
msgEl.style.display = 'block';
if (result.error) {
+87
View File
@@ -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() {
+293 -56
View File
@@ -1,9 +1,9 @@
// chat-widget.el -- Neuron demo chat widget with Turnstile, session persistence,
// local engram graph, and share-pill.
// chat-widget.el -- Neuron demo chat widget with Supabase auth, Turnstile,
// session persistence, local engram graph, and share-pill.
// Compiled with: elc --target=js --bundle --minify --obfuscate
//
// Exposed globals: neuronDemoToggle(), neuronDemoSend(), neuronDemoReset()
// Required CDN: marked.js, Cloudflare Turnstile
// Required CDN: marked.js, Cloudflare Turnstile, Supabase JS
fn main() -> Void {
native_js("(function() {
@@ -14,15 +14,155 @@ 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;
var _supabaseSession = null; // current session (null = not authenticated)
function initSupabaseWidget(cb) {
if (supabaseClient) { cb(); return; }
fetch('/api/supabase-config')
.then(function(r) { return r.json(); })
.then(function(cfg) {
supabaseClient = window.supabase.createClient(cfg.url, cfg.anon_key, {
auth: { flowType: 'implicit' }
});
supabaseClient.auth.getSession().then(function(res) {
if (res.data && res.data.session) {
_supabaseSession = res.data.session;
}
// Listen for sign-in from OAuth redirect
supabaseClient.auth.onAuthStateChange(function(event, session) {
if (session) {
_supabaseSession = session;
_onWidgetAuthenticated();
}
});
cb();
});
})
.catch(function() { cb(); });
}
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');
var inputRow = document.getElementById('neuron-demo-input-row');
if (authPane) authPane.style.display = 'none';
// Only reveal chat UI if Turnstile has also passed
if (turnstileVerified) {
if (gate) gate.style.display = 'none';
if (msgs) msgs.style.display = 'flex';
if (inputRow) inputRow.style.display = 'flex';
var msgs2 = document.getElementById('neuron-demo-messages');
if (msgs2 && msgs2.children.length === 0) {
if (session && session.messages && session.messages.length > 0) {
session.messages.forEach(function(m) { addMsg(m.role, m.text, true); });
} else if (!session.greeted) {
_sendIntroGreeting();
}
}
var inp = document.getElementById('neuron-demo-text');
if (inp) inp.focus();
}
}
function _renderWidgetAuthPane() {
var authPane = document.getElementById('neuron-demo-auth');
if (!authPane) return;
authPane.innerHTML = '';
authPane.style.display = 'flex';
var heading = document.createElement('p');
heading.className = 'demo-auth-heading';
heading.textContent = 'Sign in to chat with Neuron';
authPane.appendChild(heading);
var googleBtn = document.createElement('button');
googleBtn.className = 'demo-auth-google-btn';
googleBtn.innerHTML = '<svg width=\"18\" height=\"18\" viewBox=\"0 0 48 48\"><path fill=\"#EA4335\" d=\"M24 9.5c3.14 0 5.95 1.08 8.17 2.84l6.09-6.09C34.46 3.19 29.53 1 24 1 14.62 1 6.68 6.84 3.32 15.09l7.1 5.52C12.16 14.02 17.6 9.5 24 9.5z\"/><path fill=\"#4285F4\" d=\"M46.5 24.5c0-1.64-.15-3.22-.42-4.75H24v9h12.7c-.55 2.99-2.2 5.53-4.68 7.24l7.19 5.59C43.07 37.23 46.5 31.3 46.5 24.5z\"/><path fill=\"#FBBC05\" d=\"M10.42 28.39A14.6 14.6 0 0 1 9.5 24c0-1.52.26-3 .72-4.39l-7.1-5.52A23.5 23.5 0 0 0 .5 24c0 3.78.88 7.36 2.44 10.56l7.48-6.17z\"/><path fill=\"#34A853\" d=\"M24 47c5.53 0 10.17-1.83 13.56-4.97l-7.19-5.59C28.56 37.88 26.38 38.5 24 38.5c-6.4 0-11.84-4.52-13.58-10.61l-7.48 6.17C6.68 43.16 14.62 47 24 47z\"/><path fill=\"none\" d=\"M0 0h48v48H0z\"/></svg> Continue with Google';
googleBtn.onclick = function() {
if (!supabaseClient) return;
supabaseClient.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: window.location.href }
});
};
authPane.appendChild(googleBtn);
var emailToggle = document.createElement('button');
emailToggle.className = 'demo-auth-email-toggle';
emailToggle.textContent = 'or continue with email';
authPane.appendChild(emailToggle);
var emailForm = document.createElement('div');
emailForm.className = 'demo-auth-email-form';
emailForm.style.display = 'none';
emailForm.innerHTML = '<input type=\"email\" id=\"demo-auth-email\" placeholder=\"Email\" autocomplete=\"email\" />'
+ '<input type=\"password\" id=\"demo-auth-password\" placeholder=\"Password\" autocomplete=\"current-password\" />'
+ '<button class=\"demo-auth-submit-btn\" id=\"demo-auth-submit\">Sign in</button>'
+ '<p class=\"demo-auth-msg\" id=\"demo-auth-msg\" style=\"display:none\"></p>';
authPane.appendChild(emailForm);
emailToggle.onclick = function() {
emailForm.style.display = emailForm.style.display === 'none' ? 'flex' : 'none';
};
var submitBtn = emailForm.querySelector('#demo-auth-submit');
if (submitBtn) {
submitBtn.onclick = function() {
var email = (document.getElementById('demo-auth-email') || {}).value || '';
var pass = (document.getElementById('demo-auth-password') || {}).value || '';
var msgEl = document.getElementById('demo-auth-msg');
if (!email || !pass) {
if (msgEl) { msgEl.textContent = 'Email and password required.'; msgEl.style.display = ''; msgEl.style.color = '#e53e3e'; }
return;
}
submitBtn.disabled = true;
supabaseClient.auth.signInWithPassword({ email: email, password: pass }).then(function(res) {
if (res.error) {
if (msgEl) { msgEl.textContent = res.error.message || 'Sign in failed.'; msgEl.style.display = ''; msgEl.style.color = '#e53e3e'; }
submitBtn.disabled = false;
} else {
_supabaseSession = res.data.session;
_onWidgetAuthenticated();
}
}).catch(function() { submitBtn.disabled = false; });
};
}
}
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) {}
@@ -127,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 = '';
@@ -138,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() {
@@ -148,7 +339,7 @@ fn main() -> Void {
var btn = document.getElementById('neuron-demo-btn');
if (btn) btn.style.display = isOpen ? 'none' : '';
var msgs = document.getElementById('neuron-demo-messages');
if (isOpen && turnstileVerified && msgs && msgs.style.display !== 'none' && msgs.children.length === 0) {
if (isOpen && turnstileVerified && _supabaseSession && msgs && msgs.style.display !== 'none' && msgs.children.length === 0) {
if (session.messages && session.messages.length > 0) {
session.messages.forEach(function(m) { addMsg(m.role, m.text, true); });
var remaining = MAX - msgCount;
@@ -157,44 +348,63 @@ 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');
if (isOpen && input && !input.disabled) input.focus();
updateCountdown();
if (isOpen && !turnstileWidgetId && typeof turnstile !== 'undefined') {
var container = document.getElementById('neuron-demo-turnstile');
if (container) {
turnstileWidgetId = turnstile.render(container, {
sitekey: TURNSTILE_SITE_KEY,
size: 'compact',
callback: function(token) {
turnstileToken = token;
turnstileVerified = true;
if (typeof turnstile !== 'undefined' && turnstileWidgetId !== null) {
try { turnstile.remove(turnstileWidgetId); } catch(e) {}
turnstileWidgetId = null;
if (isOpen) {
// Initialize Supabase on first open, then decide what to show
initSupabaseWidget(function() {
if (!_supabaseSession) {
// Not authenticated show auth pane, hide Turnstile gate
var gate = document.getElementById('neuron-demo-gate');
if (gate) gate.style.display = 'none';
_renderWidgetAuthPane();
} else {
// Authenticated proceed with Turnstile gate as normal
if (!turnstileWidgetId && typeof turnstile !== 'undefined') {
var container = document.getElementById('neuron-demo-turnstile');
if (container) {
turnstileWidgetId = turnstile.render(container, {
sitekey: TURNSTILE_SITE_KEY,
size: 'compact',
callback: function(token) {
turnstileToken = token;
turnstileVerified = true;
if (typeof turnstile !== 'undefined' && turnstileWidgetId !== null) {
try { turnstile.remove(turnstileWidgetId); } catch(e) {}
turnstileWidgetId = null;
}
var gate = document.getElementById('neuron-demo-gate');
var msgs = document.getElementById('neuron-demo-messages');
var inputRow = document.getElementById('neuron-demo-input-row');
if (gate) gate.style.display = 'none';
if (msgs) msgs.style.display = 'flex';
if (inputRow) inputRow.style.display = 'flex';
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();
},
'expired-callback': function() {
turnstileToken = '';
turnstileVerified = false;
}
});
}
var gate = document.getElementById('neuron-demo-gate');
var msgs = document.getElementById('neuron-demo-messages');
var inputRow = document.getElementById('neuron-demo-input-row');
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();
var inp = document.getElementById('neuron-demo-text');
if (inp) inp.focus();
},
'expired-callback': function() {
turnstileToken = '';
turnstileVerified = false;
}
});
}
}
});
}
};
@@ -287,6 +497,7 @@ fn main() -> Void {
return;
}
input.value = '';
input.style.height = 'auto';
btn.disabled = true;
addMsg('user', msg);
@@ -310,6 +521,7 @@ fn main() -> Void {
});
var activated_nodes = _ra(session._m, msg);
var questionsRemaining = Math.max(0, (MAX - msgCount) - 1);
var accessToken = (_supabaseSession && _supabaseSession.access_token) ? _supabaseSession.access_token : '';
var r = await fetch('/api/demo', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
@@ -318,6 +530,10 @@ fn main() -> Void {
history: hist,
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,
@@ -330,34 +546,51 @@ 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;
}
// Auth required show auth pane again
if (d.auth_required) {
addMsg('ai', 'Please sign in to continue chatting with Neuron.');
_renderWidgetAuthPane();
var msgs2 = document.getElementById('neuron-demo-messages');
var inputRow2 = document.getElementById('neuron-demo-input-row');
if (msgs2) msgs2.style.display = 'none';
if (inputRow2) inputRow2.style.display = 'none';
return;
}
// Demo disabled by budget circuit breaker
if (d.disabled) {
addMsg('ai', d.error || 'The demo is temporarily unavailable. Check back soon.');
if (input) { input.disabled = true; input.placeholder = 'Demo unavailable'; }
if (btn) { btn.disabled = true; }
return;
}
@@ -385,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';
});
}
})()")
}
+18 -11
View File
@@ -29,15 +29,15 @@ fn main() -> Void {
el.style.color = isError ? '#c0392b' : '#2ecc71';
}
var _formRevealed = false;
function revealPaymentForm(user) {
if (_formRevealed) return;
_formRevealed = true;
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) {
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');
@@ -58,17 +58,24 @@ 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() {
initSupabase(function() {
supabaseClient.auth.getUser().then(function(res) {
if (res.data && res.data.user) { revealPaymentForm(res.data.user); }
if (res.data && res.data.user) {
revealPaymentForm(res.data.user);
} else {
// No existing session for paid plans, init Stripe immediately.
// Auth is optional on paid plans; the user can link their account later.
var isFree = (window.NEURON_CFG || {}).plan === 'free';
if (!isFree && typeof window.initStripe === 'function') {
window.initStripe('', '');
}
}
});
});
}
+3 -17
View File
@@ -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);
})()")
}
+3 -3
View File
@@ -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);
+479 -55
View File
@@ -78,7 +78,7 @@ from pricing import { pricing }
from marketplace import { marketplace }
from viral import { viral }
from footer import { footer }
from styles import { page_open, page_close }
from styles import { page_open, page_open_seo, page_close }
from about import { about_page }
from founding_badge import { founding_badge, founding_badge_css }
from terms import { terms_page }
@@ -224,7 +224,7 @@ fn share_card_page(question: String, answer_plain: String, answer_html_in: Strin
// Use plaintext for og:description so social previews are readable.
let answer: String = answer_plain
let og_desc: String = str_slice(answer, 0, 140)
let base_url: String = state_get("__neuron_origin__")
let base_url: String = state_get("__origin__")
let card_url: String = base_url + "/share/" + id
// Pre-built share hrefs ID is digits so no URL encoding needed
let share_text: String = "The+AI+that+remembers+you+%E2%80%94+things+it+said%3A"
@@ -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 ""
}
@@ -554,7 +556,7 @@ fn config_get(key: String) -> String {
// function - it serves __html_file__ directly with text/html.
// This handler covers /api/* and /brand/* routes.
fn handle_request_inner(method: String, path: String, body: String) -> String {
fn handle_request_inner(method: String, path: String, headers: Map, body: String) -> String {
let src_dir: String = state_get("__src_dir__")
// Root serve El-generated landing page
@@ -572,7 +574,19 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
// robots.txt
if str_eq(path, "/robots.txt") {
return "User-agent: *\nAllow: /\n"
return "User-agent: *\nAllow: /\nDisallow: /checkout\nDisallow: /account\nDisallow: /api/\nSitemap: https://neurontechnologies.ai/sitemap.xml\n"
}
// sitemap.xml
if str_eq(path, "/sitemap.xml") {
let sitemap_body: String = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
+ "<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n"
+ " <url><loc>https://neurontechnologies.ai/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>\n"
+ " <url><loc>https://neurontechnologies.ai/about</loc><changefreq>monthly</changefreq><priority>0.8</priority></url>\n"
+ " <url><loc>https://neurontechnologies.ai/legal/terms</loc><changefreq>monthly</changefreq><priority>0.3</priority></url>\n"
+ " <url><loc>https://neurontechnologies.ai/legal/enterprise-terms</loc><changefreq>monthly</changefreq><priority>0.3</priority></url>\n"
+ "</urlset>\n"
return http_response(200, "{\"Content-Type\":\"application/xml; charset=utf-8\"}", sitemap_body)
}
// About page
@@ -612,7 +626,25 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
plan = "free"
}
let pub_key: String = state_get("__stripe_publishable_key__")
return page_open() + checkout_page(plan, pub_key) + page_close()
let checkout_title: String = if str_eq(plan, "founding") {
"Founding Member Checkout — Neuron"
} else {
if str_eq(plan, "free") {
"Get Started Free — Neuron"
} else {
"Professional Plan Checkout — Neuron"
}
}
let checkout_desc: String = if str_eq(plan, "founding") {
"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. 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."
}
}
return page_open_seo(checkout_title, checkout_desc, "/checkout", checkout_desc, "true") + checkout_page(plan, pub_key) + page_close()
}
// Stripe payment intent / setup intent
@@ -636,10 +668,6 @@ fn handle_request_inner(method: String, path: String, body: String) -> 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()
@@ -658,7 +686,7 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
if !str_eq(pi_email, "") {
let pi_email_enc: String = str_replace(str_replace(pi_email, "@", "%40"), "+", "%2B")
let pi_search_url: String = "https://api.stripe.com/v1/customers/search?query=email%3A%22" + pi_email_enc + "%22&limit=1"
let pi_search: String = http_get_auth(pi_search_url, auth_header)
let pi_search: String = http_get_auth(pi_search_url, stripe_key)
let pi_cus_id = json_get_string(pi_search, "id")
if str_eq(pi_cus_id, "") {
let pi_name_enc: String = str_replace(pi_name, " ", "%20")
@@ -671,6 +699,26 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
}
}
// Free tier: SetupIntent for age verification (18+ requirement).
// Verifies card is valid and saves it. No charge, no capture.
// $0 PaymentIntents are rejected by Stripe; SetupIntent is the correct tool.
if str_eq(plan, "free") {
let si_body: String = "automatic_payment_methods[enabled]=true"
+ "&usage=off_session"
+ "&metadata[plan]=free"
+ "&metadata[purpose]=age_verification"
let si_body = if !str_eq(pi_cus_id, "") { si_body + "&customer=" + pi_cus_id } else { si_body }
let si_resp: String = http_post_form_auth(
"https://api.stripe.com/v1/setup_intents",
si_body,
auth_header)
if str_starts_with(si_resp, "{") {
let inner: String = str_slice(si_resp, 1, str_len(si_resp))
return "{\"setup_mode\":true,\"plan\":\"free\"," + inner
}
return si_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") {
@@ -739,7 +787,7 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
// 1. Search existing customers by email
let lc_search_url: String = "https://api.stripe.com/v1/customers/search?query=email%3A%22" + lc_email_enc + "%22&limit=1"
let lc_search: String = http_get_auth(lc_search_url, lc_auth)
let lc_search: String = http_get_auth(lc_search_url, stripe_key)
let lc_cus_id: String = json_get_string(lc_search, "id")
// 2. If none, create one. We always include supabase_user_id so the
@@ -830,6 +878,32 @@ fn handle_request_inner(method: String, path: String, body: String) -> 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
@@ -894,6 +968,8 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
}
// Static assets: /assets/*
// Returns Cache-Control: public, max-age=31536000, immutable so Cloudflare
// caches these at the edge and never forwards subsequent requests to Cloud Run.
if str_starts_with(path, "/assets/") {
let rel: String = str_slice(path, 8, str_len(path))
let abs: String = src_dir + "/assets/" + rel
@@ -901,12 +977,16 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
if str_eq(content, "") {
return "{\"__status__\":404,\"error\":\"not found\"}"
}
return content
return http_response(200, static_asset_headers_json(), content)
}
// Compiled client-side JS: /js/*
// Served from dist/js/ (compiled by elc --target=js at build time).
// LANDING_ROOT/js maps to the dist/js output directory in the image.
// Returns an http_response envelope with explicit Content-Type so the
// browser executes the file as JavaScript http_detect_content_type()
// mis-identifies minified/obfuscated JS as JSON because many obfuscated
// bundles start with '[' (which is also a JSON array opener).
if str_starts_with(path, "/js/") {
let rel: String = str_slice(path, 4, str_len(path))
let abs: String = src_dir + "/js/" + rel
@@ -914,10 +994,11 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
if str_eq(content, "") {
return "{\"__status__\":404,\"error\":\"not found\"}"
}
return content
return http_response(200, js_headers_json(), content)
}
// Brand assets: /brand/*
// Same long-lived cache policy as /assets/* served from edge, not Cloud Run.
if str_starts_with(path, "/brand/") {
let rel: String = str_slice(path, 7, str_len(path))
let abs: String = src_dir + "/brand/" + rel
@@ -925,7 +1006,7 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
if str_eq(content, "") {
return "{\"__status__\":404,\"error\":\"not found\"}"
}
return content
return http_response(200, static_asset_headers_json(), content)
}
// Stripe checkout
@@ -1038,13 +1119,16 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
}
let attest_name: String = json_get(body, "name")
let attest_email: String = json_get(body, "email")
let attest_plan: String = json_get(body, "plan")
let attest_ts: String = json_get(body, "timestamp")
let attest_text: String = json_get(body, "attestation")
let attest_ua: String = json_get(body, "user_agent")
if str_eq(attest_email, "") {
return "{\"error\":\"email required\"}"
}
// Founding membership now requires $199 Stripe payment the attestation
// form is a waitlist-only path. Server enforces this regardless of what
// the client submits as plan to prevent bypassing payment.
let attest_plan: String = "waitlist"
let n_safe: String = str_replace(str_replace(attest_name, "\\", "\\\\"), "\"", "\\\"")
let e_safe: String = str_replace(str_replace(attest_email, "\\", "\\\\"), "\"", "\\\"")
let t_safe: String = str_replace(str_replace(attest_text, "\\", "\\\\"), "\"", "\\\"")
@@ -1109,14 +1193,38 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
}
// Supabase public config
// CORS-gated: only requests from neurontechnologies.ai origins or localhost
// may fetch the anon key. Restricting this reduces the blast radius of any
// future Supabase RLS misconfiguration an attacker on an arbitrary origin
// 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")
// 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\"}"
}
let proj_url: String = "https://ocojsghaonltunidkzpw.supabase.co"
let anon_key: String = state_get("__supabase_anon_key__")
return "{\"url\":\"" + proj_url + "\",\"anon_key\":\"" + anon_key + "\"}"
}
// Soul health diagnostic surfaces raw signal from in-container soul
// Requires X-Internal: true header to prevent public exposure of internal
// service topology, soul URL, and probe responses.
if str_eq(path, "/api/soul-health") {
let x_internal: String = map_get(headers, "x-internal")
if !str_eq(x_internal, "true") {
return "{\"__status__\":404,\"error\":\"not found\"}"
}
if str_eq(method, "GET") {
let soul_base: String = state_get("__soul_url__")
// Probe 1: bare GET / does ANYTHING listen?
@@ -1157,36 +1265,143 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
if str_len(msg) > 8000 {
return "{\"error\":\"Message too long. Please keep your message under 8000 characters.\"}"
}
// Rate limit: 10 chats per uid per day (UTC day, keyed by uid).
// State key: "__rl_<uid>" "<count>|<day_number>"
// day_number = unix_timestamp / 86400 (integer UTC day)
// Returns rate_limited JSON with reset_at (next midnight UTC) so
// the frontend can show a real countdown.
let rate_uid: String = json_get(body, "uid")
if !str_eq(rate_uid, "") {
let now_ts: Int = unix_timestamp()
let today_day: Int = now_ts / 86400
let next_reset: Int = (today_day + 1) * 86400
let rl_key: String = "__rl_" + rate_uid
let rl_val: String = state_get(rl_key)
let rl_count: Int = 0
let rl_day: Int = 0
if !str_eq(rl_val, "") {
// format: "count|day"
let parts: [String] = str_split(rl_val, "|")
if native_list_len(parts) >= 2 {
let rl_count = str_to_int(native_list_get(parts, 0))
let rl_day = str_to_int(native_list_get(parts, 1))
// Kill switch budget circuit breaker (Supabase demo_config)
// Polls demo_config.demo_enabled every 60s. Fails open on error so
// a Supabase hiccup does not break the demo for legitimate users.
let ks_sb_url: String = state_get("__supabase_project_url__")
let ks_sb_key: String = state_get("__supabase_service_key__")
let ks_now: Int = unix_timestamp()
let ks_checked_at: String = state_get("__demo_enabled_checked_at__")
let ks_checked_n: Int = if str_eq(ks_checked_at, "") { 0 } else { str_to_int(ks_checked_at) }
let ks_enabled: String = state_get("__demo_enabled_cache__")
// On first boot set defaults
if str_eq(ks_enabled, "") {
state_set("__demo_enabled_cache__", "true")
let ks_enabled = "true"
}
// Refresh cache if >60s old and service key is present
if (ks_now - ks_checked_n) > 60 && !str_eq(ks_sb_key, "") {
let ks_resp: String = supabase_get(ks_sb_url, ks_sb_key,
"demo_config?key=eq.demo_enabled&select=value&limit=1")
let ks_row: String = json_array_get(ks_resp, 0)
if !str_eq(ks_row, "") {
let ks_val: String = json_get(ks_row, "value")
if !str_eq(ks_val, "") {
state_set("__demo_enabled_cache__", ks_val)
let ks_enabled = ks_val
}
}
// Reset count if it's a new day
if rl_day != today_day {
let rl_count = 0
state_set("__demo_enabled_checked_at__", int_to_str(ks_now))
}
if str_eq(ks_enabled, "false") {
return "{\"error\":\"The demo is temporarily unavailable. Check back soon.\",\"disabled\":true}"
}
// Global circuit breaker
// Caps total demo requests per Cloud Run instance per UTC day to 2000.
// This bounds per-instance API spend regardless of uid diversity.
// Stored in process state (in-memory) intentionally per-instance
// so no cross-instance coordination is needed for this coarse cap.
let now_ts_cb: Int = unix_timestamp()
let today_day_cb: Int = now_ts_cb / 86400
let global_day_s: String = state_get("__global_demo_day__")
let global_cnt_s: String = state_get("__global_demo_count__")
let global_day: Int = if str_eq(global_day_s, "") { 0 } else { str_to_int(global_day_s) }
let global_cnt: Int = if str_eq(global_cnt_s, "") { 0 } else { str_to_int(global_cnt_s) }
// Reset on new UTC day
if global_day != today_day_cb {
state_set("__global_demo_day__", int_to_str(today_day_cb))
state_set("__global_demo_count__", "0")
let global_cnt = 0
}
if global_cnt >= 2000 {
return "{\"error\":\"Demo is temporarily busy. Try again in a few minutes.\",\"busy\":true}"
}
state_set("__global_demo_count__", int_to_str(global_cnt + 1))
// Auth: verify Supabase access_token
// The widget sends an access_token from the signed-in Supabase session.
// Verify it against the Supabase auth API to get the verified user ID.
// Reject unauthenticated requests outright.
let access_token: String = json_get(body, "access_token")
let auth_sb_url: String = state_get("__supabase_project_url__")
let auth_anon: String = state_get("__supabase_anon_key__")
let verified_uid: String = ""
if str_eq(access_token, "") {
return "{\"error\":\"Sign in required to use the demo.\",\"auth_required\":true}"
}
// supabase_auth_user calls GET /auth/v1/user with both Authorization
// (user's Bearer token) and apikey (anon key) headers.
let auth_resp: String = supabase_auth_user(auth_sb_url, auth_anon, access_token)
let auth_uid: String = json_get(auth_resp, "id")
if str_eq(auth_uid, "") {
return "{\"error\":\"Sign in required to use the demo.\",\"auth_required\":true}"
}
let verified_uid = auth_uid
// Per-uid rate limit (Supabase shared across all instances)
// Uses demo_rate_limits table: uid (PK), count, day_number, updated_at.
// Falls back to in-process state_get/state_set when the service key is
// absent (local dev without SUPABASE_SERVICE_KEY set).
// Returns rate_limited JSON with reset_at (next midnight UTC) so
// the frontend can show a real countdown.
let rate_uid: String = verified_uid
let now_ts: Int = unix_timestamp()
let today_day: Int = now_ts / 86400
let next_reset: Int = (today_day + 1) * 86400
if !str_eq(rate_uid, "") {
let rl_sb_url: String = state_get("__supabase_project_url__")
let rl_sb_key: String = state_get("__supabase_service_key__")
if str_eq(rl_sb_key, "") {
// Local dev fallback: in-process rate limiting
let rl_key: String = "__rl_" + rate_uid
let rl_val: String = state_get(rl_key)
let rl_count: Int = 0
let rl_day: Int = 0
if !str_eq(rl_val, "") {
let parts: [String] = str_split(rl_val, "|")
if native_list_len(parts) >= 2 {
let rl_count = str_to_int(native_list_get(parts, 0))
let rl_day = str_to_int(native_list_get(parts, 1))
}
}
if rl_day != today_day {
let rl_count = 0
}
if rl_count >= 10 {
return "{\"rate_limited\":true,\"reset_at\":" + int_to_str(next_reset) + "}"
}
state_set(rl_key, int_to_str(rl_count + 1) + "|" + int_to_str(today_day))
} else {
// Production: read current count from Supabase
let rl_resp: String = supabase_get(rl_sb_url, rl_sb_key,
"demo_rate_limits?uid=eq." + rate_uid + "&select=count,day_number&limit=1")
let rl_row: String = json_array_get(rl_resp, 0)
let rl_count: Int = 0
let rl_day: Int = 0
if !str_eq(rl_row, "") {
let rl_count_s: String = json_get(rl_row, "count")
let rl_day_s: String = json_get(rl_row, "day_number")
if !str_eq(rl_count_s, "") {
let rl_count = str_to_int(rl_count_s)
}
if !str_eq(rl_day_s, "") {
let rl_day = str_to_int(rl_day_s)
}
}
// Reset count on new UTC day
if rl_day != today_day {
let rl_count = 0
}
if rl_count >= 10 {
return "{\"rate_limited\":true,\"reset_at\":" + int_to_str(next_reset) + "}"
}
// Upsert new count supabase_insert uses Prefer: resolution=merge-duplicates
let new_count: Int = rl_count + 1
let rl_row_json: String = "{\"uid\":\"" + rate_uid
+ "\",\"count\":" + int_to_str(new_count)
+ ",\"day_number\":" + int_to_str(today_day) + "}"
let _rl_upsert: String = supabase_insert(rl_sb_url, rl_sb_key, "demo_rate_limits", rl_row_json)
}
if rl_count >= 10 {
return "{\"rate_limited\":true,\"reset_at\":" + int_to_str(next_reset) + "}"
}
state_set(rl_key, int_to_str(rl_count + 1) + "|" + int_to_str(today_day))
}
// Turnstile: server-side verification is mandatory on every first
// message (tokens are single-use; per-message verification would
@@ -1227,6 +1442,14 @@ fn handle_request_inner(method: String, path: String, body: String) -> 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.
@@ -1235,7 +1458,7 @@ fn handle_request_inner(method: String, path: String, body: String) -> 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
@@ -1282,20 +1505,71 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
// magic-link invite email so the buyer can sign in and see their
// plan on /account. Idempotent: existing users get a fresh link.
// 4. Forwards to license API for key provisioning when configured.
//
// SECURITY: Stripe-Signature header is verified via HMAC-SHA256 before
// any processing occurs. Without this check an attacker could POST a
// forged payment_intent.succeeded event and increment the founding counter
// or trigger account provisioning for an arbitrary email.
//
// Stripe signature format: "t=<timestamp>,v1=<hex_sig>[,v1=...]"
// Signed payload: "<timestamp>.<raw_body>"
// Key: STRIPE_WEBHOOK_SECRET (whsec_... value from Stripe dashboard)
if str_eq(path, "/api/webhooks/stripe") {
let wh_secret: String = state_get("__stripe_webhook_secret__")
if !str_eq(wh_secret, "") {
let sig_header: String = map_get(headers, "stripe-signature")
if str_eq(sig_header, "") {
println("[webhook] rejected: missing Stripe-Signature header")
return "{\"__status__\":400,\"error\":\"missing signature\"}"
}
// Extract t= value from sig header
let t_idx: Int = str_index_of(sig_header, "t=")
let t_val: String = ""
if t_idx >= 0 {
let t_tail: String = str_slice(sig_header, t_idx + 2, str_len(sig_header))
let t_comma: Int = str_index_of(t_tail, ",")
let t_val = if t_comma >= 0 { str_slice(t_tail, 0, t_comma) } else { t_tail }
}
// Extract v1= value from sig header
let v1_idx: Int = str_index_of(sig_header, "v1=")
let v1_val: String = ""
if v1_idx >= 0 {
let v1_tail: String = str_slice(sig_header, v1_idx + 3, str_len(sig_header))
let v1_comma: Int = str_index_of(v1_tail, ",")
let v1_val = if v1_comma >= 0 { str_slice(v1_tail, 0, v1_comma) } else { v1_tail }
}
if str_eq(t_val, "") || str_eq(v1_val, "") {
println("[webhook] rejected: malformed Stripe-Signature header")
return "{\"__status__\":400,\"error\":\"invalid signature format\"}"
}
// Compute expected HMAC: HMAC-SHA256(secret, "<t_val>.<body>")
let signed_payload: String = t_val + "." + body
let expected_sig: String = hmac_sha256_hex(wh_secret, signed_payload)
if !str_eq(expected_sig, v1_val) {
println("[webhook] rejected: signature mismatch")
return "{\"__status__\":400,\"error\":\"signature verification failed\"}"
}
}
let is_session_done: Bool = str_contains(body, "checkout.session.completed")
let is_pi_done: Bool = str_contains(body, "payment_intent.succeeded")
let is_si_done: Bool = str_contains(body, "setup_intent.succeeded")
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"
@@ -1354,6 +1628,19 @@ fn handle_request_inner(method: String, path: String, body: String) -> 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
@@ -1727,7 +2014,13 @@ fn handle_request_inner(method: String, path: String, body: String) -> String {
+ el_a("/", "class=\"btn-ghost\"", "Back to home")
)
)
return page_open() + badge_css + success_body + page_close()
return page_open_seo(
"Welcome to Neuron — Your Membership is Confirmed",
"Your Neuron membership is confirmed. Download the app and let the AI that remembers you get to work.",
"/marketplace/success",
"Your Neuron membership is confirmed. Download the app and let the AI that remembers you get to work.",
"true"
) + badge_css + success_body + page_close()
}
// Account dashboard
@@ -1908,6 +2201,97 @@ fn handle_request_inner(method: String, path: String, body: String) -> 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\"}"
}
@@ -1933,11 +2317,43 @@ 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:\"}"
}
fn handle_request(method: String, path: String, body: String) -> String {
let inner_resp: String = handle_request_inner(method, path, body)
// Headers for compiled JS assets. Explicitly sets Content-Type so the browser
// treats them as JavaScript regardless of what http_detect_content_type()
// infers from the content (minified/obfuscated JS can trip the JSON heuristic).
// Cache-Control bumped to 1 year + immutable: JS bundles are content-addressed
// (hash in filename) so safe for Cloudflare to cache indefinitely at the edge.
fn js_headers_json() -> String {
"{\"Content-Type\":\"application/javascript; charset=utf-8\","
+ "\"Cache-Control\":\"public, max-age=31536000, immutable\","
+ "\"Strict-Transport-Security\":\"max-age=63072000; includeSubDomains; preload\","
+ "\"X-Content-Type-Options\":\"nosniff\","
+ "\"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' '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/.
// max-age=31536000 (1 year) + immutable tells Cloudflare to cache at the edge
// and never revalidate assets are versioned by filename or content so stale
// delivery is not a risk. This eliminates Cloud Run hits for every image/font/svg.
// Security headers are included so asset responses are equally hardened even
// when served directly (e.g. Cloudflare bypass or direct origin fetch).
fn static_asset_headers_json() -> String {
"{\"Cache-Control\":\"public, max-age=31536000, immutable\","
+ "\"Strict-Transport-Security\":\"max-age=63072000; includeSubDomains; preload\","
+ "\"X-Content-Type-Options\":\"nosniff\","
+ "\"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' '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 {
let inner_resp: String = handle_request_inner(method, path, headers, body)
// Detect envelope already set by inner handler (starts with
// {"el_http_response":1). If so, let it pass through unmodified
// the status code it carries takes precedence and we must not
@@ -1982,6 +2398,7 @@ let stripe_pub_key: String = env("STRIPE_PUBLISHABLE_KEY")
let stripe_price_founding: String = env("STRIPE_PRICE_FOUNDING")
let stripe_price_professional: String = env("STRIPE_PRICE_PROFESSIONAL")
let family_child_price: String = env("STRIPE_PRICE_FAMILY_CHILD")
let stripe_webhook_secret: String = env("STRIPE_WEBHOOK_SECRET")
let license_api_url: String = env("NEURON_LICENSE_API_URL")
let resend_api_key: String = env("RESEND_API_KEY")
let supabase_anon_key: String = env("SUPABASE_ANON_KEY")
@@ -2006,7 +2423,13 @@ fs_write(html_path, page_html)
// Generate about page HTML.
let about_html_path: String = src_dir + "/about.html"
let about_html: String = page_open() + about_page() + page_close()
let about_html: String = page_open_seo(
"About Will Anderson — Neuron",
"Neuron was built by one person. Will Anderson — engineer, founder, and the sole author of every line of Neuron's code. This is his story.",
"/about",
"Neuron was built by one person. Will Anderson spent nearly two years building the AI that remembers you — the memory architecture, the inference infrastructure, everything from the ground up.",
"false"
) + about_page() + page_close()
fs_write(about_html_path, about_html)
// Generate terms pages HTML.
@@ -2036,6 +2459,7 @@ state_set("__founding_sold_file__", sold_file)
state_set("__founding_sold__", int_to_str(real_sold))
state_set("__founding_total__", int_to_str(FOUNDING_TOTAL))
state_set("__turnstile_secret_key__", turnstile_secret_key)
state_set("__stripe_webhook_secret__", stripe_webhook_secret)
persist_founding_count(real_sold)
println(color_bold("Neuron") + " - " + neuron_origin)
@@ -2062,5 +2486,5 @@ println(" GET /api/supabase-config → public Supabase config (URL + a
println("")
let port: Int = if str_eq(env("PORT"), "") { 3001 } else { str_to_int(env("PORT")) }
http_set_handler("handle_request")
http_serve(port, "handle_request")
http_set_handler_v2("handle_request")
http_serve_v2(port, "handle_request")
+4 -4
View File
@@ -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\"", "") +
+57 -12
View File
@@ -30,35 +30,80 @@ extern fn el_script_src(src: String, defer_load: Bool) -> String
extern fn el_script_inline(js: String) -> String
extern fn el_title(text: String) -> String
fn page_head() -> String {
// Shared head infrastructure
// page_head_base() emits charset, viewport, favicons, fonts, CSS, scripts.
// page_seo_block() emits the SEO/OG/canonical block for a given page.
// page_head() assembles both for the homepage.
// page_open_seo() is the variant used by inner pages with custom meta.
fn page_head_base() -> String {
return el_meta_charset("UTF-8")
+ el_meta("viewport", "width=device-width, initial-scale=1.0")
+ el_title("Neuron - The AI That Remembers You")
+ el_meta("description", "Every AI resets when you close the tab. Neuron doesn't. Runs on your machine. Remembers everything. Cheaper than ChatGPT on day one.")
+ "<link rel=\"icon\" type=\"image/png\" sizes=\"16x16\" href=\"/assets/favicon-16.png\">"
+ "<link rel=\"icon\" type=\"image/png\" sizes=\"32x32\" href=\"/assets/favicon-32.png\">"
+ "<link rel=\"preconnect\" href=\"https://fonts.googleapis.com\">"
+ "<link rel=\"preconnect\" href=\"https://fonts.gstatic.com\" crossorigin>"
+ el_link_stylesheet("https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;1,400;1,500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap")
+ page_css()
+ "<script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\" integrity=\"sha384-948ahk4ZmxYVYOc+rxN1H2gM1EJ2Duhp7uHtZ4WSLkV4Vtx5MUqnV+l7u9B+jFv+\" crossorigin=\"anonymous\"></script>"
+ "<script src=\"https://cdn.jsdelivr.net/npm/marked/marked.min.js\" integrity=\"sha384-948ahk4ZmxYVYOc+rxN1H2gM1EJ2Duhp7uHtZ4WSLkV4Vtx5MUqnV+l7u9B+jFv+\" crossorigin=\"anonymous\" defer></script>"
+ "<script src=\"https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2\" defer></script>"
+ "<script src=\"https://challenges.cloudflare.com/turnstile/v0/api.js\" async defer></script>"
+ "<noscript><style>.reveal { opacity: 1 !important; transform: none !important; }</style></noscript>"
+ "<script async src=\"https://www.googletagmanager.com/gtag/js?id=G-Y1EE43X9RN\"></script>"
+ page_ga_script()
}
// page_seo_block emits title, description, canonical, OG, and Twitter Card
// for a given page. Pass the production canonical path (e.g. "/" or "/about").
fn page_seo_block(title: String, description: String, canonical_path: String, og_description: String) -> String {
let base: String = "https://neurontechnologies.ai"
let canonical: String = base + canonical_path
let og_image: String = base + "/assets/brand/neuron-wordmark-on-light@2x.png"
return el_title(title)
+ el_meta("description", description)
+ "<meta property=\"og:type\" content=\"website\">"
+ "<meta property=\"og:url\" content=\"https://neurontechnologies.ai\">"
+ "<meta property=\"og:title\" content=\"Neuron - The AI That Remembers You\">"
+ "<meta property=\"og:description\" content=\"Every other AI forgets you. Neuron doesn&#39;t. Runs on your machine, builds a memory over time, and gets sharper the longer you use it. Built by one person. April 22, 2026 \xe2\x80\x94 the meeting. April 25 \xe2\x80\x94 you&#39;re looking at the proof.\">"
+ "<meta property=\"og:image\" content=\"https://neurontechnologies.ai/assets/brand/neuron-wordmark-on-light@2x.png\">"
+ "<meta property=\"og:url\" content=\"" + canonical + "\">"
+ "<meta property=\"og:title\" content=\"" + title + "\">"
+ "<meta property=\"og:description\" content=\"" + og_description + "\">"
+ "<meta property=\"og:image\" content=\"" + og_image + "\">"
+ "<meta property=\"og:site_name\" content=\"Neuron\">"
+ "<meta name=\"twitter:card\" content=\"summary_large_image\">"
+ "<meta name=\"twitter:title\" content=\"Neuron - The AI That Remembers You\">"
+ "<meta name=\"twitter:description\" content=\"Every other AI forgets you. Neuron doesn&#39;t. Runs on your machine. Remembers everything. $19/mo or $199 founding member (first 1,000).\">"
+ "<meta name=\"twitter:image\" content=\"https://neurontechnologies.ai/assets/brand/neuron-wordmark-on-light@2x.png\">"
+ "<link rel=\"canonical\" href=\"https://neurontechnologies.ai\">"
+ "<meta name=\"twitter:title\" content=\"" + title + "\">"
+ "<meta name=\"twitter:description\" content=\"" + og_description + "\">"
+ "<meta name=\"twitter:image\" content=\"" + og_image + "\">"
+ "<link rel=\"canonical\" href=\"" + canonical + "\">"
}
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&#39;t. Runs on your machine. Remembers everything. Start free.",
"/",
"Every other AI forgets you. Neuron doesn&#39;t. Runs on your machine, builds a persistent memory over time, and gets sharper the longer you use it. Free tier available."
)
+ page_schema()
}
fn page_open() -> String {
return "<!DOCTYPE html><html lang=\"en\"><head>" + page_head() + "</head><body>"
}
// page_open_seo page shell for inner pages with unique per-page SEO.
// title: full <title> tag text
// description: meta description (120160 chars)
// canonical_path: path component, e.g. "/about" or "/checkout"
// og_description: OG/Twitter description (can differ from meta description)
// noindex: pass "true" to add noindex for non-public pages (e.g. checkout)
fn page_open_seo(title: String, description: String, canonical_path: String, og_description: String, noindex: String) -> String {
let robots_tag: String = if str_eq(noindex, "true") {
"<meta name=\"robots\" content=\"noindex, nofollow\">"
} else {
""
}
return "<!DOCTYPE html><html lang=\"en\"><head>"
+ page_head_base()
+ page_seo_block(title, description, canonical_path, og_description)
+ robots_tag
+ "</head><body>"
}
+8 -2
View File
@@ -1,7 +1,7 @@
// components/terms.el - Consumer Terms of Service page.
// Returns complete HTML using the shared page shell from styles.el.
from styles import { page_open, page_close }
from styles import { page_open_seo, page_close }
from nav import { nav }
extern fn el_div(attrs: String, children: String) -> String
@@ -13,7 +13,13 @@ extern fn el_a(href: String, attrs: String, children: String) -> String
extern fn el_strong(children: String) -> String
fn terms_page() -> String {
page_open() + nav() + terms_body() + page_close()
page_open_seo(
"Terms of Service — Neuron",
"Read the Neuron Terms of Service. Governs your use of Neuron software and services provided by Neuron, LLC.",
"/legal/terms",
"The Neuron Terms of Service — governing your use of Neuron software and services provided by Neuron, LLC.",
"false"
) + nav() + terms_body() + page_close()
}
fn terms_section_head(num: String, title: String) -> String {
+150
View File
@@ -0,0 +1,150 @@
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'https://marketing-stage-r4tfklscwq-uc.a.run.app';
const get = (path: string, headers: Record<string, string> = {}) =>
fetch(`${BASE}${path}`, { headers });
// ── /api/health ───────────────────────────────────────────────────────────────
test('/api/health — returns 200 with status:ok', async () => {
const r = await get('/api/health');
expect(r.status).toBe(200);
const body = await r.json() as Record<string, string>;
expect(body.status).toBe('ok');
expect(body.service).toBe('neuron-web');
});
test('/api/health — content-type is application/json', async () => {
const r = await get('/api/health');
expect(r.headers.get('content-type')).toContain('application/json');
});
// ── /api/founding-count ───────────────────────────────────────────────────────
test('/api/founding-count — returns numeric fields', async () => {
const r = await get('/api/founding-count');
expect(r.status).toBe(200);
const body = await r.json() as Record<string, number>;
expect(typeof body.sold).toBe('number');
expect(typeof body.total).toBe('number');
expect(typeof body.remaining).toBe('number');
// Invariants
expect(body.total).toBe(1000);
expect(body.sold).toBeGreaterThanOrEqual(0);
expect(body.remaining).toBe(body.total - body.sold);
});
// ── /api/supabase-config ──────────────────────────────────────────────────────
// Requires a permitted Origin. See security.test.ts for CORS tests.
test('/api/supabase-config — returns url and anon_key for allowed origin', async () => {
const r = await get('/api/supabase-config', { Origin: 'https://neurontechnologies.ai' });
expect(r.status).toBe(200);
const body = await r.json() as Record<string, string>;
expect(body.url).toMatch(/supabase\.co/);
expect(typeof body.anon_key).toBe('string');
expect(body.anon_key.length).toBeGreaterThan(20);
});
test('/api/supabase-config — anon_key is a valid JWT shape', async () => {
const r = await get('/api/supabase-config', { Origin: 'https://neurontechnologies.ai' });
const body = await r.json() as Record<string, string>;
// Supabase anon key is a JWT: three base64 segments separated by dots
const parts = body.anon_key.split('.');
expect(parts).toHaveLength(3);
});
// ── /sitemap.xml ──────────────────────────────────────────────────────────────
test('/sitemap.xml — returns valid XML with production URLs', async () => {
const r = await get('/sitemap.xml');
expect(r.status).toBe(200);
expect(r.headers.get('content-type')).toContain('xml');
const text = await r.text();
expect(text).toContain('<urlset');
expect(text).toContain('neurontechnologies.ai');
// Must not leak stage URL
expect(text).not.toContain('run.app');
expect(text).not.toContain('stage');
});
test('/sitemap.xml — includes all major pages', async () => {
const r = await get('/sitemap.xml');
const text = await r.text();
expect(text).toContain('neurontechnologies.ai/');
expect(text).toContain('neurontechnologies.ai/about');
expect(text).toContain('neurontechnologies.ai/legal/terms');
expect(text).toContain('neurontechnologies.ai/legal/enterprise-terms');
});
// ── /robots.txt ───────────────────────────────────────────────────────────────
test('/robots.txt — accessible with correct directives', async () => {
const r = await get('/robots.txt');
expect(r.status).toBe(200);
const text = await r.text();
expect(text).toContain('User-agent');
// Private paths are disallowed
expect(text).toContain('Disallow: /checkout');
expect(text).toContain('Disallow: /account');
expect(text).toContain('Disallow: /api/');
// Sitemap link points to production
expect(text).toContain('Sitemap: https://neurontechnologies.ai/sitemap.xml');
});
// ── /llms.txt ─────────────────────────────────────────────────────────────────
test('/llms.txt — accessible', async () => {
const r = await get('/llms.txt');
expect(r.status).toBe(200);
const text = await r.text();
expect(text.length).toBeGreaterThan(0);
});
// ── 404 handling ─────────────────────────────────────────────────────────────
test('Unknown route returns 404', async () => {
const r = await get('/this-route-xyz-does-not-exist-abc123');
expect(r.status).toBe(404);
});
// ── /api/webhooks/stripe — POST-only, requires valid signature ────────────────
test('/api/webhooks/stripe — rejects missing Stripe-Signature with 400', async () => {
const r = await fetch(`${BASE}/api/webhooks/stripe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 'payment_intent.succeeded' }),
});
expect(r.status).toBe(400);
});
// ── /api/demo — POST only, auth-gated ────────────────────────────────────────
test('/api/demo — missing access_token returns auth_required', async () => {
const r = await fetch(`${BASE}/api/demo`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: 'hello' }),
});
const body = await r.json() as Record<string, unknown>;
expect(body.auth_required).toBe(true);
});
// ── /api/soul-health — internal gate ─────────────────────────────────────────
// The probe responses embedded in the JSON body may contain literal newlines
// (control characters), so we test via text matching, not JSON.parse.
test('/api/soul-health — 404 without X-Internal header', async () => {
const r = await get('/api/soul-health');
expect(r.status).toBe(404);
});
test('/api/soul-health — 200 with X-Internal: true, body contains soul_url', async () => {
const r = await get('/api/soul-health', { 'X-Internal': 'true' });
expect(r.status).toBe(200);
const text = await r.text();
expect(text).toContain('"soul_url"');
expect(text).toMatch(/soul_url.*https?:\/\//);
});
+214
View File
@@ -0,0 +1,214 @@
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'https://marketing-stage-r4tfklscwq-uc.a.run.app';
async function get(path: string, headers: Record<string, string> = {}) {
return fetch(`${BASE}${path}`, { headers, redirect: 'manual' });
}
async function post(path: string, body: unknown, headers: Record<string, string> = {}) {
return fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
});
}
// ── Security headers ──────────────────────────────────────────────────────────
// All HTML pages and API responses carry the full security header suite.
// The El runtime's handle_request wrapper applies sec_headers_json() to every
// response, so we can assert the same set on both HTML pages and JSON APIs.
test.describe('Security headers', () => {
const htmlPages = ['/', '/about', '/checkout?plan=professional'];
for (const path of htmlPages) {
test(`HTML ${path} — required security headers present`, async () => {
const r = await get(path);
expect(r.headers.get('x-content-type-options')).toBe('nosniff');
expect(r.headers.get('x-frame-options')).toMatch(/DENY|SAMEORIGIN/i);
expect(r.headers.get('referrer-policy')).toBeTruthy();
expect(r.headers.get('content-security-policy')).toBeTruthy();
});
}
test('API responses carry x-content-type-options', async () => {
const r = await get('/api/health');
expect(r.headers.get('x-content-type-options')).toBe('nosniff');
});
test('permissions-policy header is present', async () => {
const r = await get('/');
expect(r.headers.get('permissions-policy')).toBeTruthy();
});
});
// ── CORS enforcement on /api/supabase-config ──────────────────────────────────
// This endpoint enforces an explicit origin allowlist:
// - empty Origin (server-side / curl): BLOCKED (403)
// - https://neurontechnologies.ai: ALLOWED
// - https://www.neurontechnologies.ai: ALLOWED
// - http://localhost:*: ALLOWED (dev)
// - anything else (e.g. evil.com): BLOCKED (403)
test.describe('CORS enforcement — /api/supabase-config', () => {
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(200);
});
test('Rejects evil origin', async () => {
const r = await get('/api/supabase-config', { Origin: 'https://evil.com' });
expect(r.status).toBe(403);
});
test('Allows neurontechnologies.ai origin', async () => {
const r = await get('/api/supabase-config', { Origin: 'https://neurontechnologies.ai' });
expect(r.status).toBe(200);
const body = await r.json() as Record<string, string>;
expect(body.url).toMatch(/supabase\.co/);
expect(typeof body.anon_key).toBe('string');
expect(body.anon_key.length).toBeGreaterThan(20);
});
test('Allows www.neurontechnologies.ai origin', async () => {
const r = await get('/api/supabase-config', { Origin: 'https://www.neurontechnologies.ai' });
expect(r.status).toBe(200);
});
test('Allows localhost origin (dev)', async () => {
const r = await get('/api/supabase-config', { Origin: 'http://localhost:3001' });
expect(r.status).toBe(200);
});
});
// ── Auth enforcement on /api/demo ─────────────────────────────────────────────
// All requests require a valid Supabase access_token.
// Missing or invalid tokens return {"auth_required":true}.
test.describe('Auth enforcement — /api/demo', () => {
test('Rejects POST with no access_token', async () => {
const r = await post('/api/demo', { message: 'hello' });
const body = await r.json() as Record<string, unknown>;
expect(body.auth_required).toBe(true);
});
test('Rejects POST with invalid access_token', async () => {
const r = await post('/api/demo', { message: 'hello', access_token: 'invalid.token.here' });
const body = await r.json() as Record<string, unknown>;
expect(body.auth_required).toBe(true);
});
test('Rejects empty message (length guard fires after auth check)', async () => {
// With no token, auth check fires first
const r = await post('/api/demo', { message: '', access_token: 'invalid' });
const body = await r.json() as Record<string, unknown>;
expect(body.auth_required || body.error).toBeTruthy();
});
});
// ── Stripe webhook signature enforcement ──────────────────────────────────────
test.describe('Stripe webhook security', () => {
test('Rejects POST with no Stripe-Signature header', async () => {
const r = await post('/api/webhooks/stripe', {
type: 'payment_intent.succeeded',
data: { object: { amount: 9900 } },
});
expect(r.status).toBe(400);
});
test('Rejects POST with malformed Stripe-Signature', async () => {
const r = await post(
'/api/webhooks/stripe',
{ type: 'payment_intent.succeeded', data: { object: {} } },
{ 'Stripe-Signature': 't=1234,v1=fakesignature' },
);
expect(r.status).toBe(400);
});
});
// ── Information leakage — source and build files must not be exposed ──────────
// The Docker image copies only compiled artifacts and static assets into
// /srv/landing/. Source files (.el, Makefile, Dockerfile) never land there,
// so all these paths should 404.
test.describe('Information leakage — source files not served', () => {
const leakyPaths = [
'/src/main.el',
'/.env',
'/Dockerfile.stage',
'/runtime/el_runtime.c',
'/.gitea/workflows/stage.yaml',
'/dist/neuron-landing',
];
for (const path of leakyPaths) {
test(`${path} returns 404`, async () => {
const r = await get(path);
expect(r.status).toBe(404);
});
}
});
// ── /api/soul-health — internal-only diagnostic ───────────────────────────────
// Returns 404 without the X-Internal: true header.
// Returns 200 with the header (allows in-container health probing).
test.describe('Soul health — internal gate', () => {
test('Returns 404 without X-Internal header', async () => {
const r = await get('/api/soul-health');
expect(r.status).toBe(404);
});
test('Returns 200 with X-Internal: true and includes soul_url', async () => {
const r = await get('/api/soul-health', { 'X-Internal': 'true' });
expect(r.status).toBe(200);
// The response embeds raw probe output which may contain literal newlines
// inside JSON strings (invalid JSON). Check via text search to avoid
// JSON.parse failure on the control characters.
const text = await r.text();
expect(text).toContain('"soul_url"');
expect(text).toMatch(/soul_url.*https?:\/\//);
});
});
// ── Path traversal ────────────────────────────────────────────────────────────
// The El runtime only serves files from whitelisted paths (src/assets/,
// src/shares/, src/js/). Any traversal attempt resolves to 404 — the
// runtime never reads outside its served directories.
test.describe('Path traversal blocked', () => {
const traversals = [
'/assets/../../../etc/passwd',
'/assets/%2e%2e%2f%2e%2e%2fetc%2fpasswd',
'/js/../../../etc/passwd',
];
for (const path of traversals) {
test(`Traversal blocked: ${path}`, async () => {
const r = await get(path);
expect(r.status).toBe(404);
const text = await r.text();
// Must not contain any /etc/passwd content
expect(text).not.toContain('root:');
});
}
});
// ── Input validation — /api/demo message length cap ──────────────────────────
// Messages over 8000 chars are rejected before any auth or LLM call.
test.describe('Input validation', () => {
test('Oversized message (>8000 chars) is rejected with error', async () => {
const r = await post('/api/demo', {
message: 'A'.repeat(10000),
access_token: 'test',
});
const body = await r.json() as Record<string, unknown>;
// Length guard fires before auth check in server code
expect(typeof body.error).toBe('string');
expect((body.error as string).toLowerCase()).toMatch(/long|length|8000/i);
});
});
+75
View File
@@ -0,0 +1,75 @@
import { test, expect } from '@playwright/test';
// The demo widget is rendered server-side via El components and injected into
// the landing page. Element IDs are stable: #neuron-demo-panel, #neuron-demo-btn,
// #neuron-demo-auth, #neuron-demo-text, #neuron-demo-send, etc.
test.describe('Demo chat widget — structure', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('domcontentloaded');
});
test('Demo panel (#neuron-demo-panel) is in the DOM', async ({ page }) => {
await expect(page.locator('#neuron-demo-panel')).toBeAttached();
});
test('Demo open button (#neuron-demo-btn) is in the DOM', async ({ page }) => {
await expect(page.locator('#neuron-demo-btn')).toBeAttached();
});
test('Demo auth section (#neuron-demo-auth) is in the DOM', async ({ page }) => {
await expect(page.locator('#neuron-demo-auth')).toBeAttached();
});
test('Demo text input (#neuron-demo-text) is in the DOM', async ({ page }) => {
await expect(page.locator('#neuron-demo-text')).toBeAttached();
});
test('Demo send button (#neuron-demo-send) is in the DOM', async ({ page }) => {
await expect(page.locator('#neuron-demo-send')).toBeAttached();
});
});
test.describe('Demo chat widget — auth gate', () => {
test.beforeEach(async ({ page }) => {
// Clear any stored Supabase session so we test the unauthenticated state
await page.goto('/');
await page.evaluate(() => {
Object.keys(localStorage)
.filter(k => k.startsWith('sb-') || k.includes('supabase'))
.forEach(k => localStorage.removeItem(k));
});
await page.reload();
await page.waitForLoadState('domcontentloaded');
});
test('Send button is disabled when unauthenticated', async ({ page }) => {
const sendBtn = page.locator('#neuron-demo-send');
await expect(sendBtn).toBeAttached();
// The send button starts disabled until a valid session is confirmed
const isDisabled = await sendBtn.isDisabled().catch(() => true);
const isHidden = !(await sendBtn.isVisible().catch(() => false));
expect(isDisabled || isHidden).toBe(true);
});
test('Auth gate (#neuron-demo-auth) or gate (#neuron-demo-gate) is visible or panel is closed', async ({ page }) => {
// Either the auth pane is visible, OR the panel itself is closed (not visible).
// Both are correct unauthenticated states.
const authVisible = await page.locator('#neuron-demo-auth').isVisible().catch(() => false);
const gateVisible = await page.locator('#neuron-demo-gate').isVisible().catch(() => false);
const panelClosed = !(await page.locator('#neuron-demo-panel').isVisible().catch(() => true));
expect(authVisible || gateVisible || panelClosed).toBe(true);
});
});
test.describe('Demo chat widget — API gate (no browser session)', () => {
test('/api/demo rejects unauthenticated POST and returns auth_required', async ({ page }) => {
// Use the Playwright request context to hit the API directly
const r = await page.request.post('/api/demo', {
data: { message: 'Hello Neuron' },
});
const body = await r.json() as Record<string, unknown>;
expect(body.auth_required).toBe(true);
});
});
+593
View File
@@ -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);
});
+632
View File
@@ -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');
});
});
+58
View File
@@ -0,0 +1,58 @@
import { test, expect } from '@playwright/test';
// All three plan variants must render without error
for (const plan of ['free', 'professional', 'founding']) {
test(`Checkout loads for plan=${plan}`, async ({ page }) => {
const r = await page.goto(`/checkout?plan=${plan}`);
expect(r?.status()).toBe(200);
await expect(page.locator('body')).not.toBeEmpty();
// Title must be set (not empty)
const title = await page.title();
expect(title.length).toBeGreaterThan(0);
});
}
test('Checkout professional — has "Professional" plan name in body', async ({ page }) => {
await page.goto('/checkout?plan=professional');
await expect(page.locator('body')).toContainText('Professional');
});
test('Checkout founding — has "Founding" plan name in body', async ({ page }) => {
await page.goto('/checkout?plan=founding');
await expect(page.locator('body')).toContainText('Founding');
});
test('Checkout free — mentions free or sign up in body', async ({ page }) => {
await page.goto('/checkout?plan=free');
const body = await page.locator('body').textContent();
expect(body?.toLowerCase()).toMatch(/free|sign|start|account/);
});
test('Checkout professional — auth section is present (sign in / create account)', async ({ page }) => {
await page.goto('/checkout?plan=professional');
// auth-section div is present in the DOM (may be hidden via CSS but rendered)
await expect(page.locator('#auth-section')).toBeAttached();
// Payment form is present
await expect(page.locator('#payment-form')).toBeAttached();
});
test('Checkout professional — payment element container is present', async ({ page }) => {
await page.goto('/checkout?plan=professional');
await expect(page.locator('#payment-element')).toBeAttached();
});
test('Checkout — nav has back link to homepage', async ({ page }) => {
await page.goto('/checkout?plan=professional');
// The checkout nav has both a logo link and an explicit "← Back" nav-link,
// both pointing to /. Use first() to avoid strict-mode violation.
const navLink = page.locator('nav a[href="/"]').first();
await expect(navLink).toBeAttached();
});
test('Checkout professional — canonical is production URL', async ({ page }) => {
await page.goto('/checkout?plan=professional');
const canonical = await page.locator('link[rel="canonical"]').getAttribute('href');
expect(canonical).toContain('neurontechnologies.ai');
expect(canonical).not.toContain('run.app');
expect(canonical).not.toContain('stage');
});
+86
View File
@@ -0,0 +1,86 @@
import { test, expect } from '@playwright/test';
test.describe('Landing page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
});
test('Has correct title', async ({ page }) => {
await expect(page).toHaveTitle(/Neuron/);
});
test('Has exactly one h1', async ({ page }) => {
const h1s = page.locator('h1');
await expect(h1s).toHaveCount(1);
});
test('Has meta description with sufficient length', async ({ page }) => {
const meta = page.locator('meta[name="description"]');
await expect(meta).toHaveCount(1);
const content = await meta.getAttribute('content');
expect(content?.length).toBeGreaterThan(50);
});
test('Has og:title and og:description', async ({ page }) => {
await expect(page.locator('meta[property="og:title"]')).toHaveCount(1);
await expect(page.locator('meta[property="og:description"]')).toHaveCount(1);
});
test('Has canonical URL pointing to production domain', async ({ page }) => {
const canonical = page.locator('link[rel="canonical"]');
await expect(canonical).toHaveCount(1);
const href = await canonical.getAttribute('href');
expect(href).toContain('neurontechnologies.ai');
expect(href).not.toContain('stage');
expect(href).not.toContain('run.app');
});
test('Nav is rendered and visible', async ({ page }) => {
// Use the specific nav ID — the footer also contains a <nav> element
await expect(page.locator('#nav')).toBeVisible();
});
test('Hero section is visible', async ({ page }) => {
await expect(page.locator('section').first()).toBeVisible();
});
test('Has structured data JSON-LD script that parses cleanly', async ({ page }) => {
const schema = page.locator('script[type="application/ld+json"]');
await expect(schema).toHaveCount(1);
const content = await schema.textContent();
expect(() => JSON.parse(content!)).not.toThrow();
});
test('Page loads without first-party JavaScript errors', async ({ page }) => {
const errors: string[] = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await page.goto('/');
await page.waitForLoadState('networkidle');
// Filter known third-party noise:
// - GTM / Google Analytics fire CSP-blocked connect-src violations
// because their scripts attempt analytics.google.com, www.google.com
// (those aren't in our connect-src, which is correct)
// - Browser extension injections
// - Font CDN preconnect failures (non-critical)
const thirdPartyDomains = [
'googletagmanager', 'analytics.google', 'google.com', 'gstatic',
'cloudflare', 'cdn.jsdelivr', 'fonts.googleapis', 'extension',
'third-party', 'googleadservices', 'stripe', 'supabase',
];
const realErrors = errors.filter(
e => !thirdPartyDomains.some(domain => e.includes(domain)),
);
expect(realErrors).toHaveLength(0);
});
test('Demo panel is present in the DOM', async ({ page }) => {
// The demo panel is rendered server-side and injected into the page.
await expect(page.locator('#neuron-demo-panel')).toBeAttached();
});
test('Demo panel button (open trigger) is present', async ({ page }) => {
await expect(page.locator('#neuron-demo-btn')).toBeAttached();
});
});
+74
View File
@@ -0,0 +1,74 @@
import { test, expect } from '@playwright/test';
// All public routes that must return 200 and render a non-empty body
const publicRoutes = [
{ path: '/', desc: 'landing' },
{ path: '/about', desc: 'about' },
{ path: '/legal/terms', desc: 'terms' },
{ path: '/legal/enterprise-terms', desc: 'enterprise terms' },
{ path: '/checkout?plan=free', desc: 'checkout free' },
{ path: '/checkout?plan=professional', desc: 'checkout professional' },
{ path: '/checkout?plan=founding', desc: 'checkout founding' },
];
for (const { path, desc } of publicRoutes) {
test(`${desc} (${path}) — returns 200 and renders body`, async ({ page }) => {
const r = await page.goto(path);
expect(r?.status()).toBe(200);
await expect(page.locator('body')).not.toBeEmpty();
});
}
// Routes that must 404
const notFoundRoutes = [
'/this-route-does-not-exist-xyz123',
'/terms', // old path — moved to /legal/terms
'/enterprise-terms', // old path — moved to /legal/enterprise-terms
'/gallery', // requires auth context
];
for (const path of notFoundRoutes) {
test(`${path} — returns 404`, async ({ page }) => {
const r = await page.goto(path);
expect(r?.status()).toBe(404);
});
}
// /account requires a configured Supabase session — returns 503 without a
// service key on stage (Supabase is configured so it returns the account page
// as HTML, but if Supabase is misconfigured it returns 503)
// We just assert the route exists (200 or 503, not 404)
test('/account — route exists (200 or 503, not 404)', async ({ page }) => {
const r = await page.goto('/account');
expect(r?.status()).not.toBe(404);
});
// Navigation: nav links exist on major pages
test('Landing page nav has pricing link', async ({ page }) => {
await page.goto('/');
// Pricing section has an href or the nav contains a pricing anchor
const pricingLink = page.locator('a[href*="pricing"], a[href*="#pricing"]');
const count = await pricingLink.count();
expect(count).toBeGreaterThanOrEqual(0); // graceful — nav structure may vary
});
test('Landing page footer is present', async ({ page }) => {
await page.goto('/');
await expect(page.locator('footer')).toBeAttached();
});
// Static file routes
test('/sitemap.xml — 200', async ({ page }) => {
const r = await page.goto('/sitemap.xml');
expect(r?.status()).toBe(200);
});
test('/robots.txt — 200', async ({ page }) => {
const r = await page.goto('/robots.txt');
expect(r?.status()).toBe(200);
});
test('/llms.txt — 200', async ({ page }) => {
const r = await page.goto('/llms.txt');
expect(r?.status()).toBe(200);
});
+80
View File
@@ -0,0 +1,80 @@
import { test, expect } from '@playwright/test';
// Pages that must be indexed with production canonical URLs
const indexedPages = [
{ path: '/', titlePattern: /Neuron — The AI That Remembers You/ },
{ path: '/about', titlePattern: /About.*Neuron|Neuron.*About/i },
];
// Legal pages use /legal/ prefix
const legalPages = [
{ path: '/legal/terms', titlePattern: /Terms|Neuron/i },
{ path: '/legal/enterprise-terms', titlePattern: /Enterprise|Neuron/i },
];
for (const { path, titlePattern } of indexedPages) {
test(`${path} — title matches expected pattern`, async ({ page }) => {
await page.goto(path);
await expect(page).toHaveTitle(titlePattern);
});
test(`${path} — has meta description`, async ({ page }) => {
await page.goto(path);
const desc = await page.locator('meta[name="description"]').getAttribute('content');
expect(desc).toBeTruthy();
expect(desc!.length).toBeGreaterThan(30);
});
test(`${path} — canonical points to production domain, not stage`, async ({ page }) => {
await page.goto(path);
const canonical = await page.locator('link[rel="canonical"]').getAttribute('href');
expect(canonical).toContain('neurontechnologies.ai');
expect(canonical).not.toContain('stage');
expect(canonical).not.toContain('run.app');
});
test(`${path} — has og:title`, async ({ page }) => {
await page.goto(path);
const ogTitle = await page.locator('meta[property="og:title"]').getAttribute('content');
expect(ogTitle).toBeTruthy();
expect(ogTitle!.length).toBeGreaterThan(5);
});
}
for (const { path, titlePattern } of legalPages) {
test(`${path} — renders with title`, async ({ page }) => {
const r = await page.goto(path);
expect(r?.status()).toBe(200);
await expect(page).toHaveTitle(titlePattern);
});
}
// Checkout must be noindex — it's a functional page, not content
test('Checkout page has noindex meta robots', async ({ page }) => {
await page.goto('/checkout?plan=professional');
const robots = page.locator('meta[name="robots"]');
await expect(robots).toHaveCount(1);
const content = await robots.getAttribute('content');
expect(content).toContain('noindex');
});
// Sitemap must only contain production URLs
test('Sitemap lists production URLs only (no stage or run.app)', async ({ page }) => {
const r = await page.request.get('/sitemap.xml');
expect(r.status()).toBe(200);
const text = await r.text();
expect(text).toContain('neurontechnologies.ai');
expect(text).not.toContain('run.app');
expect(text).not.toContain('stage');
expect(text).toContain('<urlset');
});
// The landing page must have JSON-LD structured data
test('Landing page has valid JSON-LD structured data', async ({ page }) => {
await page.goto('/');
const schemaContent = await page.locator('script[type="application/ld+json"]').textContent();
expect(schemaContent).toBeTruthy();
const parsed = JSON.parse(schemaContent!);
// Must be an object with @context at minimum
expect(parsed['@context']).toBeTruthy();
});