diff --git a/content/terraphim-llm-proxy/_index.md b/content/terraphim-llm-proxy/_index.md index 9cea2a6..bb606e1 100644 --- a/content/terraphim-llm-proxy/_index.md +++ b/content/terraphim-llm-proxy/_index.md @@ -45,8 +45,32 @@ flagship quality — so the app pays for itself out of saved tokens. Every listener is loopback-only; nothing leaves your machine except the requests you send to your providers. +
+
+ + + + Downloads + +
+

+

One-time purchase · macOS · Refund policy

+
+ + + + ## Links +- [Downloads](/terraphim-llm-proxy/download/) - [Privacy policy](/terraphim-llm-proxy/privacy/) - [Refund policy](/terraphim-llm-proxy/refunds/) - [Support — GitHub issues](https://github.com/terraphim/terraphim-ai/issues) diff --git a/content/terraphim-llm-proxy/download.md b/content/terraphim-llm-proxy/download.md new file mode 100644 index 0000000..289ea21 --- /dev/null +++ b/content/terraphim-llm-proxy/download.md @@ -0,0 +1,49 @@ ++++ +title = "Terraphim Proxy — Downloads" +description = "Signed macOS releases of Terraphim Proxy for licensed customers." +weight = 40 +template = "page.html" +render = true ++++ + +Signed macOS builds of Terraphim Proxy, served from the Terraphim Skills +marketplace. Downloads are tied to the account you purchased with — sign +in below with that account, then pick a release. Every artefact is listed +with its SHA-256 so you can verify the download before opening it. + +
+ +
+
+

Loading releases…

+
+
+ + + diff --git a/content/terraphim-llm-proxy/privacy.md b/content/terraphim-llm-proxy/privacy.md index 5962bdb..e320576 100644 --- a/content/terraphim-llm-proxy/privacy.md +++ b/content/terraphim-llm-proxy/privacy.md @@ -1,7 +1,7 @@ +++ title = "Terraphim Proxy — Privacy Policy" description = "Privacy policy for the Terraphim Proxy desktop application (cloud.terraphim.desktop)" -date = 2026-10-22 +date = 2026-10-24 weight = 10 [taxonomies] @@ -11,16 +11,21 @@ categories = ["Legal"] toc = false +++ -**Effective date: 22 October 2026.** Applies to the Terraphim Proxy macOS +**Effective date: 24 October 2026.** Applies to the Terraphim Proxy macOS application (`cloud.terraphim.desktop`), distributed via the Mac App Store and direct download. ## The short version -Terraphim Proxy collects **no data whatsoever**. There is no account system, -no telemetry, no analytics, no crash reporting, and no third-party -tracking SDK. Your credentials never leave your Mac except inside the -requests you deliberately send to the LLM providers you configured. +The Terraphim Proxy **application** collects **no data whatsoever**. There +is no account system, no telemetry, no analytics, no crash reporting, and +no third-party tracking SDK in the app. Your credentials never leave your +Mac except inside the requests you deliberately send to the LLM providers +you configured. + +Buying a direct download does create a small server-side record — kept by +the Terraphim Skills marketplace, not by the app — described under +*Purchases* below. ## What stays on your device @@ -47,10 +52,21 @@ OpenAI, xAI, or any other provider you configure). ## Purchases -The app is a one-time purchase processed entirely by Apple's App Store. -Apple — not Terraphim Proxy — receives and processes your payment -information. The app contains no in-app purchases, no subscription -checks and no licence servers. +**Mac App Store.** The app is a one-time purchase processed entirely by +Apple's App Store. Apple — not Terraphim Proxy — receives and processes +your payment information. The app contains no in-app purchases, no +subscription checks and no licence servers. + +**Direct download.** Direct-download purchases are processed by Stripe +via the Terraphim Skills marketplace (`api.terraphim-skills.md`). Stripe +— not Terraphim — receives and processes your payment information. The +marketplace stores the email address on your account, the fact of the +purchase, and which release downloads it has served to your account; +those records are used solely for entitlement checks and refund +adjudication, as described in +the [Refund policy](/terraphim-llm-proxy/refunds/). The application +itself still collects nothing — this record lives entirely on the +marketplace side. ## Permissions the app requests diff --git a/content/terraphim-llm-proxy/refunds.md b/content/terraphim-llm-proxy/refunds.md index 15da1ec..49c6687 100644 --- a/content/terraphim-llm-proxy/refunds.md +++ b/content/terraphim-llm-proxy/refunds.md @@ -6,7 +6,7 @@ template = "page.html" render = true +++ -**Last updated:** [CONFIRM: set to the date direct-download sales open] +**Effective upon launch of direct-download sales.** This policy covers **direct downloads purchased on terraphim.ai**. Purchases made through the Mac App Store are refunded by Apple under the @@ -15,22 +15,23 @@ not Terraphim, decides those refunds. ## The short version -**You can refund any time before you download a release. Once your -account has downloaded a release, the sale is final — unless the proxy -does not function.** +**Within 14 days of purchase you can get a full refund provided no +release has been downloaded. Once your account has downloaded a release, +the sale is final — unless the proxy does not function.** This is the same shape as Steam's rule for games: a refund is available while the product is untouched, and the usage measurement is what makes -the rule fair to enforce. Steam measures playtime; we measure the -download event. +the rule fair to enforce. Steam measures playtime; we measure the moment +our service first serves the download. ## Why we do it this way A signed application bundle cannot be returned once it is copied to your Mac — there is no undo. The download is the honest line between "bought but not taken" and "taken", and it is a line we can measure: the -download service records which releases your account has downloaded, and -that record is the only thing the refund rule consults. +service records the first time it serves a release download request to +your account, and that record is the only thing the refund rule +consults. We considered the alternative — a blanket 14-day window — and rejected it, because it would let anyone buy, download every release, and refund. @@ -46,6 +47,12 @@ else. | **Any release downloaded**, proxy does not function | Full refund — see *When the proxy does not function* below | | More than 14 days after purchase, no download | Not automatic. Ask anyway — see *Outside the rules* below. | +*"Downloaded" is counted when our service begins delivering the release +to your account — the request is recorded when delivery starts, not when +the bytes finish. A failed or interrupted delivery does not count as a +completed download for refund purposes (see *Downloads were broken* +below), and where the record is ambiguous we treat it in your favour.* + Refunds go back to the original payment method through Stripe. Card refunds usually appear within 5–10 business days, depending on your bank — that part is outside our control. @@ -79,7 +86,7 @@ Email **[engineering@terraphim.ai](mailto:engineering@terraphim.ai)** with the email address on the account you purchased with. For a defect claim, include the app version and macOS version. -We aim to process refund requests within **[CONFIRM: 3 business days]**. +We aim to process refund requests within **3 business days**. ## Outside the rules @@ -96,9 +103,10 @@ is not closed. We will look seriously at: ## What we record To make this policy enforceable we keep a server-side record, tied to -your purchase account, of **which releases you downloaded and when**. -That is the entire record — the app itself collects no telemetry and -reports nothing back. Purchase and download records are covered in the +your purchase account, of **when our service first serves a release +download request to you** — the version, the asset and the time. That is +the entire record — the app itself collects no telemetry and reports +nothing back. Purchase and download records are covered in the [Privacy policy](/terraphim-llm-proxy/privacy/). ## Your statutory rights diff --git a/static/js/proxy-buy.js b/static/js/proxy-buy.js new file mode 100644 index 0000000..ac7d73e --- /dev/null +++ b/static/js/proxy-buy.js @@ -0,0 +1,81 @@ +/* Terraphim Proxy — buy button. + Wires #proxy-buy-btn on /terraphim-llm-proxy/ to the Terraphim Skills + marketplace checkout. Contract: GET /api/billing/checkout?product=… + with a Bearer JWT returns {"url": ""}. + + Sign-in is a round trip: the link carries ?return_to=, the + OAuth callback lands back here with the JWT in the fragment + (#token=), which captureToken() below moves into localStorage and + strips from the address bar. Requires /js/proxy-helpers.js loaded first. */ +(function () { + 'use strict'; + var H = window.ProxyHelpers; + var btn = document.getElementById('proxy-buy-btn'); + var status = document.getElementById('proxy-buy-status'); + if (!H || !btn || !status) { return; } + + /* Move a JWT handed back in the URL fragment (or, forward-compat, the + query) into localStorage, then scrub it from the address bar and + history — a JWT must never linger in either. */ + function captureToken() { + var tok = H.tokenFromLocation(window.location); + if (!tok) { return; } + try { localStorage.setItem('terraphim_token', tok); } catch (e) { return; } + try { + var q = new URLSearchParams(window.location.search); + q.delete('token'); + var qs = q.toString(); + var hash = H.stripTokenFromHash(window.location.hash); + var clean = window.location.origin + window.location.pathname + + (qs ? '?' + qs : '') + (hash ? '#' + hash : ''); + history.replaceState(null, '', clean); + } catch (e) { /* replaceState unavailable — token is stored anyway */ } + } + + function getToken() { + try { return localStorage.getItem('terraphim_token'); } catch (e) { return null; } + } + + function signInHref() { + var back = window.location.origin + window.location.pathname + window.location.search; + return H.signInUrl(back); + } + + function showSignIn(note) { + status.innerHTML = note + + ' Sign in with GitHub, then try again.'; + } + + captureToken(); + + btn.addEventListener('click', function () { + var token = getToken(); + if (!token) { + showSignIn('Purchasing needs a Terraphim account.'); + return; + } + btn.disabled = true; + status.textContent = 'Preparing checkout…'; + fetch(H.API + '/api/billing/checkout?product=terraphim-proxy', { + headers: { 'Authorization': 'Bearer ' + token } + }) + .then(function (res) { + if (res.status === 401) { + showSignIn('Your session has expired —'); + btn.disabled = false; + return null; + } + if (!res.ok) { throw new Error('checkout ' + res.status); } + return res.json(); + }) + .then(function (data) { + if (!data) { return; } + if (data.url) { window.location.href = data.url; return; } + throw new Error('checkout returned no url'); + }) + .catch(function () { + status.textContent = 'Checkout is unavailable right now — please try again later.'; + btn.disabled = false; + }); + }); +})(); diff --git a/static/js/proxy-download.js b/static/js/proxy-download.js new file mode 100644 index 0000000..66e1d4d --- /dev/null +++ b/static/js/proxy-download.js @@ -0,0 +1,195 @@ +/* Terraphim Proxy — entitlement-gated release downloads. + Renders the public release catalogue on /terraphim-llm-proxy/download/ + and gates artefact fetches behind a Bearer JWT; 402 means the account + has not purchased. Contract (api.terraphim-skills.md): + GET /api/products/terraphim-proxy/releases + -> [{version, asset_name, size_bytes, sha256, published_at}] + (a wrapped {"releases": [...]} shape is tolerated) + GET /api/products/terraphim-proxy/releases/{version}/{asset} + -> artefact bytes + X-SHA256 header; 402 {"error":"purchase_required"}. + + Sign-in is a round trip: links carry ?return_to=, the OAuth + callback lands back here with the JWT in the fragment (#token=), + which captureToken() moves into localStorage and strips from the + address bar. Requires /js/proxy-helpers.js loaded first. */ +(function () { + 'use strict'; + var H = window.ProxyHelpers; + var banner = document.getElementById('proxy-dl-banner'); + var notice = document.getElementById('proxy-dl-notice'); + var wrap = document.getElementById('proxy-dl-wrap'); + if (!H || !notice || !wrap) { return; } + + var BUY_URL = '/terraphim-llm-proxy/#your-keys-your-costs'; + + /* Move a JWT handed back in the URL fragment (or, forward-compat, the + query) into localStorage, then scrub it from the address bar and + history — a JWT must never linger in either. */ + function captureToken() { + var tok = H.tokenFromLocation(window.location); + if (!tok) { return; } + try { localStorage.setItem('terraphim_token', tok); } catch (e) { return; } + try { + var q = new URLSearchParams(window.location.search); + q.delete('token'); + var qs = q.toString(); + var hash = H.stripTokenFromHash(window.location.hash); + var clean = window.location.origin + window.location.pathname + + (qs ? '?' + qs : '') + (hash ? '#' + hash : ''); + history.replaceState(null, '', clean); + } catch (e) { /* replaceState unavailable — token is stored anyway */ } + } + + function getToken() { + try { return localStorage.getItem('terraphim_token'); } catch (e) { return null; } + } + + function signInHref() { + var back = window.location.origin + window.location.pathname + window.location.search; + return H.signInUrl(back); + } + + captureToken(); + + /* ?checkout=success comes back from Stripe. Only claim the purchase is + confirmed when a token is present — anyone can forge the parameter, + so signed-out visitors get neutral wording instead. */ + try { + if (new URLSearchParams(window.location.search).get('checkout') === 'success' && banner) { + if (!getToken()) { + banner.innerHTML = + ' ' + + 'Checkout completed — sign in below to confirm download access.'; + } + banner.hidden = false; + } + } catch (e) { /* malformed query — ignore */ } + + function signInPrompt() { + notice.innerHTML = + '

' + + 'Sign in with GitHub using the account you purchased ' + + 'with to download releases. No licence yet? ' + + 'Buy Terraphim Proxy — $15.

'; + } + + function purchaseRequired() { + notice.innerHTML = + '

' + + 'Purchase required — this account has not bought Terraphim Proxy. ' + + 'Buy a licence to unlock downloads, or ' + + 'sign in with a different account.

'; + } + + if (!getToken()) { signInPrompt(); } + + fetch(H.API + '/api/products/terraphim-proxy/releases', { + headers: { 'Accept': 'application/json' } + }) + .then(function (res) { + if (!res.ok) { throw new Error('releases ' + res.status); } + return res.json(); + }) + .then(function (data) { + var releases = H.normaliseReleases(data); + if (!releases.length) { + wrap.innerHTML = '

No releases are published yet.

'; + return; + } + renderTable(releases); + }) + .catch(function () { + wrap.innerHTML = + '

Releases are unavailable right now — please try again later.

'; + }); + + function renderTable(releases) { + var hasToken = !!getToken(); + var rows = []; + for (var i = 0; i < releases.length; i++) { + var r = releases[i] || {}; + var sha = r.sha256 || ''; + var shaCell = sha + ? '' + H.esc(sha.slice(0, 12)) + '…' + : '—'; + var action = hasToken + ? '' + : '' + + ' Sign in'; + rows.push( + '' + + '' + H.esc(r.version || '—') + '' + + '' + H.esc(r.asset_name || '—') + '' + + '' + H.esc(H.fmtDate(r.published_at)) + '' + + '' + H.esc(H.humanSize(r.size_bytes)) + '' + + '' + shaCell + '' + + '' + action + '
' + + '' + ); + } + wrap.innerHTML = + '' + + '' + + '' + rows.join('') + '
VersionAssetReleasedSizeSHA-256
'; + var btns = wrap.querySelectorAll('button[data-i]'); + for (var j = 0; j < btns.length; j++) { + (function (btn) { + btn.addEventListener('click', function () { + downloadRelease(releases[Number(btn.getAttribute('data-i'))], btn); + }); + })(btns[j]); + } + } + + function downloadRelease(rel, btn) { + var token = getToken(); + var rs = btn.parentNode.querySelector('.proxy-dl-row-status'); + if (!token) { signInPrompt(); return; } + btn.disabled = true; + rs.innerHTML = + ' downloading…'; + fetch(H.downloadUrl(rel.version, rel.asset_name), { + headers: { 'Authorization': 'Bearer ' + token } + }) + .then(function (res) { + if (res.status === 401) { + rs.innerHTML = ''; + signInPrompt(); + throw { handled: true }; + } + if (res.status === 402) { + rs.innerHTML = ''; + purchaseRequired(); + throw { handled: true }; + } + if (!res.ok) { throw new Error('download ' + res.status); } + var sha = res.headers.get('X-SHA256'); + return res.blob().then(function (blob) { return { blob: blob, sha: sha }; }); + }) + .then(function (result) { + if (!result) { return; } + var objectUrl = URL.createObjectURL(result.blob); + var a = document.createElement('a'); + a.href = objectUrl; + a.download = rel.asset_name || 'terraphim-proxy.dmg'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function () { URL.revokeObjectURL(objectUrl); }, 5000); + rs.innerHTML = result.sha + ? ' ' + + 'Saved. SHA-256: ' + H.esc(result.sha) + ' ' + + '— verify with shasum -a 256' + : ' ' + + 'Saved ' + H.esc(rel.asset_name || 'artefact') + '.'; + }) + .catch(function (err) { + if (err && err.handled) { return; } + rs.innerHTML = + ' ' + + 'Download failed — please try again.'; + }) + .finally(function () { btn.disabled = false; }); + } +})(); diff --git a/static/js/proxy-helpers.js b/static/js/proxy-helpers.js new file mode 100644 index 0000000..b6559d5 --- /dev/null +++ b/static/js/proxy-helpers.js @@ -0,0 +1,92 @@ +/* Terraphim Proxy — pure helpers shared by the buy and download pages. + UMD-style export: attaches to window.ProxyHelpers in the browser and + exports the same object via module.exports so `node --test` can load it + without a DOM or any npm dependencies. */ +(function (root, factory) { + var api = factory(); + if (typeof module !== 'undefined' && module.exports) { module.exports = api; } + root.ProxyHelpers = api; +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + var API = 'https://api.terraphim-skills.md'; + + /* HTML-escape a value for interpolation into element markup or an + attribute — quotes included so it is safe inside title="…" too. */ + function esc(s) { + return String(s === null || s === undefined ? '' : s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + } + + /* Human-readable byte count: 16457327 -> "15.7 MB". */ + function humanSize(bytes) { + var n = Number(bytes); + if (!isFinite(n) || n < 0) { return '—'; } + var units = ['B', 'KB', 'MB', 'GB']; + var i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return (i === 0 ? String(n) : n.toFixed(1)) + ' ' + units[i]; + } + + /* en-GB short date for an ISO timestamp; invalid input passes through + so a surprising-but-non-empty server value is shown rather than lost. */ + function fmtDate(iso) { + var d = new Date(iso); + if (isNaN(d.getTime())) { return iso || '—'; } + return d.toLocaleDateString('en-GB', { year: 'numeric', month: 'short', day: 'numeric' }); + } + + /* Accept both the bare array and the {"releases": [...]} wrapped shape. */ + function normaliseReleases(data) { + if (Array.isArray(data)) { return data; } + if (data && Array.isArray(data.releases)) { return data.releases; } + return []; + } + + /* Bearer-gated artefact endpoint for one release asset. */ + function downloadUrl(version, asset) { + return API + '/api/products/terraphim-proxy/releases/' + + encodeURIComponent(version) + '/' + encodeURIComponent(asset); + } + + /* GitHub OAuth entry point carrying an absolute return_to so the token + is handed back to this origin (server-side allowlist includes + https://terraphim.ai). */ + function signInUrl(returnTo) { + return API + '/auth/oauth/github?return_to=' + encodeURIComponent(returnTo); + } + + /* Extract the JWT the OAuth callback hands back in the fragment + (#token=, possibly alongside other params) or, forward-compat, + in the query (?token=). `loc` is a {hash, search} pair — pass + window.location at runtime, plain strings in tests. Hash wins. */ + function tokenFromLocation(loc) { + var hash = (loc && loc.hash) || ''; + var hm = hash.match(/(?:^#|[#&])token=([^&]+)/); + if (hm) { return decodeURIComponent(hm[1]); } + var search = (loc && loc.search) || ''; + var qm = search.match(/[?&]token=([^&]+)/); + if (qm) { return decodeURIComponent(qm[1]); } + return null; + } + + /* Remove only the token= param from a location.hash, preserving other + params and plain anchors. Returns the bare fragment (no leading #). */ + function stripTokenFromHash(hash) { + var h = (hash || '').replace(/(^#|&)token=[^&]*/g, '$1'); + return h.replace(/^[#&]+/, '').replace(/&+$/, ''); + } + + return { + API: API, + esc: esc, + humanSize: humanSize, + fmtDate: fmtDate, + normaliseReleases: normaliseReleases, + downloadUrl: downloadUrl, + signInUrl: signInUrl, + tokenFromLocation: tokenFromLocation, + stripTokenFromHash: stripTokenFromHash + }; +}); diff --git a/tests/js/proxy-helpers.test.js b/tests/js/proxy-helpers.test.js new file mode 100644 index 0000000..76b94bd --- /dev/null +++ b/tests/js/proxy-helpers.test.js @@ -0,0 +1,127 @@ +/* Tests for the pure helpers shared by the Terraphim Proxy buy/download + pages (static/js/proxy-helpers.js). Node built-ins only — no npm deps, + no DOM mocks: the helpers are pure functions over strings/values. */ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const H = require('../../static/js/proxy-helpers.js'); + +test('esc neutralises a script-tag payload', () => { + const out = H.esc(''); + assert.equal(out, '<script>alert("x")</script>'); + assert.ok(!out.includes('<') && !out.includes('>')); +}); + +test('esc neutralises attribute-injection payloads', () => { + const out = H.esc('">'); + assert.equal(out, '"><img src=x onerror=alert(1)>'); + // Safe inside a double-quoted title attribute. + assert.ok(!out.includes('"') && !out.includes('<')); +}); + +test('esc handles apostrophes, ampersands and non-strings', () => { + assert.equal(H.esc("o'neil & "), 'o'neil & <b>'); + assert.equal(H.esc(42), '42'); + assert.equal(H.esc(null), ''); + assert.equal(H.esc(undefined), ''); + // Ampersand must be escaped first — already-escaped input stays safe. + assert.equal(H.esc('<'), '&lt;'); +}); + +test('normaliseReleases accepts the bare array shape', () => { + const list = [{ version: '0.1.45' }]; + assert.equal(H.normaliseReleases(list), list); +}); + +test('normaliseReleases accepts the wrapped {releases: [...]} shape', () => { + const list = [{ version: '0.1.45' }]; + assert.deepEqual(H.normaliseReleases({ releases: list }), list); +}); + +test('normaliseReleases returns [] for every other shape', () => { + assert.deepEqual(H.normaliseReleases(null), []); + assert.deepEqual(H.normaliseReleases(undefined), []); + assert.deepEqual(H.normaliseReleases({}), []); + assert.deepEqual(H.normaliseReleases({ releases: 'nope' }), []); + assert.deepEqual(H.normaliseReleases('[]'), []); +}); + +test('downloadUrl encodes version and asset path segments', () => { + assert.equal( + H.downloadUrl('0.1.45', 'Terraphim-Proxy-0.1.45-arm64.dmg'), + 'https://api.terraphim-skills.md/api/products/terraphim-proxy/releases/0.1.45/Terraphim-Proxy-0.1.45-arm64.dmg' + ); + // A version containing '+' or '/' must not corrupt the path. + const url = H.downloadUrl('1.0.0-beta+exp', 'a/b.dmg'); + assert.ok(url.endsWith('/releases/1.0.0-beta%2Bexp/a%2Fb.dmg')); +}); + +test('signInUrl carries an encoded absolute return_to', () => { + const back = 'https://terraphim.ai/terraphim-llm-proxy/download/?checkout=success'; + const url = H.signInUrl(back); + assert.equal( + url, + 'https://api.terraphim-skills.md/auth/oauth/github?return_to=' + + encodeURIComponent(back) + ); +}); + +test('humanSize formats byte counts', () => { + assert.equal(H.humanSize(0), '0 B'); + assert.equal(H.humanSize(512), '512 B'); + assert.equal(H.humanSize(1024), '1.0 KB'); + assert.equal(H.humanSize(16457327), '15.7 MB'); + assert.equal(H.humanSize(3 * 1024 * 1024 * 1024), '3.0 GB'); + // Beyond GB stays pinned to the last unit. + assert.equal(H.humanSize(5 * 1024 * 1024 * 1024 * 1024), '5120.0 GB'); +}); + +test('humanSize rejects invalid input', () => { + assert.equal(H.humanSize(-1), '—'); + assert.equal(H.humanSize('not-a-number'), '—'); + assert.equal(H.humanSize(null), '0 B'); // Number(null) is 0 — a real size. + assert.equal(H.humanSize(undefined), '—'); +}); + +test('fmtDate renders en-GB short dates and tolerates junk', () => { + assert.equal(H.fmtDate('2026-10-24T15:21:00Z'), '24 Oct 2026'); + assert.equal(H.fmtDate(''), '—'); + assert.equal(H.fmtDate(undefined), '—'); + // Invalid-but-non-empty input passes through unchanged. + assert.equal(H.fmtDate('soon'), 'soon'); +}); + +test('tokenFromLocation reads #token= from the fragment', () => { + assert.equal(H.tokenFromLocation({ hash: '#token=abc.def-ghi', search: '' }), 'abc.def-ghi'); + assert.equal( + H.tokenFromLocation({ hash: '#checkout=done&token=zzz', search: '' }), + 'zzz' + ); +}); + +test('tokenFromLocation reads ?token= from the query', () => { + assert.equal(H.tokenFromLocation({ hash: '', search: '?token=qq' }), 'qq'); + assert.equal(H.tokenFromLocation({ hash: '', search: '?a=1&token=qq' }), 'qq'); + // Fragment wins over query when both are present. + assert.equal( + H.tokenFromLocation({ hash: '#token=frag', search: '?token=query' }), + 'frag' + ); +}); + +test('tokenFromLocation returns null without a token', () => { + assert.equal(H.tokenFromLocation({ hash: '', search: '' }), null); + assert.equal(H.tokenFromLocation({ hash: '#section', search: '?checkout=success' }), null); + assert.equal(H.tokenFromLocation(null), null); + assert.equal(H.tokenFromLocation(undefined), null); + // A lookalike param name is not a token. + assert.equal(H.tokenFromLocation({ hash: '#mytoken=x', search: '' }), null); +}); + +test('stripTokenFromHash removes only the token param', () => { + assert.equal(H.stripTokenFromHash('#token=abc'), ''); + assert.equal(H.stripTokenFromHash('#token=abc&checkout=done'), 'checkout=done'); + assert.equal(H.stripTokenFromHash('#checkout=done&token=abc'), 'checkout=done'); + assert.equal(H.stripTokenFromHash('#your-keys-your-costs'), 'your-keys-your-costs'); + assert.equal(H.stripTokenFromHash(''), ''); +});