The dashboard for my payment provider reads, for the whole life of the account:
Revenue since inception USD 0.00
Transaction History Total count: 0
Users Total count: 0
Live mode and sandbox mode, same numbers. The easy reading is that nobody wanted it.
Here is the harder reading. Over the eight days my analytics still retains, 21 of 228 real visits reached the pricing page. Not one of those 21 had anything to click. The buy button was there. It was styled, it was enabled, it said "Sign in to start the trial". It had no click handler on it at all.
Three separate defects, none of them visible from reading the code, all three provable from the network tab.
One: my own content security policy blocked the SDK
The policy I shipped was tight on purpose. The whole frontend is first party, so I named exactly what it needed:
default-src 'self';
script-src 'self' 'unsafe-inline' https://esm.sh;
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self' https://esm.sh https://api.tiun.live https://api-sandbox.tiun.live;
Read that and it looks right. The SDK module comes from esm.sh, so esm.sh is a script source. The SDK talks to the API host, so the API host is a connect source. Done.
An SDK is not one request. Here is what init() actually does, from the shipped bundle:
let r = document.createElement("link");
r.rel = "stylesheet"; r.href = cssUrl;
document.head.appendChild(r);
let a = document.createElement("script");
a.src = scriptUrl; a.async = true;
document.head.appendChild(a);
It appends a stylesheet and a script pointing at the API host. Those are style-src and script-src decisions. The sign in surface renders in a frame, which is frame-src. With no frame-src the browser falls back to default-src 'self'. The one place I named the host was the one place it did not matter.
Driving the page over the DevTools protocol says it plainly:
requestWillBeSent .../snippets/<id>/background_css
requestWillBeSent .../snippets/<id>/background_js
Network.loadingFailed
Network.loadingFailed
Both fetch fine with curl. 84 KB of CSS, 506 KB of JS, 200 each. The refusal was mine.
The reason nobody noticed
waitForReady() resolves when the snippet posts back that it initialised. With the runtime bundle refused, that message never arrives, so the promise never settles. My code awaits it at module top level:
await tiun.waitForReady();
// everything below here never ran
for (const button of document.querySelectorAll("[data-buy]")) {
button.addEventListener("click", () => tiun.checkout({ productId: product.id }));
}
The page did not throw. It did not show an error. It stopped, silently, above the line that wires the buttons. It kept the markup the HTML shipped with. That markup shows the sign in prompt and hides the buy controls, because that is the correct signed out state. A correct looking page with no behaviour behind it.
I had written a checkoutUnavailable() path for exactly this and it never fired, because nothing rejected.
Two: getUser is synchronous
Fixing the policy moved the failure one layer up. From the SDK source:
getUser() { return { isAuthenticated: this._isAuthenticated, user: this._user } }
No promise. My code:
const user = await tiun.getUser().catch(() => null);
.catch on a plain object is a TypeError. It is thrown inside an async function, so it does not crash the page, it becomes a rejected promise nobody is awaiting. That function is the one that decides what the top bar and the buy controls look like. It died on its first line, on every call, for a month. The only trace was a single Uncaught (in promise) line in a console nobody had open.
Three: the purchases are one level down
Same call, look at the shape again. It is { isAuthenticated, user }. The email and the entitlements live on the inner object. I read them off the outer one:
const access = Object.keys(user?.productAccess ?? {}).length > 0;
Always {}. Always false. So the control that hands a paying customer their CLI licence could never appear, for anyone, ever. If someone had pushed through the other two bugs and paid me money, they would have got nothing.
And a fourth, for free
for (const event of ["login", "logout", "checkout:complete", "user:updated"]) {
tiun.on?.(event, paint);
}
The SDK emits seven event names. checkout:complete is not one of them. user:updated is not one of them. The real names are ready, login, logout, userChange, paywallShow, paywallHide and error. Subscribing to a name that is never emitted is not an error anywhere. It is a listener that waits forever. A completed purchase repainted nothing.
on() is called with ?. too, so even if the method vanished the line would stay quiet.
What I actually got wrong
Not the policy. Policies are fiddly and that mistake is cheap to make.
What I got wrong is that I treated waitForReady() resolving as proof the checkout worked. It is not proof of anything. It is one signal from one layer. Every layer under it can be broken while it still resolves. I built my whole readiness story on a check that could not fail.
I write a tool that finds exactly this in test suites. Tests that call a function and assert nothing. Assertions that cannot be false. Gates no workflow invokes. Every one of them is a signal that stays green because it is not actually connected to the thing it claims to measure. I had one of those in my own shop, on the page where people pay me.
The fix
The policy now names the hosts everywhere the SDK reaches through. Nowhere else:
script-src 'self' 'unsafe-inline' https://esm.sh <api hosts>;
style-src 'self' 'unsafe-inline' <api hosts>;
img-src 'self' data: <api hosts> https://assets.tiun.dev;
font-src 'self' data: <api hosts> https://assets.tiun.dev;
frame-src <api hosts>;
The font host turned up on the next run, because the stylesheet asks it for one webfont. It gets font-src and img-src and nothing else. A font host has no business being a script source. Nothing is widened to a bare https:, which would allow every host on the internet and is the shortcut I did not want.
Then a readiness check that can fail:
function assertSdkArrived() {
if (cspBlocked) throw new Error(CSP_BLOCKED);
const loaded = performance.getEntriesByType("resource")
.some((r) => r.name.includes("/background_js") && r.responseEnd > 0);
if (!loaded) throw new Error("tiun runtime bundle never loaded");
if (typeof tiun.login !== "function" || typeof tiun.checkout !== "function") {
throw new Error("tiun sdk is missing login or checkout");
}
}
A resource the policy blocked never enters the Resource Timing buffer, so asking the buffer is a real question with a real answer. Alongside it, a securitypolicyviolation listener records the directive:
const FATAL_DIRECTIVES = new Set(["script-src", "style-src", "frame-src", "connect-src"]);
document.addEventListener("securitypolicyviolation", (e) => {
if (!String(e.blockedURI).includes("tiun")) return;
if (FATAL_DIRECTIVES.has(e.effectiveDirective)) cspBlocked = `${e.effectiveDirective} blocked ${e.blockedURI}`;
});
That set matters. My first version treated any blocked provider URL as fatal, so the blocked webfont disabled a checkout that had just started working. A blocked font is ugly. A blocked script is a dead checkout. Only one of those should stop the sale.
Tests, since the whole point is not trusting a green light
19 of them. They pin facts rather than behaviour I cannot exercise headlessly.
One file reads the policy and asserts every directive the SDK needs names both hosts, that the font host is a font source but not a script or frame source, that nothing is a bare scheme, then that the directives with nothing to do with payments still read 'none'.
The other reads the frontend as text and holds it to the SDK: no .catch on getUser(), purchases read from the nested object by name, then every event subscribed to is one of the seven the SDK actually emits. It blanks comments before matching, because the comments describe the old wrong call and would otherwise match themselves.
128 tests pass now. They would all have passed before too, which is the point: none of them was looking here.
Where it stands
The sign in modal opens on margyn.xyz/pricing. Clicking that button did nothing yesterday.
I still have zero sales. That number has not moved. What changed is that it now means something about my product instead of something about my content security policy. Those are very different problems to have. For a month I was working on the wrong one.
The scanner is margyn, free for four checks, no account and no network call. Source is on GitHub.
Top comments (0)