Replaces the three-step apt install + GCS cache probe + gcc build sequence
with a single curl download of the pre-built binary. Eliminates build-time
C toolchain dependency and shaves ~2-3 minutes off every full build.
Nothing reaches prod without deploying to marketing-stage first and
passing a 90s HTTP smoke test. Stage uses test Stripe keys
(stripe-secret-key-stage) so checkout can be exercised safely.
Set STRIPE_PUBLISHABLE_KEY on the stage service manually once:
gcloud run services update marketing-stage --region us-central1 \
--project neuron-785695 \
--update-env-vars STRIPE_PUBLISHABLE_KEY=pk_test_...
- Detect asset-only changes (src/assets/, src/shares/, static HTML, llms.txt)
and skip El compilation, C build deps, and Docker full build entirely
- Fast path pulls :latest as base and rebuilds only the assets layer
- Gate clone-el, install-C-deps, elc-cache, build-elc, build-image, push-image
behind asset_only != 'true'; deploy steps run unconditionally
- Switch build-stage.sh from registry cache driver (requires docker-container
buildx driver) to inline cache backed by :latest — compatible with default
docker driver on the runner
- deploy.yaml: restore elc from GCS (gs://neuron-ci-cache) keyed on
source SHA; only compile on cache miss, then upload for future runs
- Dockerfile.stage: pre-compile el_runtime.o as its own layer so the
expensive object is cached when only main.c changes between runs
- build-stage.sh: add --cache-from/--cache-to pointing at Artifact
Registry so apt-get + compilation layers survive across cold builds
LinkedIn, X, Facebook, WhatsApp, TikTok, Snapchat icons now served from
/assets/social/ instead of cdn.simpleicons.org. LinkedIn uses the official
brand PNG from Downloads; remaining icons are scraped SVGs.
Compiled artifact for the soul-history fix that landed in prod
on tag fix-soul-history-1314. Source was built but the .c artifact
didn't get committed alongside the binary deploy. This is the
canonical compiled output of dist/soul-demo.el at HEAD; required
for clean rebuilds via Dockerfile.stage.
elc-bootstrap.c isn't committed to engram-lang; the actual C source
is dist/platform/elc.c. Add libcurl4-openssl-dev/libssl-dev install
step so cc has the right headers.
The committed dist/platform/elc was an arm64 binary from the local dev
box; runner is linux/amd64 and got 'cannot execute binary file: Exec
format error'. Always rebuild.
Gitea Actions doesn't currently inject ACTIONS_ID_TOKEN_REQUEST_TOKEN /
ACTIONS_ID_TOKEN_REQUEST_URL into job env, so google-github-actions/auth
can't mint a federated token. The WIF infrastructure stays in Terraform
so we can flip back once that gap closes; the JSON key in GCP_SA_KEY is
the working path today.
act_runner v0.6 host-mode hits a 'permission denied' error on
.git/objects/pack/*.idx when running two checkout steps in the same
job. Drop down to a plain git clone of engram-lang and pin EL_HOME
outside the workspace.
A real attacker probed /api/share earlier today with <script>alert(1),
<iframe src=evil>, <img onerror>, <a href="javascript:...">, and a
<form action="/steal"> payload. Nothing executed because the chat
bubble at /share/<id> renders the served HTML inside marked.js's
already-escaped output, but the prior denylist sanitizer was fragile:
- It comment-wrapped dangerous tags ("<!--script>...-->") which a
literal "-->" inside an attacker-supplied attribute value can close
early, re-exposing the original payload.
- It renamed on*= attributes to data-x-on*= which left attack
indicators visible in the served HTML.
- It was a denylist; every new attack vector required a code change.
- It didn't validate <a href> URL schemes properly.
The replacement is a runtime-level state-machine allowlist parser
(foundation/el af480f6: el_html_sanitize). The product just specifies
the JSON allowlist of allowed tags + attributes; the runtime drops
everything else, validates href/src URL schemes (http/https/mailto/
fragment/relative only), and drops whole subtrees of script/style/
iframe/object/embed/form regardless of the allowlist.
Phase 4 of bl-dc55ae07: deletes sanitize_share_html (main.el) and
gal_sanitize_html (gallery.el); replaces 3 call sites with
el_html_sanitize(html, allowlist). Defines default_share_allowlist
in main.el and the identical gallery_share_allowlist in gallery.el
(separate bindings to avoid a forward-reference at build-concat
order — gallery is concatenated before main).
Phase 5: migrations/20260502185500_backfill_resanitize_share_cards.sql
nulls answer_html for any share_cards row older than 1 hour. Applied
via the Supabase Management API; 0 rows in scope (the column was
added today and existing rows pre-date its first write).
Also fixes an orthogonal duplicate-symbol bug: unix_timestamp() was
defined in both dist/web_stubs.c and the runtime (the latter is a
recent runtime addition picked up by the runtime sync). Removed the
stub.
Backlog: bl-dc55ae07
The auto-issued GITHUB_TOKEN is scoped to the current repo only, so
cross-repo actions/checkout needs an explicit token. CHECKOUT_TOKEN
holds an admin-scoped Gitea API token; long-term we should switch to
a dedicated read-only PAT.
Push to main triggers build-stage.sh, push to Artifact Registry,
parallel deploy to all 3 marketing prod regions, traffic flip,
verify. Auth via Workload Identity Federation against the
Gitea OIDC provider — no long-lived keys on the runner.
Falls back to GCP_SA_KEY repo secret if WIF doesn't work end
to end against this Gitea instance.
Gallery was reading the plain answer field and HTML-escaping it,
showing literal `<ol>...` text where the actual share page rendered
the markdown. Now selects answer_html alongside, runs it through
the same sanitizer as the share-card render, and falls back to
escaped plaintext for legacy rows.
The wrapper now logs the response and returns a structured ok/error
shape. Four call sites converge on a single send_email helper.
Resend deliveries verified end to end against
will.anderson@neurontechnologies.ai (delivery IDs 492fa066, 74258223,
69a3d9ab, f6d1c889).
Root cause: http_post_auth in dist/web_stubs.c only set the
Authorization: Bearer header. Resend rejects requests without
Content-Type: application/json with HTTP 422 missing_required_field
because it parses the body as form-urlencoded. The 422 response was
being captured by the El handler but not parsed, so callers logged
the error body and returned ok-200 to the client. Two endpoints also
built malformed JSON by interpolating the raw request body unquoted
into the text field.
Fix:
- Added http_post_auth_json (Bearer + Content-Type: application/json)
alongside http_post_auth in dist/web_stubs.c. Stripe form-POST
callers stay on http_post_auth, JSON callers (Resend now, others
later) move to the json variant.
- New send_email(from_addr, to, subject, html, text) wrapper in
src/main.el. JSON-escapes all user-provided fields, parses the
Resend response into a structured ok/error envelope, and println's
the outcome ([email] sent id=<id>) for Cloud Run log surfaces.
- Refactored four call sites onto the wrapper: /api/enterprise-inquiry,
/api/developer-interest, /api/waitlist, /api/attest, the family
invite branch in /api/family/invite, and both DocuSeal completion
branches in /api/docuseal/webhook/<token>.
- Untracked dist/ source files (web_stubs.c, vessel_stubs.c,
soul-demo.c, entrypoint.sh, engram-snapshot.json) are now committed
- generated artifacts (main.c, binaries) stay ignored. Without this
the next CI rebuild would regress the fix.
New Supabase table neuron_config keyed on (key, scope) with jsonb
value column. Web tier reads chat.model via /api/demo with 60s TTL
caching, passes to soul via dharma envelope payload.model. No more
revision-rollout-per-model-swap.
Admin read endpoint at /api/admin/config gated by NEURON_ADMIN_TOKEN.
Write surface and Realtime subscription land in Phase 2.
Backlog: bl-6eb51893
POST /api/docuseal/webhook/<token> validates the path token against
DOCUSEAL_WEBHOOK_TOKEN, persists every event to docuseal_events with
the full payload as jsonb, and emails Will via Resend on form.completed
or form.declined. Token rotates via Secret Manager.
Replaces the broken counter-bump RPC with a per-user share_votes table
(PK share_id+user_id, RLS-enforced ownership). One vote per user per
card, change direction or undo any time. Auth required for write;
read is public. share_cards.upvotes/downvotes/score stay in sync via
recalc trigger. New endpoints: POST /api/vote (auth-gated), GET
/api/vote-state/:id (auth-aware).
Share card now displays the AI bubble's marked-rendered HTML (after
basic tag allowlist sanitization) instead of escaped plaintext.
Markdown bold, lists, code, headers all show.
Share click in chat now opens a preview modal. Publishing to the
gallery only happens when the user explicitly clicks Publish in the
modal - removes the click-and-immediately-public surprise.
The native elc silently drops bare reassignment inside an if body
(`name = expr` compiles to an orphan expr statement with no store).
Both soul_url and neuron_origin defaulted to empty, breaking every
http_post call to the in-container soul-demo. Switch to let-shadowed
if-expressions until the compiler is fixed.
See: products/web/src/main.el `/api/soul-health` route output.
Build pipeline
- build-stage.sh replaces the old in-Dockerfile bootstrap.py path. Host
pre-compiles src/*.el into dist/main.c via the canonical native elc at
foundation/el/dist/platform/elc and applies the stub-decl sed before
docker buildx runs.
- Dockerfile.stage drops bootstrap.py + python3 from the builder stage
and just runs cc on the host-supplied dist/main.c.
- Pre-rendered HTML shells under /srv/landing/ are now chowned to the
landing user so the El page-builder's fs_write at startup can rewrite
them — without that, post-COPY edits never reach the served HTML and
the served page stays as the stale build-time fallback.
Chat restore
- session.verified + session.verifiedAt persist through localStorage so
a return visit within 24h skips the Turnstile gate and lands directly
in the restored conversation.
- restoreOrGreet() is the single source of truth for what shows up in
the message pane after the gate clears: replays prior messages with
skipSave, else drops the canned hello once and remembers it.
- applyVerifiedDom() hides the gate / reveals the chat row, called both
from the verified-on-load path (DOMContentLoaded if loading, else
immediate) and from the Turnstile callback.
- neuronDemoReset clears verified + verifiedAt so the gate returns next
open.
Extracted JS assets (src/assets/js/*.js + manifest.json) and the
extract-js.py helper land here too — they match what the new build-stage
flow produces and removes the inline <script> blobs from the served HTML.
Webhook handler now reacts to payment_intent.succeeded and
setup_intent.succeeded (in addition to the legacy
checkout.session.completed) and auto-provisions a Supabase account
for every successful purchase via /auth/v1/invite. The buyer gets a
magic-link email; landing on /account?welcome=1 with that link signs
them in and the plan card renders.
Stripe Customer is updated with metadata.supabase_user_id so the
cross-reference is durable. New stub: supabase_admin_invite() in
web_stubs.c (POST {project_url}/auth/v1/invite with service-key
bearer auth + apikey header).
Chat widget:
* MAX 5 → 10 questions per session
* countdown is now bold white at all times; the red threshold
at <=5 was loud and made the chat feel rationed
* greeted-once flag in localStorage so reopening the panel
doesn't replay the canned hello (only first open greets)
* questions_remaining + is_last_question travel to the soul on
each turn so it can close in voice on the final turn instead
of leaving the visitor at a hard cap
Soul side of the last-turn handshake is still a TODO - the wrapper
plumbs the fields through but soul-demo.el has to be updated to
read and act on them.
build-local.sh is no longer needed - bootstrap.py resolves imports
natively now. Dockerfile.stage already runs bootstrap.py on
dist/main-combined.el; the next image rev will switch to running
it directly on src/main.el.
Also: COPY src/llms.txt + the four prerendered HTML shells (about /
terms / enterprise-terms / index) into /srv/landing. The El handler
does fs_read(src_dir + "/llms.txt") which returned empty because the
file didn't exist in the container.
Page section order is now: hero → pillars → how-it-works → inference →
efficiency → comparison → enterprise → mission → local-first → safety →
environmental → marketplace → viral → pricing → footer. Pricing is the
last content block so visitors scroll the whole story before being asked
to commit. Nav (desktop + mobile + dropdown) reordered to mirror the
page: How it works → Enterprise → Mission ▾ → Marketplace → Pricing →
About / Gallery / Account.
Stripe attach bug: /api/link-customer was hitting
POST /v1/payment_intents/{id} for every Intent regardless of type, so
SetupIntents (seti_*) returned 404 from Stripe. Branched on the id
prefix - SetupIntents go to /v1/setup_intents/{id} and skip the
receipt_email param (PaymentIntent-only field). Caught from a live
422 in the dashboard the moment Will tested timing=later.
The Professional plan's "Hold until product launches" radio was wired
into the markup but ignored by both /api/payment-intent and the JS
submit handler. Buyers who picked it would still get charged
immediately because the server always created a PaymentIntent and
the client always called stripe.confirmPayment.
Fix:
* /api/payment-intent reads body.timing. When plan=professional and
timing=later, it creates a SetupIntent (usage=off_session) instead
of a PaymentIntent and returns {setup_mode:true, client_secret:...}.
Founding stays unconditional (lifetime, charge now).
* checkout JS now reads the radio (currentTiming()), passes timing
to the server, and re-fetches a new client_secret on radio change
so the buyer's choice is honored even if they toggle after first
mount.
* window._neuronMode tracks 'payment' vs 'setup'. The submit handler
branches: stripe.confirmSetup for save-card, stripe.confirmPayment
for charge-now. The submit button label updates to "Save my card -
no charge today" when in setup mode so the buyer sees the
intent before they hit submit.
* /api/link-customer receives timing + mode so the server can
differentiate at attach time.
A future webhook on setup_intent.succeeded will create the actual
Subscription with trial_end at launch (Q3 2026 / 2026-09-01) - that
piece is queued via metadata[hold_until]=launch on the SetupIntent.
For now, the saved payment method sits in Stripe untouched.
The point: a buyer who picks "Hold until launch" is NOT charged. The
flow has to be airtight - no surprise charges.
Duplicate "Lifetime · Never billed again" was caused by
insertAdjacentHTML('afterend') appending another <p> on every
renderPlanCard call - and renderPlanCard runs both in init() and on
the onAuthStateChange INITIAL_SESSION event, so the line stacks.
Replaced with a dedicated #plan-billing-note-el container that
setHtml() replaces in place.
Badge: collapsed the two-branch fetch into one path. Founding
members now always see the badge; if member_number is set we render
"#N", otherwise we fall through to "Your number awaits" via the
existing n=0 endpoint behaviour.
The /account "Loading..." spinner stayed on forever because the
browser-side waitlist read went through the anon key and didn't reach
the row. Replaced it with a POST /api/my-plan: the server verifies
the user's access_token via Supabase /auth/v1/user, then reads the
waitlist row with the service key. Bypasses RLS without exposing the
service key to the browser.
Stripped implementation comments from the served JS so the browser
doesn't broadcast how internals are shaped.
Build pipeline: declared the supabase_auth_user stub for both
build-local.sh and Dockerfile.stage so the bootstrap-injected forward
declarations match what's actually linked.
Restores inline-JS styles.el (the obfuscated /assets/js/*.js path
broke the chat widget) and threads four UX fixes through it:
- Chat header: drop the all-caps "NEURON / Live Demo" subtitle, keep
just "Neuron" in bold white at 1rem.
- Greeting: replace the canned "Hi. I am Neuron. You get 5 questions"
with "Hey. What is on your mind?" in all three callsites (open,
reset, turnstile-verified). The questions counter is already in
the header, so we don't need to repeat it.
- Avatar: switch chat avatar from neuron-icon.png to the brain mark
(/assets/brand/neuron-brain.png). Thinking state now shows the
pulsing brain plus three-dot animation instead of "thinking..."
text.
- Share button: from a muted t3 link to a navy pill with subtle
background, uppercase letter-spacing, and active-press feedback.
main.el: drop the FOUNDING_SOLD = 47 floor and rewrite
fetch_founding_count_stripe to count succeeded PaymentIntents (the
live Stripe Elements path) AND legacy Checkout Sessions, excluding
refunded charges.
founding_badge.el: rewrite to the Option C "Tag" design picked from
/tmp/founding-badge-preview.html - tall card, navy top stroke, brain
mark prominent, "Neuron, LLC" footer.
terms.el: kill the "Neuron Inference - our own inference layer, live
now" claim. Inference launches Q3 2026 and the wording now says so.
Restores the /api/link-customer endpoint that was lost in the stash. It
runs right before stripe.confirmPayment() and:
- searches Stripe for an existing Customer by email
- creates one if missing (URL-encodes + and @ so Gmail aliases work)
- attaches the Customer to the live PaymentIntent
- upserts the Supabase waitlist row with stripe_customer_id + plan
Stripe locks customer on a Charge once set, so the webhook handler is
the second-line backstop for any race where confirmPayment beats the
link call.
Also: change return_url from /marketplace/success to /account?welcome=1
so buyers land where they need to be, and switch /marketplace/success
and /account from str_eq to str_starts_with so Stripe-appended query
strings (?payment_intent=...&redirect_status=succeeded) don't 404.
The auth-first flow blocked Stripe Elements from initialising for any
visitor without an existing Supabase session. Users hit the checkout
page, saw "Sign in to continue", and could not get to a card field at
all. Restored the inline-JS path (HEAD before extraction broke it),
flipped payment-section visible by default, kept the sign-in panel
behind an explicit "Already have an account? Sign in" link.
Build pipeline: added supabase_get stub injection and -lssl/-lcrypto
linker flags (web_stubs.c uses EVP for the AES-256-GCM transport).
Without those the Docker build aborts at link time.