Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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();
}