Spaces:
Running
Running
github-actions[bot] commited on
Commit ·
850ea8b
1
Parent(s): f72cbca
Deploy 4a2edb8
Browse filesRoute Paddle checkout out of the HF iframe to the approved website (#101)
Source: https://github.com/WINTER4000/turingDNA/commit/4a2edb88a32933dd8247beb7953e59105ceb8da4
- dee/core/paddle.py +3 -3
- dee/server.py +18 -2
- dee/static/billing.js +22 -127
- dee/static/index.html +21 -25
- tests/test_billing_handoff.py +31 -0
- tests/test_paddle.py +44 -5
dee/core/paddle.py
CHANGED
|
@@ -12,7 +12,7 @@ Everything the engine needs to sell and honour a Pro subscription:
|
|
| 12 |
|
| 13 |
WHAT DOES NOT LIVE HERE
|
| 14 |
-----------------------
|
| 15 |
-
Checkout itself. That is Paddle.js
|
| 16 |
the overlay opens against a price id, the buyer pays Paddle, and Paddle calls
|
| 17 |
the webhook. The server never sees a card, and never creates a transaction.
|
| 18 |
|
|
@@ -368,7 +368,7 @@ def cancel_subscription(subscription_id: Optional[str]) -> bool:
|
|
| 368 |
|
| 369 |
# ── Config handed to the browser ─────────────────────────────────────────
|
| 370 |
def public_config(user_id: Optional[str], email: Optional[str]) -> Dict[str, Any]:
|
| 371 |
-
"""What
|
| 372 |
|
| 373 |
Every value here is safe in a browser: the client token and price id are
|
| 374 |
public by design, and the customer id and user id are the requester's own.
|
|
@@ -424,7 +424,7 @@ def _user_for(data: Dict[str, Any]) -> Optional[str]:
|
|
| 424 |
|
| 425 |
Three routes, in order of how much we trust them:
|
| 426 |
|
| 427 |
-
1. custom_data.user_id, set by
|
| 428 |
the buyer Paddle actually charged (see below).
|
| 429 |
2. The profile already stamped with this customer id. A change made from
|
| 430 |
Paddle's dashboard carries no custom_data.
|
|
|
|
| 12 |
|
| 13 |
WHAT DOES NOT LIVE HERE
|
| 14 |
-----------------------
|
| 15 |
+
Checkout itself. That is Paddle.js on turingdna.com/pricing/ (landing branch):
|
| 16 |
the overlay opens against a price id, the buyer pays Paddle, and Paddle calls
|
| 17 |
the webhook. The server never sees a card, and never creates a transaction.
|
| 18 |
|
|
|
|
| 368 |
|
| 369 |
# ── Config handed to the browser ─────────────────────────────────────────
|
| 370 |
def public_config(user_id: Optional[str], email: Optional[str]) -> Dict[str, Any]:
|
| 371 |
+
"""What the engine upsell and the website checkout need to render billing.
|
| 372 |
|
| 373 |
Every value here is safe in a browser: the client token and price id are
|
| 374 |
public by design, and the customer id and user id are the requester's own.
|
|
|
|
| 424 |
|
| 425 |
Three routes, in order of how much we trust them:
|
| 426 |
|
| 427 |
+
1. custom_data.user_id, set by the website checkout — CHECKED against
|
| 428 |
the buyer Paddle actually charged (see below).
|
| 429 |
2. The profile already stamped with this customer id. A change made from
|
| 430 |
Paddle's dashboard carries no custom_data.
|
dee/server.py
CHANGED
|
@@ -1770,7 +1770,7 @@ def create_app() -> Flask:
|
|
| 1770 |
"retention_days": None if pro else _auth.FREE_LIBRARY_TTL_DAYS,
|
| 1771 |
})
|
| 1772 |
|
| 1773 |
-
@app.
|
| 1774 |
def billing_config() -> Response:
|
| 1775 |
"""What the browser needs to render the Pro upsell and open checkout.
|
| 1776 |
|
|
@@ -1778,10 +1778,26 @@ def create_app() -> Flask:
|
|
| 1778 |
the customer id, email and current plan only for the signed-in
|
| 1779 |
requester, about themselves. See dee/core/paddle.public_config.
|
| 1780 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1781 |
from .core import paddle as _paddle
|
| 1782 |
auth = _auth.get_auth()
|
| 1783 |
uid = None if auth.anonymous else auth.user_id
|
| 1784 |
-
return jsonify(_paddle.public_config(uid, auth.email))
|
| 1785 |
|
| 1786 |
@app.route("/api/account/delete", methods=["POST", "OPTIONS"])
|
| 1787 |
def account_delete() -> Response:
|
|
|
|
| 1770 |
"retention_days": None if pro else _auth.FREE_LIBRARY_TTL_DAYS,
|
| 1771 |
})
|
| 1772 |
|
| 1773 |
+
@app.route("/api/billing/config", methods=["GET", "OPTIONS"])
|
| 1774 |
def billing_config() -> Response:
|
| 1775 |
"""What the browser needs to render the Pro upsell and open checkout.
|
| 1776 |
|
|
|
|
| 1778 |
the customer id, email and current plan only for the signed-in
|
| 1779 |
requester, about themselves. See dee/core/paddle.public_config.
|
| 1780 |
"""
|
| 1781 |
+
# The approved checkout origin is the website, not the HF iframe.
|
| 1782 |
+
# Explicitly allow only the site's two canonical origins, with Bearer
|
| 1783 |
+
# auth (no cookies). No other API route inherits this permission.
|
| 1784 |
+
def checkout_response(resp):
|
| 1785 |
+
resp.headers["Cache-Control"] = "no-store"
|
| 1786 |
+
resp.vary.add("Origin")
|
| 1787 |
+
if request.headers.get("Origin") in (
|
| 1788 |
+
"https://turingdna.com", "https://www.turingdna.com"):
|
| 1789 |
+
resp.headers["Access-Control-Allow-Origin"] = request.headers["Origin"]
|
| 1790 |
+
resp.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
|
| 1791 |
+
resp.headers["Access-Control-Allow-Headers"] = "authorization"
|
| 1792 |
+
resp.headers["Access-Control-Max-Age"] = "600"
|
| 1793 |
+
return resp
|
| 1794 |
+
|
| 1795 |
+
if request.method == "OPTIONS":
|
| 1796 |
+
return checkout_response(app.make_response(("", 204)))
|
| 1797 |
from .core import paddle as _paddle
|
| 1798 |
auth = _auth.get_auth()
|
| 1799 |
uid = None if auth.anonymous else auth.user_id
|
| 1800 |
+
return checkout_response(jsonify(_paddle.public_config(uid, auth.email)))
|
| 1801 |
|
| 1802 |
@app.route("/api/account/delete", methods=["POST", "OPTIONS"])
|
| 1803 |
def account_delete() -> Response:
|
dee/static/billing.js
CHANGED
|
@@ -1,141 +1,36 @@
|
|
| 1 |
-
/*
|
| 2 |
-
*
|
| 3 |
-
*
|
| 4 |
-
* initialises Paddle.js, and — for a signed-in account on a plan below the top
|
| 5 |
-
* one, when Paddle is configured — puts the upgrades it can actually sell in
|
| 6 |
-
* the account menu. Paying happens entirely inside Paddle's iframe; the plan
|
| 7 |
-
* flips server-side when Paddle calls our webhook, so after checkout we poll
|
| 8 |
-
* /api/usage until it does rather than trust the browser.
|
| 9 |
-
*
|
| 10 |
-
* Which plans exist, their price ids and the amounts shown all come from the
|
| 11 |
-
* server. Nothing about pricing is decided here — this file renders what it
|
| 12 |
-
* is told, so a price change never needs a redeploy of the workbench.
|
| 13 |
-
*
|
| 14 |
-
* Everything here fails SILENTLY: a page with no billing configured, no
|
| 15 |
-
* Paddle.js (blocked, offline), or a config fetch that 404s simply shows no
|
| 16 |
-
* upsell. Billing must never be able to break the workbench. */
|
| 17 |
(function () {
|
| 18 |
'use strict';
|
| 19 |
-
|
| 20 |
-
function onReady(fn) {
|
| 21 |
-
if (document.readyState !== 'loading') { fn(); return; }
|
| 22 |
-
document.addEventListener('DOMContentLoaded', fn);
|
| 23 |
-
}
|
| 24 |
-
|
| 25 |
-
function initPaddle(cfg) {
|
| 26 |
-
if (!window.Paddle) return false;
|
| 27 |
-
try {
|
| 28 |
-
// Environment MUST be set before Initialize, and only for sandbox —
|
| 29 |
-
// Paddle.js defaults to production, and calling set('production') is
|
| 30 |
-
// not always supported across versions.
|
| 31 |
-
if (cfg.environment === 'sandbox' && Paddle.Environment && Paddle.Environment.set) {
|
| 32 |
-
Paddle.Environment.set('sandbox');
|
| 33 |
-
}
|
| 34 |
-
var opts = { token: cfg.client_token };
|
| 35 |
-
// pwCustomer wires Paddle Retain to the known customer. Only when we
|
| 36 |
-
// actually have their Paddle id — passing an empty one throws.
|
| 37 |
-
if (cfg.customer_id) opts.pwCustomer = { id: cfg.customer_id };
|
| 38 |
-
Paddle.Initialize(opts);
|
| 39 |
-
return true;
|
| 40 |
-
} catch (e) {
|
| 41 |
-
return false;
|
| 42 |
-
}
|
| 43 |
-
}
|
| 44 |
-
|
| 45 |
-
// After the overlay closes, the plan only changes once Paddle's webhook
|
| 46 |
-
// reaches our server. Poll /api/usage briefly so the panel updates itself
|
| 47 |
-
// instead of the user wondering whether it worked; give up quietly if the
|
| 48 |
-
// webhook is slow (it still lands, and a refresh will show it).
|
| 49 |
-
//
|
| 50 |
-
// Polls for a CHANGE FROM the plan we started on, rather than for one
|
| 51 |
-
// specific plan. A Starter subscriber upgrading to Pro starts on 'starter',
|
| 52 |
-
// so the old "is it 'pro' yet" test was fine — but "is it not 'free' yet"
|
| 53 |
-
// would have declared victory instantly, before anything moved.
|
| 54 |
-
function pollUntilPlanChanges(from, tries) {
|
| 55 |
-
if (tries <= 0) return;
|
| 56 |
-
fetch('/api/usage')
|
| 57 |
-
.then(function (r) { return r.ok ? r.json() : null; })
|
| 58 |
-
.catch(function () { return null; })
|
| 59 |
-
.then(function (d) {
|
| 60 |
-
if (d && d.plan && d.plan !== from) {
|
| 61 |
-
var slot = document.getElementById('acctUpsell');
|
| 62 |
-
if (slot) { slot.hidden = true; slot.innerHTML = ''; }
|
| 63 |
-
if (window.TDAuth && typeof window.TDAuth.refreshUsage === 'function') {
|
| 64 |
-
window.TDAuth.refreshUsage();
|
| 65 |
-
}
|
| 66 |
-
return;
|
| 67 |
-
}
|
| 68 |
-
setTimeout(function () { pollUntilPlanChanges(from, tries - 1); }, 3000);
|
| 69 |
-
});
|
| 70 |
-
}
|
| 71 |
-
|
| 72 |
-
function openCheckout(cfg, priceId) {
|
| 73 |
-
if (!window.Paddle || !priceId) return;
|
| 74 |
-
var from = cfg.plan || 'free';
|
| 75 |
-
var checkout = {
|
| 76 |
-
items: [{ priceId: priceId, quantity: 1 }],
|
| 77 |
-
// custom_data.user_id is how the webhook maps the resulting subscription
|
| 78 |
-
// back to this Supabase account (dee/core/paddle._user_for).
|
| 79 |
-
customData: { user_id: cfg.user_id || '' },
|
| 80 |
-
settings: { displayMode: 'overlay', theme: 'light' },
|
| 81 |
-
eventCallback: function (ev) {
|
| 82 |
-
if (ev && (ev.name === 'checkout.completed' || ev.name === 'checkout.closed')) {
|
| 83 |
-
pollUntilPlanChanges(from, 20);
|
| 84 |
-
}
|
| 85 |
-
}
|
| 86 |
-
};
|
| 87 |
-
if (cfg.email) checkout.customer = { email: cfg.email };
|
| 88 |
-
try { Paddle.Checkout.open(checkout); } catch (e) { /* overlay failed to open */ }
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
function upgradeButton(id, label, priceId, cfg) {
|
| 92 |
-
var btn = document.createElement('button');
|
| 93 |
-
btn.type = 'button';
|
| 94 |
-
btn.className = 'acct-menu-item acct-upgrade';
|
| 95 |
-
btn.id = id;
|
| 96 |
-
btn.textContent = label;
|
| 97 |
-
btn.addEventListener('click', function () { openCheckout(cfg, priceId); });
|
| 98 |
-
return btn;
|
| 99 |
-
}
|
| 100 |
-
|
| 101 |
function mountUpsell(cfg) {
|
| 102 |
-
|
| 103 |
-
// is offered a plan they already hold. Pro is the top tier, so a Pro
|
| 104 |
-
// account sees no upsell at all.
|
| 105 |
-
if (!cfg.user_id || cfg.plan === 'pro') return;
|
| 106 |
var slot = document.getElementById('acctUpsell');
|
| 107 |
if (!slot) return;
|
| 108 |
slot.innerHTML = '';
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
if (cfg.plan !== 'starter' && cfg.starter_price_id) {
|
| 113 |
-
|
| 114 |
-
'acctUpgradeStarter',
|
| 115 |
-
'Upgrade to Starter — ' + (cfg.starter_label || ''),
|
| 116 |
-
cfg.starter_price_id, cfg));
|
| 117 |
}
|
| 118 |
if (cfg.pro_price_id) {
|
| 119 |
-
|
| 120 |
-
'acctUpgrade',
|
| 121 |
-
'Upgrade to Pro — ' + (cfg.pro_label || ''),
|
| 122 |
-
cfg.pro_price_id, cfg));
|
| 123 |
}
|
| 124 |
-
|
| 125 |
-
// public_config), so a price change is a Space restart — the amount is no
|
| 126 |
-
// longer hard-coded here where it could drift from what is charged.
|
| 127 |
-
if (!slot.childNodes.length) return;
|
| 128 |
-
slot.hidden = false;
|
| 129 |
}
|
| 130 |
-
|
| 131 |
-
onReady(function () {
|
| 132 |
fetch('/api/billing/config')
|
| 133 |
.then(function (r) { return r.ok ? r.json() : null; })
|
| 134 |
-
.
|
| 135 |
-
.
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
});
|
| 140 |
-
});
|
| 141 |
})();
|
|
|
|
| 1 |
+
/* The engine is embedded on an unapproved HF origin. Checkout must run on
|
| 2 |
+
* turingdna.com, using that site's own signed-in session. Never put tokens,
|
| 3 |
+
* customer identities or arbitrary price IDs into the navigation URL. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
(function () {
|
| 5 |
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
function mountUpsell(cfg) {
|
| 7 |
+
if (!cfg || !cfg.configured || !cfg.user_id || cfg.plan === 'pro') return;
|
|
|
|
|
|
|
|
|
|
| 8 |
var slot = document.getElementById('acctUpsell');
|
| 9 |
if (!slot) return;
|
| 10 |
slot.innerHTML = '';
|
| 11 |
+
function add(plan, label, id) {
|
| 12 |
+
var link = document.createElement('a');
|
| 13 |
+
link.id = id;
|
| 14 |
+
link.className = 'acct-menu-item acct-upgrade';
|
| 15 |
+
link.textContent = label;
|
| 16 |
+
link.href = 'https://turingdna.com/pricing/?plan=' + plan;
|
| 17 |
+
link.target = '_top';
|
| 18 |
+
slot.appendChild(link);
|
| 19 |
+
}
|
| 20 |
if (cfg.plan !== 'starter' && cfg.starter_price_id) {
|
| 21 |
+
add('starter', 'Upgrade to Starter — ' + (cfg.starter_label || ''), 'acctUpgradeStarter');
|
|
|
|
|
|
|
|
|
|
| 22 |
}
|
| 23 |
if (cfg.pro_price_id) {
|
| 24 |
+
add('pro', 'Upgrade to Pro — ' + (cfg.pro_label || ''), 'acctUpgrade');
|
|
|
|
|
|
|
|
|
|
| 25 |
}
|
| 26 |
+
slot.hidden = !slot.childNodes.length;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
+
function ready() {
|
|
|
|
| 29 |
fetch('/api/billing/config')
|
| 30 |
.then(function (r) { return r.ok ? r.json() : null; })
|
| 31 |
+
.then(mountUpsell)
|
| 32 |
+
.catch(function () { /* The workbench remains usable during a billing outage. */ });
|
| 33 |
+
}
|
| 34 |
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ready);
|
| 35 |
+
else ready();
|
|
|
|
|
|
|
| 36 |
})();
|
dee/static/index.html
CHANGED
|
@@ -178,21 +178,21 @@
|
|
| 178 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 179 |
app.js change. Bump these numbers whenever you ship a frontend update —
|
| 180 |
without them, users keep getting the stale file for up to a week. -->
|
| 181 |
-
<link rel="stylesheet" href="/static/app.css?v=
|
| 182 |
<!-- The work catalog + the draggable rail. Kept out of app.css so two new
|
| 183 |
self-contained surfaces stay reviewable; every colour is an app.css
|
| 184 |
token, so both themes work with nothing added. -->
|
| 185 |
-
<link rel="stylesheet" href="/static/catalog.css?v=
|
| 186 |
<!-- Units, the run strip and the decision trace. Same reasoning as
|
| 187 |
catalog.css: kept out of app.css so a self-contained surface stays
|
| 188 |
reviewable, and every colour is an app.css token so both themes
|
| 189 |
work with nothing added. -->
|
| 190 |
-
<link rel="stylesheet" href="/static/trace.css?v=
|
| 191 |
<!-- Disclosure surfaces added in the 2026-08 scientific review (CRISPR
|
| 192 |
scope strip, provenance "Assumes" line). Separate file for the same
|
| 193 |
reason as catalog.css: small, self-contained, and app.css's cascade
|
| 194 |
is hostile. Uses app.css tokens only, so both themes work. -->
|
| 195 |
-
<link rel="stylesheet" href="/static/science.css?v=
|
| 196 |
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=4" />
|
| 197 |
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png?v=2" />
|
| 198 |
<!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
|
|
@@ -528,8 +528,8 @@
|
|
| 528 |
<!-- Upgrade-to-Pro affordance, filled by billing.js only for
|
| 529 |
a signed-in FREE account when Paddle is configured, so a
|
| 530 |
page with no billing set up (or a Pro user) shows nothing
|
| 531 |
-
here rather than a dead button.
|
| 532 |
-
|
| 533 |
<div class="acct-upsell" id="acctUpsell" hidden></div>
|
| 534 |
<!--
|
| 535 |
What Turing remembers about you, and the controls to
|
|
@@ -3171,44 +3171,40 @@
|
|
| 3171 |
Authorization header on every same-origin /api/* call. Without
|
| 3172 |
this, signed-in users would still hit the anonymous-quota
|
| 3173 |
gate. -->
|
| 3174 |
-
<script src="/static/auth.js?v=
|
| 3175 |
-
<!--
|
| 3176 |
-
|
| 3177 |
-
and, for a signed-in free account, opens the checkout overlay. One CDN
|
| 3178 |
-
script for both sandbox and live; the environment is set at runtime. -->
|
| 3179 |
-
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
|
| 3180 |
-
<script src="/static/billing.js?v=20260915-bench-account" defer></script>
|
| 3181 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 3182 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 3183 |
-
<script src="/static/cloning_db.js?v=
|
| 3184 |
-
<script src="/static/context.js?v=
|
| 3185 |
-
<script src="/static/lineage.js?v=
|
| 3186 |
<!-- Before app.js: it mounts a strip at init. Both are `defer`, so they
|
| 3187 |
run in document order. -->
|
| 3188 |
-
<script src="/static/tabs.js?v=
|
| 3189 |
-
<script src="/static/app.js?v=
|
| 3190 |
<!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
|
| 3191 |
on the very first event, and both are `defer`, so document order is
|
| 3192 |
load order. Loading it after would drop the opening events of a
|
| 3193 |
restored run on the floor — silently, since the push is guarded. -->
|
| 3194 |
-
<script src="/static/trace.js?v=
|
| 3195 |
<!-- THE COCKPIT — the persistent orchestrator rail. Loads after app.js so
|
| 3196 |
TDBench/TDStructure exist when a tool result asks the workspace to
|
| 3197 |
render something. This is the only conversation surface in the app. -->
|
| 3198 |
-
<script src="/static/cockpit.js?v=
|
| 3199 |
<!-- structcard before catalog: the catalog calls TDStructCard.observe as
|
| 3200 |
soon as it paints. Both are defer, so document order is load order. -->
|
| 3201 |
-
<script src="/static/structcard.js?v=
|
| 3202 |
-
<script src="/static/catalog.js?v=
|
| 3203 |
-
<script src="/static/railsplit.js?v=
|
| 3204 |
<!-- Dwell-time heartbeat. Loads after auth.js so its /api/ping calls go
|
| 3205 |
through the JWT-attaching fetch wrapper (signed-in attribution). -->
|
| 3206 |
-
<script src="/static/telemetry.js?v=
|
| 3207 |
<!-- First-party UX-behaviour collector (dead/rage clicks, scroll depth,
|
| 3208 |
funnel steps, feature clicks, client errors). Structure-only, never
|
| 3209 |
reads sequence content; shares telemetry.js's persisted td_session_id
|
| 3210 |
and POSTs batches to /api/collect via sendBeacon. First-party /static
|
| 3211 |
file so it satisfies the strict script-src CSP. -->
|
| 3212 |
-
<script src="/static/collector.js?v=
|
| 3213 |
</body>
|
| 3214 |
</html>
|
|
|
|
| 178 |
<!-- ?v= query bumps invalidate browser + iframe asset caches when app.css /
|
| 179 |
app.js change. Bump these numbers whenever you ship a frontend update —
|
| 180 |
without them, users keep getting the stale file for up to a week. -->
|
| 181 |
+
<link rel="stylesheet" href="/static/app.css?v=20260916-checkout-origin" />
|
| 182 |
<!-- The work catalog + the draggable rail. Kept out of app.css so two new
|
| 183 |
self-contained surfaces stay reviewable; every colour is an app.css
|
| 184 |
token, so both themes work with nothing added. -->
|
| 185 |
+
<link rel="stylesheet" href="/static/catalog.css?v=20260916-checkout-origin" />
|
| 186 |
<!-- Units, the run strip and the decision trace. Same reasoning as
|
| 187 |
catalog.css: kept out of app.css so a self-contained surface stays
|
| 188 |
reviewable, and every colour is an app.css token so both themes
|
| 189 |
work with nothing added. -->
|
| 190 |
+
<link rel="stylesheet" href="/static/trace.css?v=20260916-checkout-origin" />
|
| 191 |
<!-- Disclosure surfaces added in the 2026-08 scientific review (CRISPR
|
| 192 |
scope strip, provenance "Assumes" line). Separate file for the same
|
| 193 |
reason as catalog.css: small, self-contained, and app.css's cascade
|
| 194 |
is hostile. Uses app.css tokens only, so both themes work. -->
|
| 195 |
+
<link rel="stylesheet" href="/static/science.css?v=20260916-checkout-origin" />
|
| 196 |
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=4" />
|
| 197 |
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png?v=2" />
|
| 198 |
<!-- Mol* (PDBe) 3-D viewer is ~4.9 MB. We do NOT eager-load it on every
|
|
|
|
| 528 |
<!-- Upgrade-to-Pro affordance, filled by billing.js only for
|
| 529 |
a signed-in FREE account when Paddle is configured, so a
|
| 530 |
page with no billing set up (or a Pro user) shows nothing
|
| 531 |
+
here rather than a dead button. Links open checkout on the
|
| 532 |
+
approved website origin; the server never sees a card. -->
|
| 533 |
<div class="acct-upsell" id="acctUpsell" hidden></div>
|
| 534 |
<!--
|
| 535 |
What Turing remembers about you, and the controls to
|
|
|
|
| 3171 |
Authorization header on every same-origin /api/* call. Without
|
| 3172 |
this, signed-in users would still hit the anonymous-quota
|
| 3173 |
gate. -->
|
| 3174 |
+
<script src="/static/auth.js?v=20260916-checkout-origin"></script>
|
| 3175 |
+
<!-- Checkout runs on the approved turingdna.com origin, outside this iframe. -->
|
| 3176 |
+
<script src="/static/billing.js?v=20260916-checkout-origin" defer></script>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3177 |
<!-- Cloning reference data must load before app.js so the Designer
|
| 3178 |
can read VECTORS / ENZYMES / CLONING_METHODS / TAGS / LINKERS. -->
|
| 3179 |
+
<script src="/static/cloning_db.js?v=20260916-checkout-origin" defer></script>
|
| 3180 |
+
<script src="/static/context.js?v=20260916-checkout-origin" defer></script>
|
| 3181 |
+
<script src="/static/lineage.js?v=20260916-checkout-origin" defer></script>
|
| 3182 |
<!-- Before app.js: it mounts a strip at init. Both are `defer`, so they
|
| 3183 |
run in document order. -->
|
| 3184 |
+
<script src="/static/tabs.js?v=20260916-checkout-origin" defer></script>
|
| 3185 |
+
<script src="/static/app.js?v=20260916-checkout-origin" defer></script>
|
| 3186 |
<!-- The decision trace, BEFORE cockpit.js: applyEvent calls TDTrace.push
|
| 3187 |
on the very first event, and both are `defer`, so document order is
|
| 3188 |
load order. Loading it after would drop the opening events of a
|
| 3189 |
restored run on the floor — silently, since the push is guarded. -->
|
| 3190 |
+
<script src="/static/trace.js?v=20260916-checkout-origin" defer></script>
|
| 3191 |
<!-- THE COCKPIT — the persistent orchestrator rail. Loads after app.js so
|
| 3192 |
TDBench/TDStructure exist when a tool result asks the workspace to
|
| 3193 |
render something. This is the only conversation surface in the app. -->
|
| 3194 |
+
<script src="/static/cockpit.js?v=20260916-checkout-origin" defer></script>
|
| 3195 |
<!-- structcard before catalog: the catalog calls TDStructCard.observe as
|
| 3196 |
soon as it paints. Both are defer, so document order is load order. -->
|
| 3197 |
+
<script src="/static/structcard.js?v=20260916-checkout-origin" defer></script>
|
| 3198 |
+
<script src="/static/catalog.js?v=20260916-checkout-origin" defer></script>
|
| 3199 |
+
<script src="/static/railsplit.js?v=20260916-checkout-origin" defer></script>
|
| 3200 |
<!-- Dwell-time heartbeat. Loads after auth.js so its /api/ping calls go
|
| 3201 |
through the JWT-attaching fetch wrapper (signed-in attribution). -->
|
| 3202 |
+
<script src="/static/telemetry.js?v=20260916-checkout-origin" defer></script>
|
| 3203 |
<!-- First-party UX-behaviour collector (dead/rage clicks, scroll depth,
|
| 3204 |
funnel steps, feature clicks, client errors). Structure-only, never
|
| 3205 |
reads sequence content; shares telemetry.js's persisted td_session_id
|
| 3206 |
and POSTs batches to /api/collect via sendBeacon. First-party /static
|
| 3207 |
file so it satisfies the strict script-src CSP. -->
|
| 3208 |
+
<script src="/static/collector.js?v=20260916-checkout-origin" defer></script>
|
| 3209 |
</body>
|
| 3210 |
</html>
|
tests/test_billing_handoff.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exercise the actual engine script: upgrades must leave the HF iframe."""
|
| 2 |
+
import subprocess
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_engine_links_use_only_plan_names_and_the_approved_origin():
|
| 7 |
+
script = r"""
|
| 8 |
+
const fs = require('node:fs'), vm = require('node:vm'), assert = require('node:assert/strict');
|
| 9 |
+
const source = fs.readFileSync('dee/static/billing.js', 'utf8');
|
| 10 |
+
async function check(plan, configured, expected) {
|
| 11 |
+
const slot = {childNodes: [], appendChild(child) {this.childNodes.push(child);}};
|
| 12 |
+
const config = {configured, user_id: 'private-user', email: 'private-email', plan,
|
| 13 |
+
client_token: 'public-token', starter_price_id: 'pri_starter', pro_price_id: 'pri_pro'};
|
| 14 |
+
vm.runInNewContext(source, {
|
| 15 |
+
document: {readyState: 'complete', getElementById: () => slot, createElement: tag => ({tag})},
|
| 16 |
+
fetch: async () => ({ok: true, json: async () => config})
|
| 17 |
+
});
|
| 18 |
+
await new Promise(resolve => setImmediate(resolve));
|
| 19 |
+
assert.equal(slot.childNodes.length, expected.length);
|
| 20 |
+
slot.childNodes.forEach((a, i) => {
|
| 21 |
+
assert.equal(a.tag, 'a');
|
| 22 |
+
assert.equal(a.href, 'https://turingdna.com/pricing/?plan=' + expected[i]);
|
| 23 |
+
assert.equal(a.target, '_top');
|
| 24 |
+
assert.equal(a.href.includes(config.user_id), false);
|
| 25 |
+
});
|
| 26 |
+
}
|
| 27 |
+
(async () => {await check('free', true, ['starter', 'pro']);
|
| 28 |
+
await check('starter', true, ['pro']); await check('pro', true, []);
|
| 29 |
+
await check('free', false, []);})().catch(e => {console.error(e);process.exitCode=1;});
|
| 30 |
+
"""
|
| 31 |
+
subprocess.run(['node', '-e', script], check=True, cwd=Path(__file__).resolve().parents[1])
|
tests/test_paddle.py
CHANGED
|
@@ -305,21 +305,60 @@ def _read(rel):
|
|
| 305 |
return fh.read()
|
| 306 |
|
| 307 |
|
| 308 |
-
def
|
| 309 |
html = _read("dee/static/index.html")
|
| 310 |
-
assert "cdn.paddle.com/paddle/v2/paddle.js" in html
|
| 311 |
assert "/static/billing.js" in html
|
| 312 |
assert 'id="acctUpsell"' in html
|
| 313 |
|
| 314 |
|
| 315 |
def test_billing_js_never_breaks_the_workbench():
|
| 316 |
js = _read("dee/static/billing.js")
|
| 317 |
-
#
|
| 318 |
-
|
| 319 |
-
assert "
|
|
|
|
|
|
|
|
|
|
| 320 |
|
| 321 |
|
| 322 |
def test_the_migration_adds_the_billing_columns():
|
| 323 |
sql = _read("supabase/migrations/0025_paddle_billing.sql")
|
| 324 |
assert "add column if not exists paddle_customer_id" in sql
|
| 325 |
assert "add column if not exists paddle_subscription_id" in sql
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
return fh.read()
|
| 306 |
|
| 307 |
|
| 308 |
+
def test_the_engine_loads_the_handoff_without_paddle():
|
| 309 |
html = _read("dee/static/index.html")
|
| 310 |
+
assert "cdn.paddle.com/paddle/v2/paddle.js" not in html
|
| 311 |
assert "/static/billing.js" in html
|
| 312 |
assert 'id="acctUpsell"' in html
|
| 313 |
|
| 314 |
|
| 315 |
def test_billing_js_never_breaks_the_workbench():
|
| 316 |
js = _read("dee/static/billing.js")
|
| 317 |
+
# HF is not an approved checkout origin. Its only billing action is a
|
| 318 |
+
# top-level navigation; the site independently verifies the account.
|
| 319 |
+
assert "!cfg.configured" in js
|
| 320 |
+
assert "Paddle.Checkout.open" not in js
|
| 321 |
+
assert "https://turingdna.com/pricing/?plan=" in js
|
| 322 |
+
assert "link.target = '_top'" in js
|
| 323 |
|
| 324 |
|
| 325 |
def test_the_migration_adds_the_billing_columns():
|
| 326 |
sql = _read("supabase/migrations/0025_paddle_billing.sql")
|
| 327 |
assert "add column if not exists paddle_customer_id" in sql
|
| 328 |
assert "add column if not exists paddle_subscription_id" in sql
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
@pytest.mark.parametrize("origin", ["https://turingdna.com", "https://www.turingdna.com"])
|
| 332 |
+
def test_checkout_config_supports_authenticated_site_preflight(client, monkeypatch, origin):
|
| 333 |
+
from dee import server
|
| 334 |
+
monkeypatch.setattr(server._auth, "get_auth", lambda: pytest.fail("preflight must not require auth"))
|
| 335 |
+
r = client.options("/api/billing/config", headers={
|
| 336 |
+
"Origin": origin, "Access-Control-Request-Method": "GET",
|
| 337 |
+
"Access-Control-Request-Headers": "authorization"})
|
| 338 |
+
assert r.status_code == 204
|
| 339 |
+
assert r.headers["Access-Control-Allow-Origin"] == origin
|
| 340 |
+
assert r.headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
|
| 341 |
+
assert r.headers["Access-Control-Allow-Headers"] == "authorization"
|
| 342 |
+
assert "Access-Control-Allow-Credentials" not in r.headers
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
def test_checkout_config_uses_verified_account_and_cannot_be_cached(client, monkeypatch):
|
| 346 |
+
from types import SimpleNamespace
|
| 347 |
+
from dee import server
|
| 348 |
+
monkeypatch.setattr(server._auth, "get_auth", lambda: SimpleNamespace(
|
| 349 |
+
anonymous=False, user_id=_UUID, email="buyer@example.com"))
|
| 350 |
+
monkeypatch.setattr(paddle, "_profile_by", lambda field, value: {"plan": "starter"})
|
| 351 |
+
r = client.get("/api/billing/config?user_id=attacker", headers={"Origin": "https://turingdna.com"})
|
| 352 |
+
assert r.headers["Access-Control-Allow-Origin"] == "https://turingdna.com"
|
| 353 |
+
assert r.headers["Cache-Control"] == "no-store"
|
| 354 |
+
assert "Origin" in r.vary
|
| 355 |
+
assert r.json["user_id"] == _UUID
|
| 356 |
+
assert r.json["plan"] == "starter"
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
@pytest.mark.parametrize("origin", ["https://evil.example", "https://turingdna.com.evil.example",
|
| 360 |
+
"https://unapproved.turingdna.com", "http://turingdna.com"])
|
| 361 |
+
def test_checkout_config_does_not_grant_arbitrary_origins(client, origin):
|
| 362 |
+
for method in (client.get, client.options):
|
| 363 |
+
r = method("/api/billing/config", headers={"Origin": origin})
|
| 364 |
+
assert "Access-Control-Allow-Origin" not in r.headers
|