Compare commits

...

43 Commits

Author SHA1 Message Date
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 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 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
21 changed files with 733 additions and 115 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
+21 -4
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,6 +11,10 @@ on:
- 'src/**'
- 'dist/**'
- 'runtime/**'
- 'tests/**'
- 'migrations/**'
- 'playwright.config.ts'
- 'package.json'
- 'Dockerfile.stage'
- 'Dockerfile.soul-demo'
- 'build-stage.sh'
@@ -48,6 +52,8 @@ jobs:
set -euo pipefail
COMMIT_MSG=$(git log -1 --pretty=format:"%s" 2>/dev/null || true)
echo "Merge commit: $COMMIT_MSG"
# Fetch dev so ancestry check works in the shallow clone.
git fetch --depth=1 origin dev 2>/dev/null || true
# Gitea merge commits: "Merge pull request '...' (#N) from dev into stage"
# Direct branch merges: "Merge branch 'dev' into stage"
# tea pr merge with custom title: any subject line is possible, so
@@ -75,8 +81,13 @@ jobs:
CHANGED=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || git diff --name-only HEAD 2>/dev/null || echo "unknown")
echo "Changed files:"
echo "$CHANGED"
NON_ASSET=$(echo "$CHANGED" | grep -v '^src/assets/' | grep -v '^src/shares/' | grep -v '^src/index\.html' | grep -v '^src/about\.html' | grep -v '^src/terms\.html' | grep -v '^src/enterprise-terms\.html' | grep -v '^src/llms\.txt' | grep -v '^$' || true)
if [ -z "$NON_ASSET" ] && [ "$CHANGED" != "unknown" ]; then
NON_ASSET=$(echo "$CHANGED" | grep -v '^src/assets/' | grep -v '^src/shares/' | grep -v '^src/index\.html' | grep -v '^src/about\.html' | grep -v '^src/terms\.html' | grep -v '^src/enterprise-terms\.html' | grep -v '^src/llms\.txt' | grep -v '^migrations/' | grep -v '^scripts/' | grep -v '^tests/' | grep -v '^\.gitea/' | grep -v '^$' || true)
if [ -z "$CHANGED" ] || [ "$CHANGED" = "unknown" ]; then
# No diff (workflow_dispatch with no new commits, or git error).
# Registry may not have a stage-latest base image, so force full build.
echo "asset_only=false" >> "$GITHUB_OUTPUT"
echo "=> No changed files detected (workflow_dispatch?), forcing full build"
elif [ -z "$NON_ASSET" ]; then
echo "asset_only=true" >> "$GITHUB_OUTPUT"
echo "=> Asset-only change detected, will use fast path"
else
@@ -94,6 +105,12 @@ jobs:
with:
project_id: neuron-785695
- name: Run database migrations
# Applies any pending migrations in migrations/*.sql to the Supabase DB.
# Runs unconditionally (asset-only or full build) so the schema is always
# current before the new code is deployed.
run: python3 scripts/run_migrations.py
- name: Configure docker auth for Artifact Registry
run: gcloud auth configure-docker us-central1-docker.pkg.dev --quiet
@@ -204,7 +221,7 @@ jobs:
[ -f "$f" ] || continue
name=$(basename "$f" .el)
echo "Compiling $f..."
"$ELC" --target=js --bundle --minify --obfuscate "$f" > "dist/js/${name}.js" || {
"$ELC" --target=js --bundle --minify "$f" > "dist/js/${name}.js" || {
echo "elc FAILED on $f"
exit 1
}
+1
View File
@@ -9,6 +9,7 @@
#
# CI pre-build steps (in stage.yaml):
# - neuron-web: built by `elb build` → dist/neuron-landing
# Last rebuilt: 2026-05-11
FROM ubuntu:24.04
+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;
}
+4
View File
@@ -1903,7 +1903,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>"
+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. 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), name_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- If you know their name, use it once, naturally. Don't make a big deal of it.\n- Ask something back if it feels right. Or don't. Trust your read.\n\nSpeak."));
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"));
@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS public.demo_config (
ALTER TABLE public.demo_config ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "service only" ON public.demo_config;
CREATE POLICY "service only" ON public.demo_config USING (false);
-- Seed the kill switch as enabled
@@ -0,0 +1,19 @@
-- 20260511000000_user_api_keys.sql
--
-- Stores user-provisioned AI provider API keys.
-- Service role only — the web backend verifies the user JWT before
-- reading or writing. No public or anon access.
CREATE TABLE IF NOT EXISTS public.user_api_keys (
id uuid DEFAULT gen_random_uuid() PRIMARY KEY,
user_id uuid NOT NULL,
provider text NOT NULL, -- 'openai' | 'anthropic' | 'gemini' | 'grok'
key_value text NOT NULL DEFAULT '',
created_at timestamptz DEFAULT now(),
updated_at timestamptz DEFAULT now(),
UNIQUE(user_id, provider)
);
ALTER TABLE public.user_api_keys ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "service only" ON public.user_api_keys;
CREATE POLICY "service only" ON public.user_api_keys USING (false);
+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).")
+124 -1
View File
@@ -493,7 +493,29 @@ fn account_css() -> String {
.roadmap-items { list-style: none; display: flex; flex-direction: column; gap: .5rem; }
.roadmap-items li { font-family: var(--body); font-size: .875rem; font-weight: 300; color: var(--t2); line-height: 1.6; padding-left: 1rem; position: relative; }
.roadmap-items li::before { content: \"-\"; position: absolute; left: 0; color: var(--navy-65); }
.signout-section { padding-top: 1rem; display: flex; justify-content: flex-end; }"
.signout-section { padding-top: 1rem; display: flex; justify-content: flex-end; }
.api-key-list { display: flex; flex-direction: column; gap: 1.5rem; }
.api-key-entry { border-bottom: 1px solid var(--border); padding-bottom: 1.25rem; }
.api-key-entry:last-child { border-bottom: none; padding-bottom: 0; }
.api-key-header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: .625rem; }
.api-key-name { font-size: .875rem; font-weight: 500; color: var(--t1); }
.api-key-masked { font-size: .75rem; font-family: monospace; color: var(--t3); }
.api-key-row { display: flex; align-items: center; gap: .75rem; }
.api-key-actions { display: flex; gap: .5rem; flex-shrink: 0; }
.api-key-help { margin-top: .625rem; }
.api-key-help summary { font-size: .75rem; font-weight: 500; letter-spacing: .03em; color: var(--navy-65); cursor: pointer; list-style: none; padding: .25rem 0; user-select: none; }
.api-key-help summary::-webkit-details-marker { display: none; }
.api-key-help summary::before { content: \"\\25B8 \"; font-size: .6rem; }
.api-key-help[open] summary::before { content: \"\\25BE \"; }
.api-key-help-body { padding: .75rem 0 .125rem; }
.api-key-help-body ol { padding-left: 1.25rem; display: flex; flex-direction: column; gap: .375rem; }
.api-key-help-body li { font-size: .8125rem; font-weight: 300; color: var(--t2); line-height: 1.55; }
.api-key-help-body a { color: var(--navy-65); text-decoration: underline; text-underline-offset: 2px; }
.api-key-help-body a:hover { color: var(--navy); }
.api-key-note { font-size: .75rem; font-weight: 300; color: var(--t3); margin-top: .625rem; line-height: 1.55; padding: .5rem .75rem; background: var(--bg2); border-left: 2px solid var(--navy-b); }
.api-key-note a { color: var(--navy-65); text-decoration: underline; text-underline-offset: 2px; }
.api-key-model-note { font-size: .8125rem; font-weight: 300; color: var(--t2); line-height: 1.65; padding: .75rem 1rem; background: var(--navy-d); border-left: 2px solid var(--navy); margin-bottom: 1.5rem; }
.api-key-model-note strong { font-weight: 600; color: var(--t1); }"
"<style>" + css + "</style>"
}
@@ -804,6 +826,106 @@ fn account_devices_card() -> String {
)
}
fn api_key_provider_row(provider_id: String, provider_name: String, placeholder: String, instructions: String) -> String {
el_div(
"class=\"api-key-entry\"",
el_div(
"class=\"api-key-header\"",
el_span("class=\"api-key-name\"", provider_name) +
el_span("class=\"api-key-masked\" id=\"apikey-masked-" + provider_id + "\"", "Not configured")
) +
el_div(
"class=\"api-key-row\"",
"<input type=\"password\" id=\"apikey-input-" + provider_id + "\" class=\"acct-input\" placeholder=\"" + placeholder + "\" autocomplete=\"off\" style=\"margin-bottom:0;flex:1\">" +
el_div(
"class=\"api-key-actions\"",
el_button("type=\"button\" class=\"btn-primary\" style=\"padding:.5rem 1rem;font-size:.75rem\" onclick=\"saveApiKey('" + provider_id + "')\"", "Save") +
el_button("type=\"button\" class=\"btn-ghost\" style=\"padding:.5rem 1rem;font-size:.75rem;display:none\" id=\"apikey-del-" + provider_id + "\" onclick=\"deleteApiKey('" + provider_id + "')\"", "Remove")
)
) +
instructions
)
}
fn account_api_keys_section() -> String {
let openai_help: String =
"<details class=\"api-key-help\">" +
"<summary>How to get an OpenAI key</summary>" +
"<div class=\"api-key-help-body\"><ol>" +
"<li>Go to <a href=\"https://platform.openai.com/api-keys\" target=\"_blank\" rel=\"noopener\">platform.openai.com/api-keys &#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() +
+5 -15
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,9 +339,7 @@ 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
}
+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)
)
+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.
+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() {
+70 -11
View File
@@ -14,6 +14,10 @@ fn main() -> Void {
var turnstileVerified = false;
var isOpen = false;
var MAX = 10;
var _userName = '';
var _userTimezone = (typeof Intl !== 'undefined' && Intl.DateTimeFormat)
? Intl.DateTimeFormat().resolvedOptions().timeZone
: '';
// Supabase auth state
var supabaseClient = null;
@@ -45,6 +49,11 @@ fn main() -> Void {
}
function _onWidgetAuthenticated() {
// Capture user name for personalized greeting
if (_supabaseSession && _supabaseSession.user) {
var _meta = _supabaseSession.user.user_metadata || {};
_userName = _meta.full_name || _meta.name || _supabaseSession.user.email || '';
}
var authPane = document.getElementById('neuron-demo-auth');
var gate = document.getElementById('neuron-demo-gate');
var msgs = document.getElementById('neuron-demo-messages');
@@ -60,10 +69,8 @@ fn main() -> Void {
if (session && session.messages && session.messages.length > 0) {
session.messages.forEach(function(m) { addMsg(m.role, m.text, true); });
} else if (!session.greeted) {
addMsg('ai', 'Hey. What is on your mind?', true);
session.greeted = true;
saveSession(session);
}
_sendIntroGreeting();
}
}
var inp = document.getElementById('neuron-demo-text');
if (inp) inp.focus();
@@ -247,10 +254,61 @@ fn main() -> Void {
}
}
function _timeOfDay() {
var h = new Date().getHours();
if (h < 12) return 'morning';
if (h < 17) return 'afternoon';
if (h < 21) return 'evening';
return 'night';
}
function _sendIntroGreeting() {
if (session.greeted) return;
session.greeted = true;
saveSession(session);
var accessToken = (_supabaseSession && _supabaseSession.access_token) ? _supabaseSession.access_token : '';
fetch('/api/demo', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
message: '__intro_phase1__',
history: [],
cf_token: '',
uid: session.uid || '',
access_token: accessToken,
user_name: _userName,
user_timezone: _userTimezone,
time_of_day: _timeOfDay(),
is_return: session.count > 0 ? 'true' : 'false',
activated_nodes: [],
engram_node_count: 0,
questions_remaining: MAX,
is_last_question: false
})
})
.then(function(r) { return r.json(); })
.then(function(d) {
var reply = d.response || d.reply || d.message || '';
if (reply) {
addMsg('ai', reply, true);
session.messages = session.messages || [];
session.messages.push({ role: 'ai', text: reply });
saveSession(session);
} else {
addMsg('ai', \"Hey. What's on your mind?\", true);
}
})
.catch(function() {
addMsg('ai', \"Hey. What's on your mind?\", true);
});
}
window.neuronDemoReset = function() {
if (_headerResetInterval) { clearInterval(_headerResetInterval); _headerResetInterval = null; }
clearSession();
session = { messages: [], count: 0, context: '' };
session.uid = 'u' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
saveSession(session);
msgCount = 0;
var msgs = document.getElementById('neuron-demo-messages');
if (msgs) msgs.innerHTML = '';
@@ -258,7 +316,7 @@ fn main() -> Void {
if (input) { input.disabled = false; input.placeholder = 'Ask me anything...'; }
var btn = document.getElementById('neuron-demo-send');
if (btn) btn.disabled = false;
addMsg('ai', 'Hey. What is on your mind?', true);
_sendIntroGreeting();
};
window.neuronDemoToggle = function() {
@@ -277,9 +335,7 @@ fn main() -> Void {
if (input) { input.disabled = true; input.placeholder = 'Interaction limit reached'; }
}
} else if (!session.greeted) {
addMsg('ai', 'Hey. What is on your mind?', true);
session.greeted = true;
saveSession(session);
_sendIntroGreeting();
}
}
var input = document.getElementById('neuron-demo-text');
@@ -315,8 +371,8 @@ fn main() -> Void {
if (gate) gate.style.display = 'none';
if (msgs) msgs.style.display = 'flex';
if (inputRow) inputRow.style.display = 'flex';
addMsg('ai', 'Hey. What is on your mind?', true);
updateCountdown();
_sendIntroGreeting();
var inp = document.getElementById('neuron-demo-text');
if (inp) inp.focus();
},
@@ -454,6 +510,9 @@ fn main() -> Void {
cf_token: turnstileVerified && !session._cfSent ? turnstileToken : '',
uid: session.uid || '',
access_token: accessToken,
user_name: _userName,
user_timezone: _userTimezone,
time_of_day: _timeOfDay(),
activated_nodes: activated_nodes,
engram_node_count: (session._m && session._m.nodes) ? session._m.nodes.length : 0,
questions_remaining: questionsRemaining,
@@ -473,7 +532,7 @@ fn main() -> Void {
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 + '.';
return \"You've had 10 conversations today. Come back in \" + ts + \".\";
};
addMsg('ai', _showRateTimer());
// Update the last ai message with a live ticker
@@ -485,7 +544,7 @@ fn main() -> Void {
if (lastAi) { lastAi.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 (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; }
}
+5 -14
View File
@@ -36,15 +36,8 @@ fn main() -> Void {
if (user && user.id) { window._neuronSupaId = user.id; }
var auth = document.getElementById('auth-section');
if (auth) auth.style.display = 'none';
var isFree = (window.NEURON_CFG || {}).plan === 'free';
if (isFree) {
// Free plan: show the success panel (user is signed in or just signed up)
var freeSuccess = document.getElementById('free-success');
if (freeSuccess) freeSuccess.style.display = '';
} else {
var payment = document.getElementById('payment-section');
if (payment) payment.style.display = '';
}
var payment = document.getElementById('payment-section');
if (payment) payment.style.display = '';
if (user) {
var badge = document.getElementById('auth-badge');
@@ -65,11 +58,9 @@ fn main() -> Void {
if (emailEl) emailEl.value = user.email;
}
if (!isFree) {
var userEmail = user ? (user.email || '') : '';
var userName = user ? ((user.user_metadata && user.user_metadata.full_name) || '') : '';
if (typeof window.initStripe === 'function') window.initStripe(userEmail, userName);
}
var userEmail = user ? (user.email || '') : '';
var userName = user ? ((user.user_metadata && user.user_metadata.full_name) || '') : '';
if (typeof window.initStripe === 'function') window.initStripe(userEmail, userName);
}
function checkExistingSession() {
+128 -9
View File
@@ -666,10 +666,6 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
}
let timing: String = json_get_string(body, "timing")
if str_eq(timing, "") { let timing = "now" }
// Free tier: no card required. Return immediately no Stripe interaction.
if str_eq(plan, "free") {
return "{\"plan\":\"free\",\"free\":true,\"no_payment_required\":true}"
}
// Hard cap: block founding checkouts when 1,000 spots are filled
if str_eq(plan, "founding") {
let current_sold: Int = get_sold()
@@ -701,6 +697,25 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
}
}
// Free tier: creates a SetupIntent for age verification (18+ requirement).
// No charge but the user must provide a valid payment method.
if str_eq(plan, "free") {
let free_si_body: String = "automatic_payment_methods[enabled]=true"
+ "&usage=off_session"
+ "&metadata[plan]=free"
+ "&metadata[purpose]=age_verification"
let free_si_body = if !str_eq(pi_cus_id, "") { free_si_body + "&customer=" + pi_cus_id } else { free_si_body }
let free_si_resp: String = http_post_form_auth(
"https://api.stripe.com/v1/setup_intents",
free_si_body,
auth_header)
if str_starts_with(free_si_resp, "{") {
let inner: String = str_slice(free_si_resp, 1, str_len(free_si_resp))
return "{\"setup_mode\":true,\"plan\":\"free\"," + inner
}
return free_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") {
@@ -1152,11 +1167,16 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
// would not be able to silently obtain the key to make authenticated calls.
if str_eq(path, "/api/supabase-config") {
let req_origin: String = map_get(headers, "origin")
let origin_ok: Bool = str_eq(req_origin, "")
// map_get returns 0 (null) when the header is absent same-origin
// browser fetches don't send Origin at all. str_starts_with(null, "http")
// returns false, so !origin_present correctly passes no-origin requests.
let origin_present: Bool = str_starts_with(req_origin, "http")
let origin_ok: Bool = !origin_present
|| str_eq(req_origin, "https://neurontechnologies.ai")
|| str_eq(req_origin, "https://www.neurontechnologies.ai")
|| str_starts_with(req_origin, "http://localhost:")
|| str_starts_with(req_origin, "http://127.0.0.1:")
|| str_starts_with(req_origin, "https://marketing-stage-")
if !origin_ok {
return "{\"__status__\":403,\"error\":\"forbidden\"}"
}
@@ -1390,6 +1410,14 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
let qrem_safe: String = if str_eq(qrem_str, "") { "10" } else { qrem_str }
let is_last_str: String = json_get(body, "is_last_question")
let is_last_safe: String = if str_eq(is_last_str, "true") { "true" } else { "false" }
let user_name_raw: String = json_get_string(body, "user_name")
let user_tz_raw: String = json_get_string(body, "user_timezone")
let tod_raw: String = json_get_string(body, "time_of_day")
let is_return_raw: String = json_get_string(body, "is_return")
let user_name_safe: String = str_replace(str_replace(user_name_raw, "\\", "\\\\"), "\"", "\\\"")
let user_tz_safe: String = str_replace(str_replace(user_tz_raw, "\\", "\\\\"), "\"", "\\\"")
let tod_safe: String = str_replace(str_replace(tod_raw, "\\", "\\\\"), "\"", "\\\"")
let is_return_safe: String = if str_eq(is_return_raw, "true") { "true" } else { "false" }
// Look up the configured chat model from public.neuron_config
// (Phase 1 runtime config store). 60s TTL caching, falls back
// to the hardcoded default on Supabase miss / error.
@@ -1398,7 +1426,7 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
// Build inner content with history and engram context for thread context.
// soul-demo unwraps payload from the dharma envelope, then reads
// model with json_get(body, "model") - so this propagates end to end.
let inner: String = "{\"event_type\":\"chat\",\"payload\":{\"message\":\"" + msg_safe + "\",\"history\":" + hist_safe + ",\"an\":" + an_safe + ",\"ec\":" + ec_safe + ",\"questions_remaining\":" + qrem_safe + ",\"is_last_question\":" + is_last_safe + ",\"model\":\"" + model_safe + "\"}}"
let inner: String = "{\"event_type\":\"chat\",\"payload\":{\"message\":\"" + msg_safe + "\",\"history\":" + hist_safe + ",\"an\":" + an_safe + ",\"ec\":" + ec_safe + ",\"questions_remaining\":" + qrem_safe + ",\"is_last_turn\":" + is_last_safe + ",\"model\":\"" + model_safe + "\",\"user_name\":\"" + user_name_safe + "\",\"user_timezone\":\"" + user_tz_safe + "\",\"time_of_day\":\"" + tod_safe + "\",\"is_return\":\"" + is_return_safe + "\"}}"
// Escape inner for the outer content field
let inner_safe: String = str_replace(str_replace(inner, "\\", "\\\\"), "\"", "\\\"")
// Build dharma envelope with per-user channel
@@ -2121,6 +2149,97 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
return "{\"ok\":true}"
}
// API key provisioning POST /api/api-keys
// Returns user's stored provider keys (masked) for display on /account.
// Body: { access_token: "<jwt>" }
if str_eq(path, "/api/api-keys") && str_eq(method, "POST") {
let ak_jwt: String = json_get_string(body, "access_token")
if str_eq(ak_jwt, "") {
return "{\"__status__\":401,\"error\":\"missing_jwt\"}"
}
let ak_url: String = state_get("__supabase_project_url__")
let ak_anon: String = state_get("__supabase_anon_key__")
let ak_service: String = state_get("__supabase_service_key__")
if str_eq(ak_url, "") {
return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}"
}
let ak_user: String = supabase_auth_user(ak_url, ak_anon, ak_jwt)
let ak_uid: String = json_get(ak_user, "id")
if str_eq(ak_uid, "") {
return "{\"__status__\":401,\"error\":\"invalid_jwt\"}"
}
let ak_q: String = "user_api_keys?select=provider,key_value&user_id=eq." + ak_uid
let ak_rows: String = supabase_get(ak_url, ak_service, ak_q)
return "{\"rows\":" + ak_rows + "}"
}
// API key provisioning POST /api/api-keys/save
// Upserts a single provider key for the authenticated user.
// Body: { access_token: "<jwt>", provider: "openai"|"anthropic"|"gemini"|"grok", key: "<value>" }
if str_eq(path, "/api/api-keys/save") {
let aks_jwt: String = json_get_string(body, "access_token")
let aks_provider: String = json_get_string(body, "provider")
let aks_key: String = json_get_string(body, "key")
if str_eq(aks_jwt, "") {
return "{\"__status__\":401,\"error\":\"missing_jwt\"}"
}
if str_eq(aks_provider, "") || str_eq(aks_key, "") {
return "{\"__status__\":400,\"error\":\"missing_provider_or_key\"}"
}
let aks_valid_provider: Bool = str_eq(aks_provider, "openai")
|| str_eq(aks_provider, "anthropic")
|| str_eq(aks_provider, "gemini")
|| str_eq(aks_provider, "grok")
if !aks_valid_provider {
return "{\"__status__\":400,\"error\":\"invalid_provider\"}"
}
let aks_url: String = state_get("__supabase_project_url__")
let aks_anon: String = state_get("__supabase_anon_key__")
let aks_service: String = state_get("__supabase_service_key__")
if str_eq(aks_url, "") {
return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}"
}
let aks_user: String = supabase_auth_user(aks_url, aks_anon, aks_jwt)
let aks_uid: String = json_get(aks_user, "id")
if str_eq(aks_uid, "") {
return "{\"__status__\":401,\"error\":\"invalid_jwt\"}"
}
let aks_row: String = "{\"user_id\":\"" + aks_uid + "\",\"provider\":\"" + aks_provider + "\",\"key_value\":\"" + aks_key + "\",\"updated_at\":\"now()\"}"
let _aks_resp: String = supabase_insert(aks_url, aks_service, "user_api_keys?on_conflict=user_id,provider", aks_row)
let aks_klen: Int = str_len(aks_key)
let aks_masked: String = if aks_klen <= 10 {
str_repeat("", aks_klen)
} else {
str_slice(aks_key, 0, 6) + "••••" + str_slice(aks_key, aks_klen - 4, aks_klen)
}
return "{\"ok\":true,\"masked\":\"" + aks_masked + "\"}"
}
// API key provisioning POST /api/api-keys/delete
// Soft-deletes a provider key by clearing key_value for the authenticated user.
// Body: { access_token: "<jwt>", provider: "openai"|"anthropic"|"gemini"|"grok" }
if str_eq(path, "/api/api-keys/delete") {
let akd_jwt: String = json_get_string(body, "access_token")
let akd_provider: String = json_get_string(body, "provider")
if str_eq(akd_jwt, "") {
return "{\"__status__\":401,\"error\":\"missing_jwt\"}"
}
let akd_url: String = state_get("__supabase_project_url__")
let akd_anon: String = state_get("__supabase_anon_key__")
let akd_service: String = state_get("__supabase_service_key__")
if str_eq(akd_url, "") {
return "{\"__status__\":503,\"error\":\"supabase_not_configured\"}"
}
let akd_user: String = supabase_auth_user(akd_url, akd_anon, akd_jwt)
let akd_uid: String = json_get(akd_user, "id")
if str_eq(akd_uid, "") {
return "{\"__status__\":401,\"error\":\"invalid_jwt\"}"
}
let akd_row: String = "{\"user_id\":\"" + akd_uid + "\",\"provider\":\"" + akd_provider + "\",\"key_value\":\"\",\"updated_at\":\"now()\"}"
let _akd_resp: String = supabase_insert(akd_url, akd_service, "user_api_keys?on_conflict=user_id,provider", akd_row)
return "{\"ok\":true}"
}
// Fallback
return "{\"__status__\":404,\"error\":\"not found\"}"
}
@@ -2146,7 +2265,7 @@ fn sec_headers_json() -> String {
+ "\"X-Frame-Options\":\"SAMEORIGIN\","
+ "\"Referrer-Policy\":\"strict-origin-when-cross-origin\","
+ "\"Permissions-Policy\":\"geolocation=(), microphone=(), camera=()\","
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
}
// Headers for compiled JS assets. Explicitly sets Content-Type so the browser
@@ -2162,7 +2281,7 @@ fn js_headers_json() -> String {
+ "\"X-Frame-Options\":\"SAMEORIGIN\","
+ "\"Referrer-Policy\":\"strict-origin-when-cross-origin\","
+ "\"Permissions-Policy\":\"geolocation=(), microphone=(), camera=()\","
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
}
// Headers for static assets under /assets/ and /brand/.
@@ -2178,7 +2297,7 @@ fn static_asset_headers_json() -> String {
+ "\"X-Frame-Options\":\"SAMEORIGIN\","
+ "\"Referrer-Policy\":\"strict-origin-when-cross-origin\","
+ "\"Permissions-Policy\":\"geolocation=(), microphone=(), camera=()\","
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
+ "\"Content-Security-Policy\":\"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://challenges.cloudflare.com https://cdn.jsdelivr.net https://www.googletagmanager.com https://www.google-analytics.com; style-src 'self' 'unsafe-inline'; frame-src https://challenges.cloudflare.com; connect-src 'self' https://api.stripe.com https://*.supabase.co; img-src 'self' data: https:; font-src 'self' data:\"}"
}
fn handle_request(method: String, path: String, headers: Map, body: String) -> String {
+6 -4
View File
@@ -51,11 +51,13 @@ test.describe('Security headers', () => {
// - anything else (e.g. evil.com): BLOCKED (403)
test.describe('CORS enforcement — /api/supabase-config', () => {
test('Rejects requests with no Origin header', async () => {
// No Origin = not from a browser context — the server treats this as
// an unknown caller and returns 403 to prevent server-side exfiltration.
test('Allows requests with no Origin header (same-origin browser fetches)', async () => {
// Same-origin browser fetches (e.g. checkout page fetching supabase-config on
// the same domain) do not send an Origin header. The server must pass these
// through — blocking them would break the checkout flow on production.
// Server-side exfiltration is prevented by the evil-origin 403 below.
const r = await get('/api/supabase-config');
expect(r.status).toBe(403);
expect(r.status).toBe(200);
});
test('Rejects evil origin', async () => {
+2 -2
View File
@@ -7,7 +7,7 @@ import { test, expect } from '@playwright/test';
test.describe('Demo chat widget — structure', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.waitForLoadState('networkidle');
await page.waitForLoadState('domcontentloaded');
});
test('Demo panel (#neuron-demo-panel) is in the DOM', async ({ page }) => {
@@ -41,7 +41,7 @@ test.describe('Demo chat widget — auth gate', () => {
.forEach(k => localStorage.removeItem(k));
});
await page.reload();
await page.waitForLoadState('networkidle');
await page.waitForLoadState('domcontentloaded');
});
test('Send button is disabled when unauthenticated', async ({ page }) => {
+36 -23
View File
@@ -243,12 +243,7 @@ test('[free] auth-section visible on load (account creation flow)', async ({ pag
await expect(page.locator('#auth-section')).toBeVisible();
});
test('[free] free-success panel hidden on load', async ({ page }) => {
await page.goto('/checkout?plan=free');
await expect(page.locator('#free-success')).toBeHidden();
});
test('[free] no payment-section or it is hidden', async ({ page }) => {
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) {
@@ -256,6 +251,11 @@ test('[free] no payment-section or it is hidden', async ({ page }) => {
}
});
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();
@@ -301,6 +301,7 @@ for (const plan of ['professional', 'founding'] as const) {
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 });
@@ -311,6 +312,7 @@ test('[free] submit with empty email shows auth error', async ({ page }) => {
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();
@@ -323,6 +325,7 @@ test('[free] submit with password < 8 chars shows length error', async ({ page }
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();
@@ -339,6 +342,7 @@ test('[free] initial button says "Create account"', async ({ page }) => {
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');
});
@@ -350,16 +354,17 @@ test('[free] divider label changes for email mode', async ({ page }) => {
// ─── Mocked free-plan auth flows ──────────────────────────────────────────────
test('[free] successful sign-up → free-success shown, auth-section hidden', async ({ page }) => {
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('#free-success')).toBeVisible({ timeout: 6000 });
await expect(page.locator('#payment-section')).toBeVisible({ timeout: 6000 });
await expect(page.locator('#auth-section')).toBeHidden();
});
@@ -367,6 +372,7 @@ test('[free] sign-up email-confirm-required → shows check-email message', asyn
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');
@@ -378,23 +384,25 @@ test('[free] sign-up email-confirm-required → shows check-email message', asyn
expect(text.toLowerCase()).toMatch(/email|confirm|check/);
});
test('[free] sign-in success (via toggle) → free-success shown', async ({ page }) => {
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('#free-success')).toBeVisible({ timeout: 6000 });
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');
@@ -465,21 +473,26 @@ for (const plan of ['professional', 'founding'] as const) {
});
}
// ─── /api/checkout endpoint ───────────────────────────────────────────────────
// ─── /api/payment-intent endpoint ────────────────────────────────────────────
test('POST /api/checkout free plan returns no_payment_required', async ({ request }) => {
const res = await request.post('/api/checkout', {
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 never calls Stripe — must be fast and return the flag
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.no_payment_required ?? body.free).toBeTruthy();
// 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/checkout professional returns client_secret or config error (not 500)', async ({ request }) => {
const res = await request.post('/api/checkout', {
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' },
});
@@ -490,16 +503,16 @@ test('POST /api/checkout professional returns client_secret or config error (not
}
});
test('POST /api/checkout founding returns client_secret or config error (not 500)', async ({ request }) => {
const res = await request.post('/api/checkout', {
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/checkout empty body returns 4xx not 500', async ({ request }) => {
const res = await request.post('/api/checkout', { data: {} });
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);
});
+12 -6
View File
@@ -36,6 +36,8 @@ 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
@@ -144,8 +146,8 @@ test('[founding] Stripe.js script tag present in page', async ({ page }) => {
await expect(stripeScript).toBeAttached();
});
test('[free] Stripe.js is still loaded (though not used)', async ({ page }) => {
// Free plan still includes Stripe.js for forward compat
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();
@@ -416,14 +418,18 @@ test('[professional] successful payment: submit-btn shows spinner then loading s
// ─── /api/payment-intent endpoint contracts ───────────────────────────────────
test('POST /api/payment-intent free plan returns no_payment_required', async ({ request }) => {
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' },
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.no_payment_required ?? body.free).toBeTruthy();
// 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 }) => {