Spaces:
Running
Running
| /* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * TuringDNA engine β static auth bootstrap | |
| * ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * | |
| * Runs FIRST on page load (before app.js). Two jobs: | |
| * | |
| * 1. Pick up the Supabase access token from the URL fragment. The | |
| * /app iframe wrapper at turingdna.com/app forwards the JWT via | |
| * location.hash because we can't share cookies cross-origin | |
| * (turingdna.com β winter4000-syntheogenesis.hf.space). We read it | |
| * once, stash it in sessionStorage, then scrub the hash so it | |
| * doesn't end up in the browser history. | |
| * | |
| * 2. Wrap window.fetch so every /api/* call automatically carries the | |
| * token in an Authorization: Bearer header. app.js can stay | |
| * unmodified β it calls fetch('/api/...') as before, and the wrapper | |
| * injects the auth. | |
| * | |
| * 402 handling: if any /api/* call returns 402 with kind="auth_required", | |
| * we redirect the iframe parent (window.top) to /signup. Anonymous users | |
| * who've used their free run see a clean handoff to the sign-up page | |
| * rather than a JSON error in the chat panel. | |
| * | |
| * No external deps. Works without Supabase being configured (the engine | |
| * gracefully falls back to anonymous mode and the server-side auth | |
| * module also degrades open). Lives at /static/auth.js, loaded before | |
| * app.js in index.html. | |
| * ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| (function () { | |
| 'use strict'; | |
| const TOKEN_KEY = 'td_access_token'; | |
| const REFRESH_KEY = 'td_refresh_token'; | |
| const USER_ID_KEY = 'td_user_id'; | |
| const USER_EMAIL_KEY = 'td_user_email'; | |
| // Origins we accept a live token-refresh push FROM (the /app wrapper). | |
| // The message handler WRITES auth tokens to sessionStorage, so this | |
| // allowlist is a security boundary β never widen it to '*'. Apex + www | |
| // cover both ways the wrapper may be served. | |
| const TRUSTED_PARENT_ORIGINS = [ | |
| 'https://turingdna.com', | |
| 'https://www.turingdna.com', | |
| ]; | |
| // Timestamp (ms) of the last fresh token the parent pushed in. Used to | |
| // tell a silent refresh ("the token was renewed under us") apart from a | |
| // dead session ("refresh failed β must nag the user"). | |
| let _lastTokenRefreshAt = 0; | |
| // βββ 1. Read tokens from URL fragment, if present ββββββββββββββββ | |
| // The fragment is set by /app's iframe wrapper when the user is | |
| // signed in. Format: | |
| // #access_token=...&refresh_token=...&user_id=...&user_email=... | |
| // Single writer for the token quartet β used by both the URL-fragment | |
| // path (initial load) and the live postMessage path (token rotation | |
| // while the iframe stays mounted). Only the access token is required; | |
| // the rest are best-effort. | |
| function _stashTokens(at, rt, uid, uem) { | |
| if (!at) return false; | |
| try { | |
| sessionStorage.setItem(TOKEN_KEY, at); | |
| if (rt) sessionStorage.setItem(REFRESH_KEY, rt); | |
| if (uid) sessionStorage.setItem(USER_ID_KEY, uid); | |
| if (uem) sessionStorage.setItem(USER_EMAIL_KEY, uem); | |
| } catch (_) { | |
| // Some browsers disable sessionStorage in private mode β | |
| // we lose persistence but the wrapper re-pushes the token | |
| // on every refresh / refocus anyway. | |
| return false; | |
| } | |
| return true; | |
| } | |
| function _readFragmentAndStash() { | |
| const hash = window.location.hash || ''; | |
| if (!hash.startsWith('#') || !hash.includes('access_token=')) return; | |
| const params = new URLSearchParams(hash.slice(1)); | |
| const at = params.get('access_token'); | |
| const rt = params.get('refresh_token'); | |
| const uid = params.get('user_id'); | |
| const uem = params.get('user_email'); | |
| if (!at) return; | |
| _stashTokens(at, rt, uid, uem); | |
| // Scrub the SENSITIVE keys from the hash so the token doesn't end up | |
| // in browser history, but keep a non-secret `view=` deep-link (e.g. | |
| // from turingdna.com/app/#turing β β¦#access_token=β¦&view=turing) alive | |
| // so app.js's currentRoute() β which runs later, after this synchronous | |
| // script β can still route to it. Use replaceState so back still works. | |
| try { | |
| const view = params.get('view'); | |
| const cleanUrl = window.location.pathname + window.location.search | |
| + (view ? '#view=' + encodeURIComponent(view) : ''); | |
| window.history.replaceState(null, '', cleanUrl); | |
| } catch (_) { /* ignore */ } | |
| // 2026-05-28 β Fix for "signed-in users still hit the 5-min wall": | |
| // A returning user who hit the wall as anonymous still has the | |
| // localStorage trial-timer state from that session. Even though | |
| // every entry-point into _showTrialModal() guards on _accessToken(), | |
| // the guard is fragile β clear the state outright the moment we | |
| // know they're authenticated. Cookies on the engine origin are | |
| // independent of this and still represent the trial, but the | |
| // server-side gate also checks the JWT FIRST and short-circuits | |
| // before reading the cookie, so the cookie can sit harmless. | |
| try { localStorage.removeItem('td_trial_started_at'); } catch (_) {} | |
| } | |
| _readFragmentAndStash(); | |
| // βββ 2. Token accessors βββββββββββββββββββββββββββββββββββββββββ | |
| function _accessToken() { | |
| try { return sessionStorage.getItem(TOKEN_KEY) || null; } | |
| catch (_) { return null; } | |
| } | |
| function _userEmail() { | |
| try { return sessionStorage.getItem(USER_EMAIL_KEY) || null; } | |
| catch (_) { return null; } | |
| } | |
| // Wipe the cached session so a dead token can't keep masking a fresh | |
| // sign-in (the "session out of sync that won't clear" loop). | |
| function _clearTokens() { | |
| try { | |
| sessionStorage.removeItem(TOKEN_KEY); | |
| sessionStorage.removeItem(REFRESH_KEY); | |
| sessionStorage.removeItem(USER_ID_KEY); | |
| sessionStorage.removeItem(USER_EMAIL_KEY); | |
| } catch (_) { /* private mode β nothing to clear */ } | |
| } | |
| // If the user was already signed in (sessionStorage carried over from a | |
| // prior load in this tab), wipe the legacy trial state too. Same | |
| // rationale as the wipe inside _readFragmentAndStash, but covers the | |
| // case where the fragment-handoff doesn't run on this load (e.g. the | |
| // wrapper re-used a cached iframe URL without the hash). | |
| if (_accessToken()) { | |
| try { localStorage.removeItem('td_trial_started_at'); } catch (_) {} | |
| } | |
| // βββ 3. Wrap fetch to attach Authorization on same-origin /api/* ββ | |
| // We only attach the token for same-origin requests to /api/*. We | |
| // don't want to leak tokens to BLAST / AlphaFold / vendor URLs. | |
| const _origFetch = window.fetch.bind(window); | |
| window.fetch = function patchedFetch(input, init) { | |
| try { | |
| const url = (typeof input === 'string') ? input : (input && input.url) || ''; | |
| const isApi = url.startsWith('/api/') || | |
| url.startsWith(window.location.origin + '/api/'); | |
| if (isApi) { | |
| const token = _accessToken(); | |
| if (token) { | |
| init = init || {}; | |
| const headers = new Headers(init.headers || {}); | |
| if (!headers.has('Authorization')) { | |
| headers.set('Authorization', 'Bearer ' + token); | |
| } | |
| init.headers = headers; | |
| } | |
| } | |
| } catch (_) { /* if wrapper errors, fall through to original fetch */ } | |
| const p = _origFetch(input, init); | |
| // Auth-failure handler β fires on EVERY fetch so any protected | |
| // endpoint can trigger the gate: | |
| // | |
| // 402 + kind=auth_required β trial expired (anon ran out | |
| // of the 5-min window). Send | |
| // them to /signin. | |
| // 403 + kind=signin_required β endpoint requires an account | |
| // AND the user appears anon to | |
| // the backend (no JWT, or | |
| // expired JWT). For a user who | |
| // THINKS they're signed in but | |
| // whose token has rolled over, | |
| // reloading the top window is | |
| // the cheapest reset β the | |
| // /app wrapper calls | |
| // refreshSession() and injects | |
| // the fresh JWT into the iframe | |
| // URL fragment. For a genuinely | |
| // anonymous user, the wrapper's | |
| // no-session branch fires and | |
| // /signin shows up via the | |
| // normal flow. | |
| return p.then((res) => { | |
| if (!res) return res; | |
| if (res.status !== 402 && res.status !== 403) return res; | |
| // Clone so the caller can still read the body if it wants. | |
| res.clone().json().then((data) => { | |
| if (!data || !data.kind) return; | |
| // 2026-05-28 β Critical branch: if the user HAS a JWT in | |
| // sessionStorage and we STILL got a quota/sign-in error, | |
| // the JWT was sent and rejected by the backend. This is | |
| // NOT a "user is anonymous" case β they're signed in, | |
| // their token just isn't being accepted right now | |
| // (expired, clock skew, race with autoRefreshToken in | |
| // the wrapper, etc.). Bouncing them to /signin would | |
| // round-trip them straight back to /app with the same | |
| // dead session (the wrapper's getSession() returns the | |
| // cached value first). This is the "thrown back to the | |
| // start" symptom users reported on Instagram after | |
| // signing up. | |
| // | |
| // Correct response: surface a "session expired" modal | |
| // that, on click, refreshes the top window so the | |
| // wrapper's refreshSession() path can mint a fresh JWT | |
| // and inject it into the iframe. No automatic reload β | |
| // we learned in commit 50d9d32 that auto-reloads cause | |
| // loops when the refresh-token is also dead. | |
| const signedIn = !!_accessToken(); | |
| if (signedIn) { | |
| // Try a silent, no-reload token refresh via the wrapper | |
| // first; only nag if that fails (root-cause fix for the | |
| // "reload won't clear it" session loop). | |
| _onSignedInAuthFailure(data && data.signup_url); | |
| return; | |
| } | |
| if (data.kind === 'auth_required') { | |
| _redirectToSignup(data.signup_url); | |
| } else if (data.kind === 'signin_required') { | |
| // Genuinely-anonymous case: dispatch the event so | |
| // view-specific UI (e.g. the CRISPR controller's | |
| // sign-in modal) can react. Same idiom as 50d9d32. | |
| try { | |
| window.dispatchEvent(new CustomEvent( | |
| 'td:signin-required', | |
| { detail: { signup_url: data.signup_url } }, | |
| )); | |
| } catch (_) { /* old browsers β fine */ } | |
| } | |
| }).catch(() => { /* not JSON, ignore */ }); | |
| return res; | |
| }); | |
| }; | |
| function _redirectToSignup(url) { | |
| // Default to /signin (most users have an account); the page itself | |
| // links to /signup for new users. Backend sends an absolute URL | |
| // in its 402 payload β we prefer that whenever present. | |
| const target = url || 'https://turingdna.com/signin/?from=app'; | |
| // If we're inside the iframe (the production setup), redirect | |
| // the TOP window so /signup loads on turingdna.com, not inside | |
| // the iframe. If we're standalone, redirect this window. | |
| try { | |
| if (window.top && window.top !== window.self) { | |
| window.top.location.href = target; | |
| return; | |
| } | |
| } catch (_) { /* cross-origin parent β fall through */ } | |
| window.location.href = target; | |
| } | |
| // βββ 4. Trial-timer modal βββββββββββββββββββββββββββββββββββββββ | |
| // 5-minute anonymous trial. The backend stamps two response headers | |
| // on every /api/run for anon users: | |
| // | |
| // X-TD-Trial-Started-At unix seconds when their trial began | |
| // X-TD-Trial-Remaining-S seconds left in the window | |
| // | |
| // We read those headers from the fetch response and arm a setTimeout | |
| // for the remaining time. When it fires, we inject a modal that | |
| // blocks the engine UI with a "Sign in to keep going" CTA. The | |
| // modal is built and styled in JS so we don't need to touch the | |
| // engine's main HTML/CSS β it's a clean drop-in. | |
| // | |
| // If the user reloads, the timer state survives via localStorage | |
| // (key = TRIAL_START_KEY) β they don't get a fresh 5 min by hitting | |
| // refresh. Server cookie is the authoritative source; localStorage | |
| // is just used to know when to render the modal without waiting for | |
| // a fresh /api/run response. | |
| const TRIAL_START_KEY = 'td_trial_started_at'; | |
| const TRIAL_SECONDS = 300; // mirrors auth.py default; the server | |
| // is authoritative β the modal only | |
| // controls the visual countdown timing | |
| let _trialTimerId = null; | |
| let _modalShown = false; | |
| function _ensureModalEl() { | |
| let el = document.getElementById('td-trial-modal'); | |
| if (el) return el; | |
| el = document.createElement('div'); | |
| el.id = 'td-trial-modal'; | |
| el.setAttribute('role', 'dialog'); | |
| el.setAttribute('aria-modal', 'true'); | |
| // Docked right-hand sheet (not a centred overlay) β light scrim so the | |
| // workspace stays visible behind it instead of being fully covered. | |
| el.style.cssText = [ | |
| 'position:fixed', 'inset:0', 'z-index:99999', | |
| 'display:flex', 'align-items:stretch', 'justify-content:flex-end', | |
| 'background:rgba(10,10,10,0.16)', | |
| 'opacity:0', | |
| 'transition:opacity 200ms ease', | |
| 'font-family:"Source Serif 4",Georgia,serif', | |
| 'color:#0A0A0A', | |
| ].join(';'); | |
| const card = document.createElement('div'); | |
| card.style.cssText = [ | |
| 'width:400px', 'max-width:100%', 'height:100%', | |
| 'background:#F7F5F0', | |
| 'padding:40px 32px 32px', | |
| 'border-left:1px solid #D6D3D1', | |
| 'box-shadow:-18px 0 50px rgba(20,19,16,0.22)', | |
| 'display:flex', 'flex-direction:column', | |
| 'overflow-y:auto', | |
| 'box-sizing:border-box', | |
| ].join(';'); | |
| card.innerHTML = | |
| '<p style="font-family:\'JetBrains Mono\',ui-monospace,monospace;font-size:0.74rem;text-transform:uppercase;letter-spacing:0.14em;color:#6B6862;margin:0 0 14px;">Β§ Trial ended</p>' + | |
| '<h2 style="font-family:\'Playfair Display\',Georgia,serif;font-style:italic;font-weight:700;font-size:1.95rem;line-height:1.08;letter-spacing:-0.01em;color:#0A0A0A;margin:0 0 14px;">Sign in to keep going.</h2>' + | |
| '<p style="font-size:1rem;line-height:1.55;color:#2A2A29;margin:0 0 24px;">' + | |
| 'Your 5-minute trial has ended. Sign in to keep going β accounts are free and we save your generated libraries for 45 days.' + | |
| '</p>' + | |
| '<div style="display:flex;flex-direction:column;gap:10px;margin-top:auto;">' + | |
| '<a id="td-trial-signin" href="https://turingdna.com/signin/?from=app" style="font-family:\'JetBrains Mono\',ui-monospace,monospace;font-size:0.76rem;text-transform:uppercase;letter-spacing:0.1em;padding:0.85em 1.2em;border:1px solid #0A0A0A;background:#0A0A0A;color:#F7F5F0;text-decoration:none;border-radius:2px;min-height:44px;display:inline-flex;align-items:center;justify-content:center;width:100%;box-sizing:border-box;">Sign in</a>' + | |
| '<a id="td-trial-home" href="https://turingdna.com/" style="font-family:\'JetBrains Mono\',ui-monospace,monospace;font-size:0.76rem;text-transform:uppercase;letter-spacing:0.1em;padding:0.85em 1.2em;border:1px solid #0A0A0A;color:#0A0A0A;background:transparent;text-decoration:none;border-radius:2px;min-height:44px;display:inline-flex;align-items:center;justify-content:center;width:100%;box-sizing:border-box;">Back to home</a>' + | |
| '</div>'; | |
| el.appendChild(card); | |
| document.body.appendChild(el); | |
| // Both links break out of the iframe to the top window so the | |
| // user lands on turingdna.com/signup/ (not signup-inside-iframe). | |
| ['td-trial-signin', 'td-trial-home'].forEach(function (id) { | |
| const a = el.querySelector('#' + id); | |
| if (!a) return; | |
| a.addEventListener('click', function (e) { | |
| e.preventDefault(); | |
| const href = a.getAttribute('href'); | |
| _redirectToSignup(href); | |
| }); | |
| }); | |
| // Block engine interaction beneath the modal. | |
| document.body.style.overflow = 'hidden'; | |
| return el; | |
| } | |
| function _showTrialModal() { | |
| if (_modalShown) return; | |
| if (_accessToken()) return; // signed in β never show | |
| _modalShown = true; | |
| const el = _ensureModalEl(); | |
| // Trigger CSS transition. | |
| requestAnimationFrame(function () { el.style.opacity = '1'; }); | |
| } | |
| // βββ 4b. Session-expired modal (signed-in user whose JWT was rejected) β | |
| // Shown when the fetch wrapper sees a 402 or 403 BUT _accessToken() is | |
| // truthy. Inline reload-on-click β bypasses the broken auto-reload | |
| // loop that commit 50d9d32 killed by handing the decision to the user. | |
| let _sessionExpiredShown = false; | |
| function _showSessionExpiredModal(signupUrl) { | |
| if (_sessionExpiredShown) return; | |
| _sessionExpiredShown = true; | |
| let el = document.getElementById('td-session-expired-modal'); | |
| if (el) { el.style.opacity = '1'; return; } | |
| el = document.createElement('div'); | |
| el.id = 'td-session-expired-modal'; | |
| el.setAttribute('role', 'dialog'); | |
| el.setAttribute('aria-modal', 'true'); | |
| el.style.cssText = [ | |
| 'position:fixed', 'inset:0', 'z-index:99999', | |
| 'display:flex', 'align-items:center', 'justify-content:center', | |
| 'background:rgba(10,10,10,0.78)', | |
| 'backdrop-filter:blur(6px)', | |
| '-webkit-backdrop-filter:blur(6px)', | |
| 'padding:24px', | |
| 'opacity:0', | |
| 'transition:opacity 200ms ease', | |
| 'font-family:"Source Serif 4",Georgia,serif', | |
| 'color:#0A0A0A', | |
| ].join(';'); | |
| const card = document.createElement('div'); | |
| card.style.cssText = [ | |
| 'max-width:480px', 'width:100%', | |
| 'background:#F7F5F0', | |
| 'padding:36px 32px', | |
| 'border-radius:4px', | |
| 'box-shadow:0 30px 80px rgba(0,0,0,0.45)', | |
| ].join(';'); | |
| const fallback = signupUrl || 'https://turingdna.com/signin/?from=app'; | |
| card.innerHTML = | |
| '<p style="font-family:\'JetBrains Mono\',ui-monospace,monospace;font-size:0.74rem;text-transform:uppercase;letter-spacing:0.14em;color:#6B6862;margin:0 0 14px;">Β§ Session out of sync</p>' + | |
| '<h2 style="font-family:\'Playfair Display\',Georgia,serif;font-style:italic;font-weight:700;font-size:1.95rem;line-height:1.08;letter-spacing:-0.01em;color:#0A0A0A;margin:0 0 14px;">Refresh your session.</h2>' + | |
| '<p style="font-size:1rem;line-height:1.55;color:#2A2A29;margin:0 0 24px;">' + | |
| 'The engine couldn’t verify your sign-in for this request. Reload to refresh the connection — your work is saved. If it keeps happening, sign in again.' + | |
| '</p>' + | |
| '<div style="display:flex;gap:12px;flex-wrap:wrap;">' + | |
| '<button id="td-session-expired-reload" type="button" style="font-family:\'JetBrains Mono\',ui-monospace,monospace;font-size:0.76rem;text-transform:uppercase;letter-spacing:0.1em;padding:0.85em 1.2em;border:1px solid #0A0A0A;background:#0A0A0A;color:#F7F5F0;cursor:pointer;border-radius:2px;min-height:44px;">Reload & continue</button>' + | |
| '<a id="td-session-expired-signin" href="' + fallback + '" style="font-family:\'JetBrains Mono\',ui-monospace,monospace;font-size:0.76rem;text-transform:uppercase;letter-spacing:0.1em;padding:0.85em 1.2em;border:1px solid #0A0A0A;color:#0A0A0A;background:transparent;text-decoration:none;border-radius:2px;min-height:44px;display:inline-flex;align-items:center;">Sign in again</a>' + | |
| '</div>'; | |
| el.appendChild(card); | |
| document.body.appendChild(el); | |
| document.body.style.overflow = 'hidden'; | |
| // Reload button: top-window reload triggers the wrapper's | |
| // getSession + refreshSession path which mints a fresh JWT and | |
| // injects it into the iframe URL fragment. If the refresh-token | |
| // is also dead, the wrapper falls through to its no-session | |
| // branch and the user sees /signin via the pill, no loop. | |
| const reloadBtn = card.querySelector('#td-session-expired-reload'); | |
| if (reloadBtn) { | |
| reloadBtn.addEventListener('click', function () { | |
| // Drop the dead token FIRST. Otherwise, if the wrapper re-uses | |
| // a cached iframe URL (no fresh #access_token fragment), the | |
| // iframe re-reads the same stale token from sessionStorage and | |
| // loops on "session out of sync." Cleared β worst case the user | |
| // is treated as anonymous and gets the normal sign-in flow, no | |
| // dead-token loop. (This is the "reload doesn't fix it" bug.) | |
| _clearTokens(); | |
| try { | |
| if (window.top && window.top !== window.self) { | |
| window.top.location.reload(); | |
| return; | |
| } | |
| } catch (_) { /* cross-origin parent β fall through */ } | |
| window.location.reload(); | |
| }); | |
| } | |
| // Sign-in fallback: explicit top-window navigation so the user | |
| // can choose to re-authenticate from scratch. Clear the stale token so | |
| // the fresh sign-in isn't masked by a cached dead session. | |
| const signinLink = card.querySelector('#td-session-expired-signin'); | |
| if (signinLink) { | |
| signinLink.addEventListener('click', function (e) { | |
| e.preventDefault(); | |
| _clearTokens(); | |
| _redirectToSignup(signinLink.getAttribute('href')); | |
| }); | |
| } | |
| requestAnimationFrame(function () { el.style.opacity = '1'; }); | |
| } | |
| // Tear down the session-expired modal β called when a fresh token | |
| // arrives from the parent (silent recovery), so the user never has to | |
| // click "Reload" and never loses in-progress work (e.g. a half-done | |
| // GenBank upload). | |
| function _dismissSessionExpiredModal() { | |
| const el = document.getElementById('td-session-expired-modal'); | |
| if (el) { try { el.remove(); } catch (_) {} } | |
| _sessionExpiredShown = false; | |
| try { document.body.style.overflow = ''; } catch (_) {} | |
| } | |
| // βββ 4c. Live token channel (no-reload session refresh) ββββββββββ | |
| // The /app wrapper holds the long-lived Supabase session and rotates | |
| // the 1-hour access token in the background. It pushes each fresh JWT | |
| // into this iframe via postMessage so our sessionStorage token never | |
| // goes stale β the root-cause fix for "session out of sync that a | |
| // reload won't clear." Pushing tokens (vs reloading the iframe with a | |
| // new URL fragment) keeps the user's current work intact. | |
| window.addEventListener('message', function (ev) { | |
| // SECURITY: this writes auth tokens, so only accept pushes from the | |
| // known wrapper origins. Anything else is ignored outright. | |
| if (TRUSTED_PARENT_ORIGINS.indexOf(ev.origin) === -1) return; | |
| const data = ev.data; | |
| if (!data || data.type !== 'td-token-refresh' || !data.access_token) return; | |
| const ok = _stashTokens( | |
| data.access_token, data.refresh_token, data.user_id, data.user_email); | |
| if (!ok) return; | |
| _lastTokenRefreshAt = Date.now(); | |
| // A signed-in user can't be mid-trial β clear any stale timer state. | |
| try { localStorage.removeItem(TRIAL_START_KEY); } catch (_) {} | |
| // If we were showing the "session out of sync" nag, the fresh token | |
| // just resolved it β dismiss silently. | |
| _dismissSessionExpiredModal(); | |
| // The email may only arrive on this push (cached iframe URL w/o hash) β | |
| // (re)render the sidebar account row now that we know who's signed in. | |
| try { _renderSidebarAccount(); } catch (_) { /* DOM not ready yet */ } | |
| }); | |
| // Ask the parent wrapper to mint + push a fresh JWT. Carries NO secret | |
| // (just a request), so it's safe to broadcast to each trusted origin; | |
| // postMessage to a non-matching frame is a silent no-op. | |
| function _requestParentTokenRefresh() { | |
| try { | |
| if (!window.parent || window.parent === window.self) return; | |
| TRUSTED_PARENT_ORIGINS.forEach(function (origin) { | |
| try { window.parent.postMessage({ type: 'td-token-please' }, origin); } | |
| catch (_) { /* origin mismatch β ignore */ } | |
| }); | |
| } catch (_) { /* no reachable parent β fall through to the modal */ } | |
| } | |
| // Signed-in user whose JWT was just rejected. Instead of immediately | |
| // nagging, ask the wrapper for a fresh token first. If it lands within | |
| // the grace window the user continues seamlessly (no modal, no reload); | |
| // only if the refresh fails (dead refresh token) do we surface the | |
| // session-expired modal so they can re-authenticate. | |
| function _onSignedInAuthFailure(signupUrl) { | |
| const before = _lastTokenRefreshAt; | |
| _requestParentTokenRefresh(); | |
| setTimeout(function () { | |
| if (_lastTokenRefreshAt > before) return; // silent refresh won | |
| _showSessionExpiredModal(signupUrl); | |
| }, 1500); | |
| } | |
| function _armTrialTimer(remainingSeconds) { | |
| if (_trialTimerId !== null) { | |
| clearTimeout(_trialTimerId); | |
| _trialTimerId = null; | |
| } | |
| if (_accessToken()) return; // signed in β no timer needed | |
| if (remainingSeconds <= 0) { | |
| _showTrialModal(); | |
| return; | |
| } | |
| // Cap setTimeout delay to avoid integer overflow on very long | |
| // remainders and to add a safety check for clock skew. | |
| const delayMs = Math.min(remainingSeconds, 7200) * 1000 + 250; | |
| _trialTimerId = setTimeout(_showTrialModal, delayMs); | |
| } | |
| function _captureTrialFromResponse(res) { | |
| // Read the server's headers to learn (a) when this visitor's | |
| // trial started, (b) how much time is left. Whichever happens | |
| // first across signin/timeout fires the modal. | |
| try { | |
| const startedAt = res.headers.get('X-TD-Trial-Started-At'); | |
| const remaining = res.headers.get('X-TD-Trial-Remaining-S'); | |
| if (startedAt) { | |
| localStorage.setItem(TRIAL_START_KEY, String(parseInt(startedAt, 10) || 0)); | |
| } | |
| if (remaining !== null) { | |
| const rem = Math.max(0, parseInt(remaining, 10) || 0); | |
| _armTrialTimer(rem); | |
| } | |
| } catch (_) { /* headers not readable in some CORS configs β ignore */ } | |
| } | |
| function _restoreTrialFromStorage() { | |
| // If the page reloads, we may not have a fresh /api/run response | |
| // yet β restore the timer from localStorage so the modal still | |
| // fires on schedule. | |
| if (_accessToken()) return; | |
| let startedAt; | |
| try { | |
| startedAt = parseInt(localStorage.getItem(TRIAL_START_KEY) || '0', 10); | |
| } catch (_) { | |
| startedAt = 0; | |
| } | |
| if (!startedAt) return; | |
| const elapsed = Math.floor(Date.now() / 1000) - startedAt; | |
| const remaining = TRIAL_SECONDS - elapsed; | |
| _armTrialTimer(remaining); | |
| } | |
| _restoreTrialFromStorage(); | |
| // Full sign-out from inside the engine iframe. The long-lived Supabase | |
| // session lives in the /app WRAPPER, not here β so clearing our own | |
| // sessionStorage isn't enough: re-entering /app would call getSession(), | |
| // re-inject a fresh JWT, and silently sign the user back in. So we ALSO | |
| // ask the wrapper to drop its Supabase session (td-signout-please); it | |
| // then navigates the top window to /signin. If no wrapper picks that up | |
| // (older landing build, or running standalone) we fall back to navigating | |
| // ourselves after a short grace window so the user is never stranded. | |
| function _fullSignOut() { | |
| try { | |
| sessionStorage.removeItem(TOKEN_KEY); | |
| sessionStorage.removeItem(REFRESH_KEY); | |
| sessionStorage.removeItem(USER_ID_KEY); | |
| sessionStorage.removeItem(USER_EMAIL_KEY); | |
| // Clear the engine's session-resume so the next anon visitor | |
| // (or a different user signing in on this browser) doesn't | |
| // see the prior user's pasted sequence. | |
| sessionStorage.removeItem('td_engine_resume'); | |
| localStorage.removeItem(TRIAL_START_KEY); | |
| } catch (_) { /* ignore */ } | |
| let askedParent = false; | |
| try { | |
| if (window.parent && window.parent !== window.self) { | |
| TRUSTED_PARENT_ORIGINS.forEach(function (origin) { | |
| try { | |
| window.parent.postMessage({ type: 'td-signout-please' }, origin); | |
| askedParent = true; | |
| } catch (_) { /* origin mismatch β ignore */ } | |
| }); | |
| } | |
| } catch (_) { /* no reachable parent */ } | |
| // Give the wrapper a beat to clear the Supabase session + navigate the | |
| // top window. If it doesn't (no handler), we navigate ourselves. | |
| setTimeout(function () { | |
| _redirectToSignup('https://turingdna.com/signin/?from=signout'); | |
| }, askedParent ? 1400 : 0); | |
| } | |
| // βββ 5. Public surface ββββββββββββββββββββββββββββββββββββββββββ | |
| window.TuringAuth = { | |
| getToken: _accessToken, | |
| getEmail: _userEmail, | |
| isSignedIn: function () { return !!_accessToken(); }, | |
| signOut: _fullSignOut, | |
| }; | |
| // βββ 5b. Sidebar account row (foot of the engine's nav rail) βββββ | |
| // Renders the signed-in user's identity natively at the bottom of the | |
| // sidebar (Claude-style), replacing the wrapper's old floating chip. | |
| function _renderSidebarAccount() { | |
| const signin = document.getElementById('sidebarSignin'); | |
| const root = document.getElementById('sidebarAccount'); | |
| const email = _userEmail(); | |
| const signedIn = !!_accessToken() && !!email; | |
| // Anonymous β show the "Sign in" row and tell the /app wrapper to retire | |
| // its own floating fallback so the two never both appear once this ships. | |
| if (signin) { | |
| signin.hidden = signedIn; | |
| if (!signedIn) _announceAnonAffordance(); | |
| } | |
| if (!root) return; | |
| if (!signedIn) { root.hidden = true; return; } | |
| const nameEl = document.getElementById('acctName'); | |
| const emailEl = document.getElementById('acctMenuEmail'); | |
| const avatar = document.getElementById('acctAvatar'); | |
| if (nameEl) nameEl.textContent = email; | |
| if (emailEl) emailEl.textContent = email; | |
| if (avatar) avatar.textContent = (email.trim().charAt(0) || '?').toUpperCase(); | |
| root.hidden = false; | |
| } | |
| // Tell the /app wrapper that the engine now renders its own anonymous | |
| // "Sign in" row in the sidebar foot, so the wrapper can drop its floating | |
| // fallback link (prevents a duplicate once this build is deployed). The | |
| // current production wrapper simply ignores an unknown message type. | |
| function _announceAnonAffordance() { | |
| try { | |
| if (!window.parent || window.parent === window.self) return; | |
| TRUSTED_PARENT_ORIGINS.forEach(function (origin) { | |
| try { window.parent.postMessage({ type: 'td-anon-affordance' }, origin); } | |
| catch (_) { /* origin mismatch β ignore */ } | |
| }); | |
| } catch (_) { /* no reachable parent */ } | |
| } | |
| function _initSidebarAccount() { | |
| const trigger = document.getElementById('acctTrigger'); | |
| const menu = document.getElementById('acctMenu'); | |
| const signout = document.getElementById('acctSignOut'); | |
| if (trigger && menu && !trigger._tdWired) { | |
| trigger._tdWired = true; | |
| const close = function () { | |
| menu.hidden = true; | |
| trigger.setAttribute('aria-expanded', 'false'); | |
| }; | |
| const open = function () { | |
| menu.hidden = false; | |
| trigger.setAttribute('aria-expanded', 'true'); | |
| }; | |
| trigger.addEventListener('click', function (e) { | |
| e.stopPropagation(); | |
| if (menu.hidden) open(); else close(); | |
| }); | |
| // Click anywhere else closes the menu. | |
| document.addEventListener('click', function (e) { | |
| if (menu.hidden) return; | |
| if (e.target === trigger || trigger.contains(e.target)) return; | |
| if (menu.contains(e.target)) return; | |
| close(); | |
| }); | |
| document.addEventListener('keydown', function (e) { | |
| if (e.key === 'Escape' && !menu.hidden) close(); | |
| }); | |
| if (signout) { | |
| signout.addEventListener('click', function () { close(); _fullSignOut(); }); | |
| } | |
| } | |
| _renderSidebarAccount(); | |
| } | |
| // Hook the fetch wrapper from Β§3 so it also reads trial headers. | |
| // We re-wrap rather than mutating the earlier wrapper so the order | |
| // of operations stays explicit. | |
| const _captureFetch = window.fetch.bind(window); | |
| window.fetch = function trialAwareFetch(input, init) { | |
| return _captureFetch(input, init).then(function (res) { | |
| try { | |
| const url = (typeof input === 'string') ? input : (input && input.url) || ''; | |
| if ((url.includes('/api/run') || url.indexOf('/api/run') >= 0) && res) { | |
| _captureTrialFromResponse(res); | |
| } | |
| } catch (_) { /* ignore */ } | |
| return res; | |
| }); | |
| }; | |
| // βββ 6. Tell the /app wrapper the engine has booted ββββββββββββββ | |
| // Lets the wrapper drop its cold-start "Waking the engineβ¦" screen as | |
| // soon as our UI is up β reliable on iOS, where the cross-origin iframe | |
| // load event isn't. Carries no data; posted only to trusted parents. | |
| function _announceEngineReady() { | |
| try { | |
| if (!window.parent || window.parent === window.self) return; | |
| TRUSTED_PARENT_ORIGINS.forEach(function (origin) { | |
| try { window.parent.postMessage({ type: 'td-engine-ready' }, origin); } | |
| catch (_) { /* origin mismatch β ignore */ } | |
| }); | |
| } catch (_) { /* no reachable parent */ } | |
| } | |
| function _onReady() { | |
| _announceEngineReady(); | |
| _initSidebarAccount(); | |
| } | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', _onReady); | |
| } else { | |
| _onReady(); | |
| } | |
| })(); | |