Tengo Gzirishvili
Support deep-linking into a view from the branded /app/ wrapper
bc4db2f
Raw
History Blame Contribute Delete
37.4 kB
/* ════════════════════════════════════════════════════════════════════════
* 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&rsquo;t verify your sign-in for this request. Reload to refresh the connection &mdash; 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 &amp; 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();
}
})();