diff --git a/bin/check-file-allowlist.sh b/bin/check-file-allowlist.sh index 49ff931..c9fb630 100755 --- a/bin/check-file-allowlist.sh +++ b/bin/check-file-allowlist.sh @@ -69,6 +69,10 @@ ALLOW_PATTERNS=( '^\.github/workflows/[A-Za-z0-9_-][A-Za-z0-9._-]*\.ya?ml$' '^functions/(api/)?[A-Za-z0-9._-]+\.js$' '^[A-Za-z0-9._-]+/index\.html$' # pre-positioned pages, e.g. bcn-removed/index.html + '^checkout\.js$' + '^checkout-config\.js$' + '^tests/checkout\.test\.js$' + '^checkout/(success|cancel)/index\.html$' ) # The only dot-prefixed paths allowed to exist. .github/ is where GitHub requires diff --git a/buy/index.html b/buy/index.html new file mode 100644 index 0000000..e88ff3a --- /dev/null +++ b/buy/index.html @@ -0,0 +1,119 @@ + + + + + +Choose a plan + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Draft, test mode only.

+

Choose a plan

+

TODO-COPY: one line on what the plans are.

+

TODO-COPY: Checkout is not open yet.

+ +

Lost your key? Send it again.

+
+ + + + + diff --git a/checkout-config.js b/checkout-config.js new file mode 100644 index 0000000..9ef53b4 --- /dev/null +++ b/checkout-config.js @@ -0,0 +1,4 @@ +// The one switch for the whole checkout (payments SB-10.1: the values the two sides exchange are the backend addresses, the success +// address, the cancel address and the allowed origins). OFF until the apply has printed the three test-mode addresses; turning it +// on is a reviewed change that also adds the same three origins to connect-src in _headers. No secret, no price and no key ever lives here. +window.FOSTERPAY_CONFIG = { enabled: false, checkout: "", entitlement: "", resend: "" }; diff --git a/checkout.js b/checkout.js new file mode 100644 index 0000000..881e078 --- /dev/null +++ b/checkout.js @@ -0,0 +1,171 @@ +// FosterStack checkout script: one same-origin file, no dependencies, no inline script anywhere (payments SB-10.2, SB-10.4, SB-10.5, SB-10.6). +// It holds no secret, no price and no Stripe code (SB-10.3). It sends only: the buy click to the checkout address, one read to the entitlement +// address from the success page, and the resend page's address to the resend address. It stores nothing: no cookie, no browser storage. +// The resend page's email field is the one exception to "no form on www"; no other page may have one. +(function (root, factory) { + "use strict"; + var api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + else { root.FosterPay = api; if (typeof document !== "undefined") api.boot(root, document); } +})(typeof window !== "undefined" ? window : this, function () { + "use strict"; + + var TEXT = { + // TODO-COPY: every line below is placeholder copy; the other lines are final. + closed: "TODO-COPY: Checkout is not open yet.", + checkoutFailed: "TODO-COPY: We could not start checkout just now. Please try again in a minute.", + resendSent: "If this address has a purchase, we sent the key.", + resendFailed: "TODO-COPY: We are busy. Please try again in a minute.", + resendBadAddress: "TODO-COPY: Please type the address you bought with.", + resendPromise: "We never store the address you type. It is used once to send your key and then discarded. Amazon's email service may keep an address that bounces.", + processing: "TODO-COPY: Your payment is still being confirmed. Check again in a minute.", + none: "TODO-COPY: There is no key to show on this page. A key is shown only once, right after payment. If you already saw yours and lost it, you can have it sent again.", + error: "TODO-COPY: We could not read your order just now. Please try again in a minute." + }; + + var STRIPE_HOST = "checkout.stripe.com"; + var TIERS = { team: 1, business: 1, compliance: 1 }; + var INTERVALS = { month: 1, year: 1 }; + var SESSION = /^cs_test_[A-Za-z0-9_]{1,200}$/; // test mode only: a live id is never sent from this build + + function enabled(cfg) { + if (!cfg || cfg.enabled !== true) return false; + return ["checkout", "entitlement", "resend"].every(function (k) { return typeof cfg[k] === "string" && /^https:\/\/[a-z0-9.-]+\/$/.test(cfg[k]); }); + } + + function newAttemptId(c) { + var b = new Uint8Array(16); + c.getRandomValues(b); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + var h = Array.prototype.map.call(b, function (x) { return (x + 256).toString(16).slice(1); }).join(""); + return h.slice(0, 8) + "-" + h.slice(8, 12) + "-" + h.slice(12, 16) + "-" + h.slice(16, 20) + "-" + h.slice(20); + } + + // One attempt id per choice, kept in memory only: a double click repeats it (one session at the backend), a changed choice makes a new one. + function attemptHolder(c) { + var key = "", id = ""; + return { get: function (tier, interval) { var k = tier + "|" + interval; if (k !== key) { key = k; id = newAttemptId(c); } return id; } }; + } + + function stripeUrlOk(u) { + if (typeof u !== "string") return false; + try { + var p = new URL(u); + return p.protocol === "https:" && p.hostname === STRIPE_HOST && p.port === "" && p.username === "" && p.password === ""; + } catch (e) { return false; } + } + + // fetch with a time limit; any failure becomes null, so no detail from the network or the backend ever reaches the page + async function call(d, url, init) { + var ctl = typeof AbortController === "function" ? new AbortController() : null; + var timer = null; + if (ctl) { init.signal = ctl.signal; timer = setTimeout(function () { ctl.abort(); }, d.timeoutMs || 15000); } + try { + var r = await d.fetch(url, init); + var body = null; + try { body = JSON.parse(await r.text()); } catch (e) { body = null; } + return { status: r.status, body: body }; + } catch (e) { return null; } finally { if (timer) clearTimeout(timer); } + } + + async function startCheckout(choice, d) { + var fail = { ok: false, text: TEXT.checkoutFailed }; + if (!enabled(d.config)) return { ok: false, text: TEXT.closed }; + if (!choice || !TIERS[choice.tier] || !INTERVALS[choice.interval] || (choice.tier === "compliance" && choice.interval !== "year")) return fail; + var res = await call(d, d.config.checkout + "v1/checkout-session", { + method: "POST", credentials: "omit", + headers: { "Content-Type": "application/json", "Idempotency-Key": d.attemptId }, + body: JSON.stringify({ product: "cache", tier: choice.tier, interval: choice.interval }) + }); + if (!res || res.status !== 200 || !res.body || !stripeUrlOk(res.body.url)) return fail; + (d.navigate || function (u) { window.location.assign(u); })(res.body.url); + return { ok: true }; + } + + function sessionFromSearch(search) { + if (typeof search !== "string" || search.charAt(0) !== "?") return ""; + var rest = search.slice(1); + if (rest.indexOf("&") !== -1 || rest.slice(0, 2) !== "s=") return ""; + var v = rest.slice(2); + return SESSION.test(v) ? v : ""; + } + + async function readEntitlement(sid, d) { + if (!enabled(d.config)) return { status: "closed" }; + if (typeof sid !== "string" || !SESSION.test(sid)) return { status: "none" }; + var res = await call(d, d.config.entitlement + "v1/entitlement?s=" + sid, { method: "GET", credentials: "omit" }); + if (!res || res.status !== 200 || !res.body) return { status: "error" }; + var s = res.body.status; + if (s === "key") return typeof res.body.key === "string" && res.body.key !== "" ? { status: "key", key: res.body.key } : { status: "error" }; + if (s === "processing" || s === "none") return { status: s }; + return { status: "error" }; + } + + var ADDRESS = /^[^\s@]{1,64}@[^\s@]{1,190}\.[^\s@]{1,63}$/; + + async function sendResend(email, d) { + if (!enabled(d.config)) return { ok: false, text: TEXT.closed }; + if (typeof email !== "string" || email.length > 254 || !ADDRESS.test(email)) return { ok: false, text: TEXT.resendBadAddress }; + var res = await call(d, d.config.resend + "v1/resend", { method: "POST", credentials: "omit", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email: email }) }); + if (!res || res.status !== 202 || !res.body || res.body.status !== "ok") return { ok: false, text: TEXT.resendFailed }; + return { ok: true, text: TEXT.resendSent }; + } + + function scrubAddress(loc, hist) { hist.replaceState(null, "", loc.pathname); } + + // Everything below puts values on the page as text, never as markup. + function renderEntitlement(out, doc) { + var box = doc.getElementById("fp-key-box"), key = doc.getElementById("fp-key"), note = doc.getElementById("fp-note"), again = doc.getElementById("fp-again"); + if (box) box.hidden = out.status !== "key"; + if (key) key.textContent = out.status === "key" ? out.key : ""; + if (note) note.textContent = out.status === "key" ? "" : (TEXT[out.status] || TEXT.error); + if (again) again.hidden = out.status !== "processing"; + } + + function show(doc, id, on) { var e = doc.getElementById(id); if (e) e.hidden = !on; } + + function boot(win, doc) { + var cfg = win.FOSTERPAY_CONFIG; + var on = enabled(cfg); + show(doc, "fp-closed", !on); + show(doc, "fp-open", on); + if (!on) return; + var d = { config: cfg, fetch: function (u, i) { return win.fetch(u, i); } }; + var buttons = doc.querySelectorAll("[data-buy-tier]"); + if (buttons.length) { + var holder = attemptHolder(win.crypto); + var status = doc.getElementById("fp-status"); + Array.prototype.forEach.call(buttons, function (b) { + b.addEventListener("click", async function () { + var tier = b.getAttribute("data-buy-tier"), interval = b.getAttribute("data-buy-interval"); + b.disabled = true; + var out = await startCheckout({ tier: tier, interval: interval }, Object.assign({ attemptId: holder.get(tier, interval) }, d)); + if (!out.ok) { b.disabled = false; if (status) status.textContent = out.text; } + }); + }); + } + if (doc.getElementById("fp-key-box")) { + var sid = sessionFromSearch(win.location.search); + scrubAddress(win.location, win.history); + var reads = 0; + var run = async function () { reads += 1; renderEntitlement(await readEntitlement(sid, d), doc); if (reads >= 2) show(doc, "fp-again", false); }; + run(); + var again = doc.getElementById("fp-again"); + if (again) again.addEventListener("click", function () { if (reads < 2) run(); }); + } + var send = doc.getElementById("fp-send"); + if (send) { + var field = doc.getElementById("fp-email"), result = doc.getElementById("fp-result"); + send.addEventListener("click", async function () { + send.disabled = true; + var out = await sendResend(field.value, d); + if (result) result.textContent = out.text; + if (out.ok) field.value = ""; + send.disabled = false; + }); + } + } + + return { TEXT: TEXT, enabled: enabled, newAttemptId: newAttemptId, attemptHolder: attemptHolder, startCheckout: startCheckout, sessionFromSearch: sessionFromSearch, readEntitlement: readEntitlement, sendResend: sendResend, scrubAddress: scrubAddress, renderEntitlement: renderEntitlement, boot: boot }; +}); diff --git a/checkout/cancel/index.html b/checkout/cancel/index.html new file mode 100644 index 0000000..50a259e --- /dev/null +++ b/checkout/cancel/index.html @@ -0,0 +1,98 @@ + + + + + +No charge was made + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

No charge was made.

+

You left checkout before paying, so nothing was charged.

+
Back to the plans FosterStack home
+
+ + + diff --git a/checkout/success/index.html b/checkout/success/index.html new file mode 100644 index 0000000..1f59cce --- /dev/null +++ b/checkout/success/index.html @@ -0,0 +1,110 @@ + + + + + +Thank you: order received + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Draft, test mode only.

+

Thank you: order received.

+

TODO-COPY: Checkout is not open yet.

+ +
FosterStack home
+
+ + + + + diff --git a/privacy/index.html b/privacy/index.html new file mode 100644 index 0000000..c05c4f9 --- /dev/null +++ b/privacy/index.html @@ -0,0 +1,100 @@ + + + + + +Privacy policy + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Privacy policy

+

The privacy policy is not published yet. This placeholder will be replaced before any paid tier is sold. Until then it is not in effect.

+
+ FosterStack home +
+
+ + + diff --git a/resend-key/index.html b/resend-key/index.html new file mode 100644 index 0000000..3a05408 --- /dev/null +++ b/resend-key/index.html @@ -0,0 +1,108 @@ + + + + + +Send my key again + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Draft, test mode only.

+

Send my key again

+

TODO-COPY: Type the address you bought with.

+

TODO-COPY: Checkout is not open yet.

+ +
+ + + + + diff --git a/terms-of-sale/index.html b/terms-of-sale/index.html new file mode 100644 index 0000000..e3a919c --- /dev/null +++ b/terms-of-sale/index.html @@ -0,0 +1,100 @@ + + + + + +Terms of sale and refunds + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Terms of sale and refunds

+

The terms of sale and the refund policy are not published yet. This placeholder will be replaced before any paid tier is sold. Until then they are not in effect.

+
+ FosterStack home +
+
+ + + diff --git a/terms/index.html b/terms/index.html new file mode 100644 index 0000000..f5f0eed --- /dev/null +++ b/terms/index.html @@ -0,0 +1,100 @@ + + + + + +Terms of service + + + + + + + + + +
+ +
Infrastructure worth keeping — and kept patched.
+
+
+

Terms of service

+

The terms of service are not published yet. This placeholder will be replaced before any paid tier is sold. Until then they are not in effect.

+
+ FosterStack home +
+
+ + + diff --git a/tests/checkout.test.js b/tests/checkout.test.js new file mode 100644 index 0000000..df7620f --- /dev/null +++ b/tests/checkout.test.js @@ -0,0 +1,316 @@ +// Tests for the checkout script (written before the script). Plain node, no dependencies: `node --test tests/`. +// Rules served: payments SB-10.2, SB-10.4, SB-10.5, SB-10.6 (ratified Oct 9) and the www drafts WB-1..WB-15 (docs in the ops repo, not yet ratified). +// The resend page's email field is the owner's ratified single exception to "no form on www" (SB-10.6, revised Q9). +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const root = path.join(__dirname, ".."); +const FP = require(path.join(root, "checkout.js")); + +const ON = { enabled: true, checkout: "https://aaaaaaaaaaaaaaaaaaaaaa.lambda-url.us-east-1.on.aws/", entitlement: "https://bbbbbbbbbbbbbbbbbbbbbb.lambda-url.us-east-1.on.aws/", resend: "https://cccccccccccccccccccccc.lambda-url.us-east-1.on.aws/" }; +const OFF = { enabled: false, checkout: "", entitlement: "", resend: "" }; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; + +function recorder(answer) { + const calls = []; + const fetch = async (url, init) => { calls.push({ url, init }); return typeof answer === "function" ? answer(url, init) : answer; }; + return { fetch, calls }; +} +function resp(status, body) { return { status, ok: status >= 200 && status < 300, text: async () => (typeof body === "string" ? body : JSON.stringify(body)) }; } +function files(dir, ext, out = []) { + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + if (e.name.startsWith(".") || e.name === "node_modules") continue; + const p = path.join(dir, e.name); + if (e.isDirectory()) files(p, ext, out); else if (p.endsWith(ext)) out.push(p); + } + return out; +} + +test("test_wb_1_flag_off_no_button_no_page_no_request", async () => { + const r = recorder(resp(200, { url: "https://checkout.stripe.com/c/pay/x" })); + assert.equal(FP.enabled(OFF), false); + assert.equal(FP.enabled({ enabled: true, checkout: "", entitlement: "", resend: "" }), false, "on with no addresses is still off"); + assert.equal(FP.enabled({ ...ON, enabled: "yes" }), false, "only the boolean true turns it on"); + const out = await FP.startCheckout({ tier: "team", interval: "month" }, { config: OFF, fetch: r.fetch, attemptId: "x" }); + assert.equal(out.ok, false); + assert.equal(r.calls.length, 0, "no request while off"); + const e = await FP.readEntitlement("cs_test_abc", { config: OFF, fetch: r.fetch }); + assert.equal(e.status, "closed"); + const s = await FP.sendResend("a@b.co", { config: OFF, fetch: r.fetch }); + assert.equal(s.ok, false); + assert.equal(r.calls.length, 0); + const cfg = fs.readFileSync(path.join(root, "checkout-config.js"), "utf8"); + assert.match(cfg, /enabled:\s*false/, "the shipped config is OFF"); + assert.doesNotMatch(cfg, /lambda-url/, "no address is committed before the owner's apply"); +}); + +test("test_wb_2_request_has_only_the_three_fields", async () => { + const r = recorder(resp(200, { url: "https://checkout.stripe.com/c/pay/x" })); + const nav = []; + const id = FP.newAttemptId({ getRandomValues: (a) => { for (let i = 0; i < a.length; i++) a[i] = (i * 37 + 11) & 255; return a; } }); + assert.match(id, UUID); + const out = await FP.startCheckout({ tier: "business", interval: "year" }, { config: ON, fetch: r.fetch, attemptId: id, navigate: (u) => nav.push(u) }); + assert.equal(out.ok, true); + assert.equal(r.calls.length, 1); + const c = r.calls[0]; + assert.equal(c.url, ON.checkout + "v1/checkout-session"); + assert.equal(c.init.method, "POST"); + assert.deepEqual(JSON.parse(c.init.body), { product: "cache", tier: "business", interval: "year" }); + assert.deepEqual(Object.keys(c.init.headers).map((k) => k.toLowerCase()).sort(), ["content-type", "idempotency-key"]); + assert.equal(c.init.headers["Idempotency-Key"], id); + assert.equal(c.init.credentials, "omit", "no cookies"); + assert.equal(c.init.headers["Content-Type"], "application/json"); + assert.deepEqual(nav, ["https://checkout.stripe.com/c/pay/x"]); +}); + +test("test_wb_2_a_double_click_reuses_the_attempt_id", async () => { + const ids = []; + const r = recorder((u, init) => { ids.push(init.headers["Idempotency-Key"]); return resp(200, { url: "https://checkout.stripe.com/c/pay/x" }); }); + const attempt = FP.attemptHolder({ getRandomValues: (a) => { for (let i = 0; i < a.length; i++) a[i] = (i * 7 + 3) & 255; return a; } }); + const d = { config: ON, fetch: r.fetch, navigate: () => {} }; + await FP.startCheckout({ tier: "team", interval: "month" }, { ...d, attemptId: attempt.get("team", "month") }); + await FP.startCheckout({ tier: "team", interval: "month" }, { ...d, attemptId: attempt.get("team", "month") }); + assert.equal(ids[0], ids[1], "the same click sequence keeps one attempt id"); + assert.match(ids[0], UUID); +}); + +test("test_wb_3_compliance_year_only_and_nothing_else_is_sent", async () => { + const r = recorder(resp(200, { url: "https://checkout.stripe.com/x" })); + const d = { config: ON, fetch: r.fetch, attemptId: "x", navigate: () => {} }; + for (const bad of [{ tier: "compliance", interval: "month" }, { tier: "free", interval: "month" }, { tier: "team", interval: "week" }, { tier: "", interval: "year" }, {}, { tier: "team" }]) { + const out = await FP.startCheckout(bad, d); + assert.equal(out.ok, false, JSON.stringify(bad)); + } + assert.equal(r.calls.length, 0); +}); + +test("test_wb_4_only_the_stripe_checkout_host_is_followed", async () => { + for (const url of ["http://checkout.stripe.com/x", "https://evil.example/x", "https://checkout.stripe.com.evil.example/x", "javascript:alert(1)", "//checkout.stripe.com/x", "https://user@evil.example/", "https://checkout.stripe.com@evil.example/", 5, null, "", "https://checkout.stripe.com:8443/x"]) { + const nav = []; + const r = recorder(resp(200, { url })); + const out = await FP.startCheckout({ tier: "team", interval: "month" }, { config: ON, fetch: r.fetch, attemptId: "x", navigate: (u) => nav.push(u) }); + assert.equal(out.ok, false, String(url)); + assert.deepEqual(nav, [], String(url)); + } +}); + +test("test_wb_5_every_failure_ends_on_fixed_text", async () => { + const cases = [resp(400, { error: "bad_tier" }), resp(502, { error: "unavailable" }), resp(429, ""), resp(503, ""), resp(200, "not json"), resp(200, { nourl: 1 }), resp(200, { url: "https://checkout.stripe.com/x", extra: "SECRET-DETAIL" }) /* extra field: still fine, but never shown */]; + for (const c of cases.slice(0, 6)) { + const nav = []; + const r = recorder(c); + const out = await FP.startCheckout({ tier: "team", interval: "month" }, { config: ON, fetch: r.fetch, attemptId: "x", navigate: (u) => nav.push(u) }); + assert.equal(out.ok, false); + assert.equal(out.text, FP.TEXT.checkoutFailed); + assert.deepEqual(nav, []); + } + const thrown = await FP.startCheckout({ tier: "team", interval: "month" }, { config: ON, fetch: async () => { throw new Error("net SECRET-DETAIL"); }, attemptId: "x", navigate: () => {} }); + assert.equal(thrown.text, FP.TEXT.checkoutFailed); + assert.doesNotMatch(JSON.stringify(thrown), /SECRET-DETAIL/); + const slow = await FP.startCheckout({ tier: "team", interval: "month" }, { config: ON, fetch: (u, init) => new Promise((_, rej) => init.signal.addEventListener("abort", () => rej(new Error("aborted")))), attemptId: "x", navigate: () => {}, timeoutMs: 20 }); + assert.equal(slow.text, FP.TEXT.checkoutFailed, "no answer in time ends on the fixed text"); +}); + +test("test_wb_7_the_success_page_makes_one_call_and_shows_only_the_three_states", async () => { + const sid = "cs_test_a1B2c3"; + for (const [body, want] of [[{ status: "key", key: "FS-KEY-123" }, "key"], [{ status: "processing" }, "processing"], [{ status: "none" }, "none"]]) { + const r = recorder(resp(200, body)); + const out = await FP.readEntitlement(sid, { config: ON, fetch: r.fetch }); + assert.equal(out.status, want); + assert.equal(r.calls.length, 1); + assert.equal(r.calls[0].url, ON.entitlement + "v1/entitlement?s=" + sid); + assert.equal(r.calls[0].init.method, "GET"); + assert.equal(r.calls[0].init.credentials, "omit"); + if (want === "key") assert.equal(out.key, "FS-KEY-123"); else assert.equal(out.key, undefined); + } + for (const body of [{ status: "key" }, { status: "key", key: 5 }, { status: "key", key: "" }, { status: "other" }, {}, "x"]) { + const out = await FP.readEntitlement(sid, { config: ON, fetch: recorder(resp(200, body)).fetch }); + assert.equal(out.status, "error", JSON.stringify(body)); + } + for (const c of [resp(400, { error: "bad_request" }), resp(503, { error: "unavailable" }), resp(429, "")]) { + const out = await FP.readEntitlement(sid, { config: ON, fetch: recorder(c).fetch }); + assert.equal(out.status, "error"); + } + const r = recorder(resp(200, { status: "none" })); + for (const bad of ["", "abc", "cs_test_", "cs_test_a&b=1", "cs_test_a b", "cs_live_x", "cs_test_a%20b", null, undefined]) { + const out = await FP.readEntitlement(bad, { config: ON, fetch: r.fetch }); + assert.equal(out.status, "none", "no usable session id: the fixed nothing text, and no call: " + bad); + } + assert.equal(r.calls.length, 0); +}); + +test("test_wb_7_session_id_comes_from_the_address_only_when_it_is_exactly_s", () => { + assert.equal(FP.sessionFromSearch("?s=cs_test_abc123"), "cs_test_abc123"); + for (const bad of ["", "?", "?s=", "?x=cs_test_a", "?s=cs_test_a&t=1", "?s=cs_live_a", "?s=cs_test_a%2F", "?s=cs_test_a&s=cs_test_b"]) assert.equal(FP.sessionFromSearch(bad), "", bad); +}); + +function fakeEl() { + return { textContent: "", hidden: false, attrs: {}, children: [], setAttribute(k, v) { this.attrs[k] = v; }, appendChild(c) { this.children.push(c); }, addEventListener() {} }; +} + +test("test_wb_8_values_are_inserted_as_text_not_markup", () => { + const evil = ''; + const box = fakeEl(), note = fakeEl(); + const doc = { getElementById: (id) => ({ "fp-key": box, "fp-note": note })[id] || fakeEl() }; + FP.renderEntitlement({ status: "key", key: evil }, doc); + assert.equal(box.textContent, evil, "set as text"); + assert.equal(box.innerHTML, undefined, "never touched as markup"); + const src = fs.readFileSync(path.join(root, "checkout.js"), "utf8"); + assert.doesNotMatch(src, /innerHTML|outerHTML|insertAdjacentHTML|document\.write|\beval\(|new Function/); +}); + +test("test_wb_9_nothing_stored_and_the_session_id_leaves_the_address_bar", () => { + const src = fs.readFileSync(path.join(root, "checkout.js"), "utf8"); + assert.doesNotMatch(src, /localStorage|sessionStorage|document\.cookie|indexedDB|console\./); + const calls = []; + FP.scrubAddress({ pathname: "/checkout/success/", search: "?s=cs_test_abc", hash: "" }, { replaceState: (...a) => calls.push(a), state: null }); + assert.equal(calls.length, 1); + assert.equal(calls[0][2], "/checkout/success/", "the address is rewritten without the id"); + assert.equal(JSON.stringify(calls[0][0]), "null"); +}); + +test("test_wb_10_resend_posts_only_the_email_in_the_body_and_shows_fixed_text", async () => { + const r = recorder(resp(202, { status: "ok" })); + const out = await FP.sendResend("buyer@example.com", { config: ON, fetch: r.fetch }); + assert.equal(out.ok, true); + assert.equal(out.text, FP.TEXT.resendSent); + const c = r.calls[0]; + assert.equal(c.url, ON.resend + "v1/resend"); + assert.equal(c.init.method, "POST"); + assert.deepEqual(JSON.parse(c.init.body), { email: "buyer@example.com" }); + assert.equal(c.url.includes("buyer"), false, "never in the address"); + assert.deepEqual(Object.keys(c.init.headers).map((k) => k.toLowerCase()), ["content-type"]); + assert.equal(c.init.credentials, "omit"); + assert.match(FP.TEXT.resendSent, /if this address has a purchase, we sent the key/i); + assert.equal(FP.TEXT.resendPromise, "We never store the address you type. It is used once to send your key and then discarded. Amazon's email service may keep an address that bounces."); + const bad = await FP.sendResend("not an address", { config: ON, fetch: r.fetch }); + assert.equal(bad.ok, false); + assert.equal(r.calls.length, 1, "an invalid address makes no call"); +}); + +test("test_wb_12_resend_failure_is_fixed_text_and_claims_nothing", async () => { + for (const c of [resp(400, { error: "bad_request" }), resp(503, { error: "unavailable" }), resp(200, { status: "ok" }), resp(202, { status: "no" }), resp(202, "x"), resp(429, "")]) { + const out = await FP.sendResend("buyer@example.com", { config: ON, fetch: recorder(c).fetch }); + assert.equal(out.ok, false); + assert.equal(out.text, FP.TEXT.resendFailed); + assert.doesNotMatch(out.text, /sent/i, "no claim about an email"); + } + const t = await FP.sendResend("buyer@example.com", { config: ON, fetch: async () => { throw new Error("x"); } }); + assert.equal(t.text, FP.TEXT.resendFailed); +}); + +test("test_wb_11_resend_is_the_only_page_with_an_email_field", () => { + const pages = files(root, ".html").filter((p) => !p.includes(`${path.sep}.claude${path.sep}`)); + const withField = pages.filter((p) => /<(input|textarea|form|select)\b/i.test(fs.readFileSync(p, "utf8"))).map((p) => path.relative(root, p)); + assert.deepEqual(withField, [path.join("resend-key", "index.html")]); + const js = fs.readFileSync(path.join(root, "checkout.js"), "utf8"); + assert.match(js, /one exception/i, "the code says why the one field exists"); +}); + +test("test_sb_10_4_cancel_page_static", () => { + const html = fs.readFileSync(path.join(root, "checkout", "cancel", "index.html"), "utf8"); + assert.match(html, /No charge was made\./); + assert.match(html, /href="\/#pricing"|href="\/buy\/"/); + assert.doesNotMatch(html, /