github-actions[bot] commited on
Commit
850ea8b
·
1 Parent(s): f72cbca

Deploy 4a2edb8

Browse files

Route Paddle checkout out of the HF iframe to the approved website (#101)

Source: https://github.com/WINTER4000/turingDNA/commit/4a2edb88a32933dd8247beb7953e59105ceb8da4

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 in the browser (dee/static/billing.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 dee/static/billing.js needs to render the upsell and open checkout.
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 billing.js at 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.
 
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.get("/api/billing/config")
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
- /* billing.js — the upgrade upsell and Paddle.js checkout overlay.
2
- *
3
- * The server never sees a card. This asks /api/billing/config what to render,
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
- // Anonymous visitors and unconfigured deployments get nothing, and nobody
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
- // Starter is offered only to a free account, and only where a Starter
110
- // price is actually configured — a deployment selling Pro alone must
111
- // render exactly what it rendered before this existed.
 
 
 
 
 
 
112
  if (cfg.plan !== 'starter' && cfg.starter_price_id) {
113
- slot.appendChild(upgradeButton(
114
- 'acctUpgradeStarter',
115
- 'Upgrade to Starter — ' + (cfg.starter_label || ''),
116
- cfg.starter_price_id, cfg));
117
  }
118
  if (cfg.pro_price_id) {
119
- slot.appendChild(upgradeButton(
120
- 'acctUpgrade',
121
- 'Upgrade to Pro — ' + (cfg.pro_label || ''),
122
- cfg.pro_price_id, cfg));
123
  }
124
- // Labels and price ids both come from the server (dee/core/paddle
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
- .catch(function () { return null; })
135
- .then(function (cfg) {
136
- if (!cfg || !cfg.configured) return; // billing not set up on this deploy
137
- if (!initPaddle(cfg)) return; // Paddle.js missing/blocked
138
- mountUpsell(cfg);
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=20260915-bench-account" />
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=20260915-bench-account" />
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=20260915-bench-account" />
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=20260915-bench-account" />
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. Checkout is a Paddle.js
532
- overlay; 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,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=20260915-bench-account"></script>
3175
- <!-- Paddle.js (Merchant-of-Record checkout). Loads from cdn.paddle.com,
3176
- named in the CSP script-src. billing.js below reads /api/billing/config
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=20260915-bench-account" defer></script>
3184
- <script src="/static/context.js?v=20260915-bench-account" defer></script>
3185
- <script src="/static/lineage.js?v=20260915-bench-account" defer></script>
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=20260915-bench-account" defer></script>
3189
- <script src="/static/app.js?v=20260915-bench-account" defer></script>
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=20260915-bench-account" defer></script>
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=20260915-bench-account" defer></script>
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=20260915-bench-account" defer></script>
3202
- <script src="/static/catalog.js?v=20260915-bench-account" defer></script>
3203
- <script src="/static/railsplit.js?v=20260915-bench-account" defer></script>
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=20260915-bench-account" defer></script>
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=20260915-bench-account" defer></script>
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 test_the_page_loads_paddle_and_the_upsell_slot():
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
- # Guards on the two things that can be absent: config and Paddle.js itself.
318
- assert "!cfg.configured" in js and "window.Paddle" in js
319
- assert "Paddle.Checkout.open" in js
 
 
 
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