crates/monetize-site-buy/assets/welcome.js
// **The welcome page's script, ONE copy for every product site** (monetize-site-buy `assets`,
// owner 2026-10-23): Gunnar's `auth/welcome.html` script, moved as it is; each site fills in
// only its settings (`assets::welcome_js`).
// ── The whole of this page ───────────────────────────────────────────────────
//
// It asks `/auth/signup/status?session=…` on a bounded backoff (one second,
// growing to five) and renders what comes back. It decides NOTHING about the payment: the outcome reaches
// monetize by the vendor's own webhook, and a return URL's query string is the
// customer's claim about whether they paid. So this page has no state of its
// own beyond a timer, and it believes the console.
//
// Everything is built with textContent and real elements, never innerHTML: this
// script is hashed into the CSP, and both the user name and monetize's refusal
// are text that came from somewhere else and must never be parsed as markup.
const CONTACT = '@@CONTACT@@';
// ★ The bounded backoff (PAID-WORDS, 2026-10-26): a paid account settles in
// seconds, so the first asks are quick; they grow to one every five seconds.
const POLL_FIRST_MS = 1000;
const POLL_MAX_MS = 5000;
let pollMs = POLL_FIRST_MS;
let slowed = false;
// ★ RA4-19: while the account settles the page says ONLY the shop's sentences: the one it
// answered with, and, should it stop answering, the one it sent ahead for that
// (`words_if_silent`, due `silent_after_ms` after its answer: the server's settle bound).
// No bound and no settle sentence of the page's own: "maintenance" and "taking longer"
// are the shop's words, never this page's.
// ★ PAID-2 (owner 2026-10-26): the register button says what it does.
const REGISTER_NOW = 'Register your security key now';
const REGISTER_OR_MAIL = 'Or open the link we sent to your e-mail.';
// Ten minutes of polling, then the page slows to one poll a minute and says
// so. Not an error, and never a promise about mail: whether a mail goes out
// is the server's configuration, which this page does not know (measured
// 2026-09-09: the page said "your mail is on its way" on a deployment with
// no mailer — a false sentence to a paying customer). What it CAN say is
// true either way: this page keeps working, and reloading it later works.
const PATIENCE_MS = 10 * 60 * 1000;
const SLOW_POLL_MS = 60 * 1000;
// Half a minute of "monetize has never heard of this", which is long enough to
// rule out a poll that raced the vendor's own redirect.
const UNKNOWN_MS = 30 * 1000;
const headline = document.getElementById('headline');
const detail = document.getElementById('detail');
const progress = document.getElementById('progress');
const foot = document.getElementById('foot');
// A refusal's sentence: `{"error": …}` is the one shape the sign-up routes
// refuse in (auth::validate::refuse); anything else is shown as it came.
async function refusal(res) {
const text = await res.text().catch(() => '');
try { const j = JSON.parse(text); if (j && typeof j.error === 'string') return j.error; } catch (e) {}
return text;
}
function say(head, text, cls) {
headline.textContent = head;
detail.textContent = text;
detail.className = cls || '';
}
function stop() { progress.hidden = true; }
// The mail address, as a real anchor a phone can tap.
function offerTheDesk(lead) {
foot.textContent = lead ? lead + ' ' : '';
const a = document.createElement('a');
a.href = CONTACT ? 'mailto:' + CONTACT + '?subject=@@PRODUCT@@%20signup' : '/';
a.textContent = CONTACT || location.hostname;
foot.appendChild(a);
foot.hidden = false;
}
// The checkout session, read ONCE and taken out of the address bar.
//
// `cs_…` identifies a payment. It has to arrive in a query string — the payment
// vendor is what put this browser here, and that half is not ours to change —
// but where it stays afterwards is: the address bar for the rest of the visit,
// the session's history, the `Referer` of anything this page loads next, and
// whatever screen is being shared, which for a paid signup is often a call with
// the desk.
//
// It was left here on the argument that reloading this page later has to work
// and the id is what makes that true. The requirement is real; the id in the
// URL is not the only way to meet it. `sessionStorage` is per TAB, survives a
// reload, is not carried in any header, and is gone when the tab is — which is
// the right lifetime for a thing that stops mattering the moment the invite is
// redeemed. So: read the query, keep the id, rewrite the query away with
// replaceState (no navigation, no history entry of its own), and fall back to
// what was kept when there is no query at all. A fresh tab with neither still
// goes to /login, exactly as before.
//
// Wrapped in try/catch the way every storage access has to be: a private window
// or a browser set to block site data throws on the accessor itself, and a
// welcome page that threw would leave a paying customer looking at nothing.
const SESSION_KEY = '@@STORAGE@@signup_session';
const SESSION = (() => {
try {
const params = new URLSearchParams(location.search);
const fromUrl = params.get('session') || '';
if (fromUrl) {
try { sessionStorage.setItem(SESSION_KEY, fromUrl); } catch (e) {}
params.delete('session');
const rest = params.toString();
history.replaceState(null, '', location.pathname + (rest ? '?' + rest : ''));
return fromUrl;
}
try { return sessionStorage.getItem(SESSION_KEY) || ''; } catch (e) { return ''; }
} catch (e) { return ''; }
})();
function sessionId() { return SESSION; }
// Only a rooted path on this console is followed. The server sends one on
// purpose — this is `/login`'s own rule about `?next=`, applied to the one
// redirect this page makes, so that a console misconfigured tomorrow cannot
// turn a paid signup into an open redirect.
//
// It is now the SAME rule, spelled the same way, and not an approximation of
// it: this page used to test `//` and not `/\`, which every engine reads as an
// authority too, so the stricter of the two validators was on the page that
// needed it less. Both are written out rather than shared because each page is
// one document with one inline script, hashed into the CSP on its own — so the
// thing to keep in step is the text, and the tests assert both carry it.
function safeLocalPath(raw) {
if (typeof raw !== 'string' || raw === '') return '';
if (!raw.startsWith('/') || raw.startsWith('//') || raw.startsWith('/\\')) return '';
if (/[\u0000-\u0020\u007F]/.test(raw)) return '';
return raw;
}
function safeInvite(url) {
return safeLocalPath(url);
}
// The masked address the sign-up page kept in this tab (its MAILED_KEY): only ever
// the masked form, read here and sent nowhere.
const MAILED_KEY = '@@STORAGE@@signin_mailed';
// ★ The sign-up page sent this tab to Stripe's CARD CHECK (owner 2026-10-15): nothing was
// paid, so this page never says "Payment received" for it.
const CARD_CHECK_KEY = '@@STORAGE@@card_check';
function cardChecked() {
try { return sessionStorage.getItem(CARD_CHECK_KEY) === '1'; } catch (e) { return false; }
}
function mailedTo() {
try { return sessionStorage.getItem(MAILED_KEY) || ''; } catch (e) { return ''; }
}
// [Send again], and the key registered right here instead of from the mail.
function mailLine(onward) {
foot.textContent = '';
const again = document.createElement('button');
again.type = 'button';
again.textContent = 'Send again';
again.addEventListener('click', async () => {
again.disabled = true;
try {
const res = await fetch('/auth/signup/sign-in-again', {
method: 'POST',
credentials: 'omit',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ session: sessionId(), next: NEXT }),
});
if (res.ok) {
const j = await res.json().catch(() => ({}));
detail.textContent = (j && j.words) || 'Sent again.';
} else {
detail.textContent = await refusal(res) || 'It could not be sent again right now.';
}
} catch (e) {
detail.textContent = '@@PRODUCT@@ is not answering. Try again in a moment.';
} finally {
again.disabled = false;
}
});
foot.appendChild(again);
if (onward) {
foot.append(' ');
const here = document.createElement('a');
here.href = onward;
here.textContent = REGISTER_NOW;
foot.appendChild(here);
foot.appendChild(document.createElement('br'));
const or = document.createElement('span');
or.className = 'hint';
or.textContent = REGISTER_OR_MAIL;
foot.appendChild(or);
}
foot.hidden = false;
}
// ★ **The way back after the passkey** (one shop, 2026-10-07): an account made
// on the private choice of the sign-up page returns there. `?next=`, a path on
// this console by `/login`'s own rule, rides the registration link; `/login`
// follows it once the person has logged in.
const NEXT = safeLocalPath(new URLSearchParams(location.search).get('next'));
const session = sessionId();
if (!session) {
// Nothing to wait for. The login page is where a person with no session
// belongs, and this page has no business existing without one.
location.replace('/login');
} else {
const started = Date.now();
let unknownSince = 0;
let timer = 0;
// The last sentence the shop sent while the account settles: a poll it does not
// answer keeps it on the page, as a blip is not news to the buyer. `ahead`: the
// sentence the shop sent for its silence, and when it is due.
let lastWords = '';
let ahead = { words: '', due: 0 };
const again = () => {
const wait = slowed ? SLOW_POLL_MS : pollMs;
pollMs = Math.min(POLL_MAX_MS, Math.round(pollMs * 1.5));
timer = setTimeout(tick, wait);
};
// While the account settles, in the buyer's words, all of them the shop's: the
// sentence it answered with; while it does not answer, its last one, until the
// sentence it sent ahead for its silence is due. Before any sentence the page keeps
// what the site's markup says.
function settling(user, body) {
const head = user ? 'Setting up ' + user + ' …' : 'Setting up your account …';
if (body) {
if (body.words) lastWords = body.words;
ahead = { words: body.words_if_silent || '', due: Date.now() + (Number(body.silent_after_ms) || 0) };
}
const words = !body && ahead.words && Date.now() >= ahead.due ? ahead.words : lastWords;
headline.textContent = head;
if (words) say(head, words);
}
async function tick() {
let res;
try {
res = await fetch('/auth/signup/status?session=' + encodeURIComponent(session),
{ credentials: 'omit' });
} catch (e) {
// A dropped connection is not an answer. Keep waiting; the patience
// check below is what ends this.
return patience();
}
if (res.status === 404) {
stop();
say('This console does not do that', await refusal(res) || 'paid signup is not offered here.', 'err');
offerTheDesk('Write to us:');
return;
}
if (res.status === 400) {
stop();
say('That link is not one of ours', await refusal(res) || 'the session in the address is not a checkout session.', 'err');
offerTheDesk('Write to us:');
return;
}
if (res.status === 429) {
// Slow down rather than give up: the budget refills.
return patience();
}
if (!res.ok) {
// 5xx, or a shop that did not answer this poll. Not an error to the buyer
// (PAID-WORDS): the account is still being made, so the page keeps what the
// shop said and keeps asking; once due, it shows the sentence the shop sent
// ahead for its silence (RA4-19).
settling('', null);
return patience();
}
let body;
try { body = await res.json(); } catch (e) { return patience(); }
const state = body && body.state;
const user = (body && body.username) || '';
if (state === 'created') {
const invite = safeInvite(body.invite_url);
const onward = invite ? (NEXT ? invite + '&next=' + encodeURIComponent(NEXT) : invite) : '';
// ★ **Where the sign-in link went** (owner 2026-10-11): the sign-up page kept the
// buyer's address MASKED in this tab; with it, this page says the mail is on its
// way, offers [Send again], and the key may still be registered right here.
const mailed = mailedTo();
if (invite && mailed) {
stop();
const paid = !session.startsWith('acct_') && !cardChecked();
say('Your account is ready', (paid ? 'Payment received. ' : '') + 'We’ve sent a sign-in link to ' + mailed + '. Open it to register your security key.', 'ok');
mailLine(onward);
return;
}
if (invite) {
stop();
say('Your account is ready', 'Taking you to register your passkey …', 'ok');
location.replace(onward);
return;
}
// The account exists and its registration link has been used. That is
// the ordinary state for a return URL opened twice, and the way in is
// the ordinary one.
stop();
say('Your account is ready', body.words || 'This link has already been used.', 'ok');
foot.textContent = '';
const a = document.createElement('a');
a.href = '/login';
a.textContent = 'Log in with your passkey';
foot.appendChild(a);
foot.hidden = false;
return;
}
if (state === 'refused') {
stop();
// **The name, and only the name, gets a second try.**
//
// `renameable` is monetize's own VALUE, not a phrase read out of the
// refusal: a page matching on prose breaks the day somebody improves a
// sentence, and what it would break is a paying customer's only way
// through. A refusal about the ORDER — a plan nobody sells, an amount
// that drifted — is not something anybody can type their way out of, so
// that one gets the desk and no field.
if (body.renameable) {
say('Choose another username', body.words || 'that name has been taken.', 'err');
askAgain();
return;
}
// monetize's CUSTOMER sentence, verbatim. The console does not reword a
// refusal: an operator comparing the two would have no way to know which
// to believe. Its operator reason is in the console's journal, never here.
say('We could not finish this', body.words || 'the signup was refused.', 'err');
offerTheDesk('Write to us and we will sort it out:');
return;
}
if (state === 'unknown') {
if (!unknownSince) unknownSince = Date.now();
if (Date.now() - unknownSince >= UNKNOWN_MS) {
stop();
say('This session is not known to us',
'Nothing here matches that address. If you paid, we will find it.', 'err');
offerTheDesk('Write to us:');
return;
}
say('Setting up your account …', cardChecked() ? 'Waiting for Stripe to confirm your card.' : 'Waiting for the payment to reach us.');
return again();
}
// pending
unknownSince = 0;
// The shop's own sentence wins when it sends one: "Setting up your account",
// "taking longer than usual", or — only when the product says so by name —
// "down for maintenance" (PAID-WORDS). Never why.
settling(user, body);
return patience();
}
// ── The second name ────────────────────────────────────────────────────
//
// The money has already moved. All this does is post one name and go back to
// polling: monetize re-checks it against the same rule and the same list, and
// the next poll sees whatever became of it — so nothing here decides anything
// and a dropped connection costs a retry rather than an account.
const RULE = /^[a-z0-9][a-z0-9._-]{1,31}$/;
const RULE_SAID = 'Use a-z, 0-9, dot, underscore or dash — 2 to 32 characters, starting with a letter or a digit.';
const form = document.getElementById('rename');
const field = document.getElementById('u');
const go = document.getElementById('go');
function askAgain() {
form.hidden = false;
go.disabled = false;
field.focus();
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
if (go.disabled) return;
const name = field.value.trim().toLowerCase();
field.value = name;
if (!RULE.test(name)) {
say('Choose another username', RULE_SAID, 'err');
return;
}
go.disabled = true;
say('Choose another username', 'Checking …');
let res;
try {
res = await fetch('/auth/signup/rename', {
method: 'POST',
credentials: 'omit',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ session: session, username: name }),
});
} catch (e) {
say('Choose another username', '@@PRODUCT@@ is not answering. Try again in a moment.', 'err');
go.disabled = false;
return;
}
if (!res.ok) {
// 409 is the ordinary one: that name is taken too. The sentence is
// monetize's, and the field stays where it is.
say('Choose another username', await refusal(res) || 'that name is taken too.', 'err');
go.disabled = false;
field.select();
return;
}
// Whatever it became, the poll below is what renders it — including the
// invite, which is minted against the name MONETIZE returned.
form.hidden = true;
say('Setting up ' + name + ' …', 'Making the account now.');
progress.hidden = false;
pollMs = POLL_FIRST_MS;
tick();
});
function patience() {
if (Date.now() - started >= PATIENCE_MS && !slowed) {
slowed = true;
say('Still setting up',
'This is taking longer than usual. This page keeps checking once a minute; you can also come back to this address later — it will finish here.');
offerTheDesk('If it is not done within the hour, write to us:');
}
again();
}
tick();
}