Add window/neuronCheckoutFree stubs to web_stubs.c — needed to link
without undefined symbol errors on macOS (vessel stubs were already
handled, web platform stubs were not).
Add c_source directives to manifest.el so elb includes web_stubs.c and
vessel_stubs.c in the link — requires the matching elb update in
foundation/el PR #46.
Move gallery_share_allowlist from module scope into gallery_page() to
prevent elc from emitting a second main() for the gallery module, which
caused a duplicate-symbol link error when combined with main.el.
Update elc-linux-amd64 binary (rebuilt with RBrace fix from PR #46).
Replace all return "..." HTML string literals with native El templates —
removes all \" escapes, converts + interpolations to {expr}/{raw(expr)},
and replaces conditional string concatenation with {#if}/{#else}/{/if}.
No functional changes; output is identical.
Recovers original JS from git history and ports it into proper El source
files under src/js/. Each file wraps the original JS in a native_js call
inside a main() function, making it valid El that compiles to a
self-contained IIFE via elc --target=js --bundle.
Files added:
src/js/account-auth.el - Supabase OTP magic-link (sendMagicLink)
src/js/account-dashboard.el - Account dashboard: session, plan card, family
src/js/chat-widget.el - Demo chat widget (neuronDemoToggle/Send/Reset)
src/js/checkout-auth.el - Checkout auth: OAuth, email sign-in/up
src/js/checkout-free.el - Free plan: auth-badge watch -> payment reveal
src/js/checkout-stripe.el - Stripe Payment Element (reads NEURON_CFG)
src/js/enterprise.el - Enterprise inquiry form + headcount filter
src/js/environmental.el - Efficiency calculator slider
src/js/gallery.el - Gallery nav, search/sort, Supabase voting
src/js/main.el - Share page voting + copyForPlatform
src/js/marketplace.el - Developer interest form
src/js/nav.el - Nav hamburger + Mission dropdown
src/js/styles.el - Landing: nav scroll, reveal, founding counter
Gallery: remove <a> from share allowlist. Gallery cards wrap content in
<a class="gal-link">; allowing <a> in sanitized answer HTML causes nested
anchors that the HTML5 adoption agency algorithm resolves by restructuring
the DOM, producing mismatched </div> tags that leave gallery-grid open and
pull sibling elements into the grid as spurious grid columns.
Account: replace email+password sign-up/sign-in with magic-link OTP.
supabase.auth.signInWithOtp handles both new and existing users in one
flow. Existing onAuthStateChange listener (dadeb8ddb9a8.js) retained for
post-redirect dashboard display. sendMagicLink added to extract-js
RESERVED_GLOBALS so the obfuscator does not mangle the onclick reference.
Turnstile callback unconditionally showed the greeting message, wiping
session history for returning visitors who hadn't verified yet.
Callback now uses the same restoreOrGreet pattern as neuronDemoToggle:
replays prior messages if session.messages is non-empty, else shows the
greeting once and marks session.greeted.
Also extracts the gallery voting inline script (a49ca0a129e8.js) as a
side effect of re-running extract-js.py. Chat JS rebuilds to de72b8b61d75.js.
Voting was completely broken: gallery.el referenced d8251f5e5aa1.js
which was never written. Buttons rendered disabled with no JS to
enable them, load vote state, or call /api/vote.
Fix 1 — client: inline the voting script directly in gallery.el.
Initializes Supabase client from window.NEURON_CFG, calls
/api/vote-state/<id> on load (with JWT if signed in) to populate
scores and active states, wires vote buttons with toggle logic
(same direction = undo/none), handles sign-in modal with magic-link
flow, re-loads all vote states on auth state change.
Fix 2 — server: replace supabase_upsert_user (upsert via user JWT)
with delete-then-insert. Upsert requires both INSERT + UPDATE RLS
policies; the UPDATE policy is typically absent on share_votes.
Delete (user JWT, RLS-safe) + insert (service key, user already
auth-validated) is reliable for both new votes and vote changes.
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
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.
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).
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.