Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions bin/check-file-allowlist.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions buy/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Choose a plan</title>
<meta name="description" content="Choose a FosterStack plan. Draft, test mode.">
<meta name="robots" content="noindex">
<link rel="canonical" href="https://fosterstack.com/buy/">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style>
:root {
--bg: #ffffff; --fg: #16181d; --muted: #5b6472; --line: #e4e7ec;
--accent: #0d7a5f; --accent-fg: #ffffff; --card: #f6f8f9; --warn-bg: #fff7ed; --warn-line: #fdba74;
--mark: #0f4d66;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #101318; --fg: #e8eaf0; --muted: #98a2b3; --line: #262c36;
--accent: #2fbf95; --accent-fg: #08110e; --card: #171c23; --warn-bg: #241a10; --warn-line: #7c4a12;
--mark: #e8eaf0;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg); color: var(--fg);
font: 16px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
main { max-width: 44rem; margin: 0 auto; padding: 0 1.25rem 4rem; }
header.site { max-width: 44rem; margin: 0 auto; padding: 1.25rem; display: flex; align-items: baseline; gap: .6rem; }
.logo { font-weight: 700; font-size: 1.05rem; letter-spacing: -.01em; }
.logo span { color: var(--accent); }
.logo .mark { width: 1.2em; height: 1.2em; vertical-align: -.24em; margin-right: .4em; }
.tag { color: var(--muted); font-size: .85rem; }
.notice {
background: var(--warn-bg); border: 1px solid var(--warn-line); border-radius: 8px;
padding: .7rem 1rem; font-size: .92rem; margin: 1.5rem 0 2.5rem;
}
h1 { font-size: 2rem; line-height: 1.2; letter-spacing: -.02em; margin-bottom: 1rem; }
.lede { font-size: 1.12rem; color: var(--muted); margin-bottom: 2rem; }
h2 { font-size: 1.25rem; margin: 3rem 0 .9rem; letter-spacing: -.01em; }
p + p { margin-top: .8rem; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: .8rem; margin-top: 1rem; }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 1rem 1.1rem; }
.card h3 { font-size: .98rem; margin-bottom: .35rem; }
.card p { font-size: .9rem; color: var(--muted); }
ul.trust { list-style: none; margin-top: .5rem; }
ul.trust li { padding: .45rem 0 .45rem 1.6rem; position: relative; }
ul.trust li::before { content: "✓"; position: absolute; left: .2rem; color: var(--accent); font-weight: 700; }
code, pre { font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: .88em; }
code { background: var(--card); border: 1px solid var(--line); border-radius: 5px; padding: .1em .35em; }
pre code { background: none; border: 0; padding: 0; color: inherit; font-size: 1em; }
button {
padding: .7rem 1.3rem; font-size: 1rem; font-weight: 600; cursor: pointer;
background: var(--accent); color: var(--accent-fg); border: 0; border-radius: 8px;
}
button:disabled { opacity: .6; cursor: default; }
table { width: 100%; border-collapse: collapse; font-size: .92rem; margin-top: 1rem; }
th, td { text-align: left; padding: .55rem .6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
th { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em; color: var(--muted); }
pre { background: #0f172a; color: #e2e8f0; padding: .9rem 1rem; border-radius: 8px;
overflow-x: auto; font-size: .9rem; line-height: 1.5; }
.cta-row { display: flex; gap: .6rem; flex-wrap: wrap; margin-top: 1.1rem; }
.btn { display: inline-block; padding: .55rem 1rem; border-radius: 8px; font-weight: 600;
text-decoration: none; background: var(--accent); color: #fff; }
.btn-secondary { background: transparent; color: inherit; border: 1px solid #cbd5e1; }
.muted-note { color: var(--muted); font-size: .93rem; margin-top: 1.1rem; }
footer { border-top: 1px solid var(--line); margin-top: 4rem; padding: 1.5rem 1.25rem 3rem; }
footer div { max-width: 44rem; margin: 0 auto; color: var(--muted); font-size: .82rem; }
a { color: var(--accent); }
</style>
</head>
<body>
<header class="site">
<div class="logo"><svg class="mark" viewBox="0 0 96 96" aria-hidden="true" focusable="false"><rect x="12" y="14" width="72" height="18" rx="5" fill="var(--mark)"/><rect x="12" y="39" width="48" height="18" rx="5" fill="var(--mark)"/><rect x="12" y="64" width="26" height="18" rx="5" fill="var(--accent)"/></svg>foster<span>stack</span></div>
<div class="tag">Infrastructure worth keeping — and kept patched.</div>
</header>
<main>
<p class="notice">DRAFT, test mode only. Every line marked TODO-COPY is a placeholder for the owner's wording.</p>
<h1>Choose a plan</h1>
<p class="lede">TODO-COPY: one line on what the plans are.</p>
<p id="fp-closed" class="notice">TODO-COPY: Checkout is not open yet.</p>
<div id="fp-open" hidden>
<div class="cards">
<div class="card"><h3>Team</h3><p>$49 a month, or $490 a year.</p>
<p>TODO-COPY: one-line description.</p>
<div class="cta-row"><button data-buy-tier="team" data-buy-interval="month">Team, monthly</button> <button data-buy-tier="team" data-buy-interval="year">Team, yearly</button></div></div>
<div class="card"><h3>Business</h3><p>$199 a month, or $1,990 a year.</p>
<p>TODO-COPY: one-line description.</p>
<div class="cta-row"><button data-buy-tier="business" data-buy-interval="month">Business, monthly</button> <button data-buy-tier="business" data-buy-interval="year">Business, yearly</button></div></div>
<div class="card"><h3>Compliance</h3><p>$5,988 a year.</p>
<p>TODO-COPY: one-line description.</p>
<div class="cta-row"><button data-buy-tier="compliance" data-buy-interval="year">Compliance, yearly</button></div></div>
</div>
<p class="muted-note">Monthly plans: This plan renews automatically every month until you cancel. Cancel any time; you keep access to the end of the paid month; no refunds.</p>
<p class="muted-note">Yearly plans: This plan renews automatically every year until you cancel. Cancel any time before the renewal date; you keep access to the end of the paid year; no refunds, except a full refund of the annual payment if we retire the product.</p>
<p class="muted-note">Read the <a href="/terms-of-sale/">terms of sale</a> first. Payment, your address and your receipt are handled on Stripe's own page; we never see or keep them.</p>
<p id="fp-status" role="status"></p>
</div>
<p class="muted-note">Lost your key? <a href="/resend-key/">Send it again</a>.</p>
</main>
<footer>
<div>
<p>FosterStack is not affiliated with, endorsed by, or sponsored by Gradle Inc.
"Gradle" and "Develocity" are trademarks of Gradle Inc., used here only to identify
compatibility. FosterStack contains no Gradle Inc. code; it is a clean-room implementation
of the documented remote build cache HTTP protocol. Apache Maven is a trademark of the
Apache Software Foundation; the Maven Build Cache Extension is Apache's project, not
ours.</p>
<p style="margin-top:.6rem">© 2026 FosterStack · <a href="/regulated/">For regulated markets</a> · <a href="https://github.com/fosterstack/cache">github.com/fosterstack/cache</a> · <a href="mailto:hello@fosterstack.com">hello@fosterstack.com</a></p>
</div>
</footer>
<script src="/checkout-config.js"></script>
<script src="/checkout.js"></script>
</body>
</html>
4 changes: 4 additions & 0 deletions checkout-config.js
Original file line number Diff line number Diff line change
@@ -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 owner's 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: "" };
171 changes: 171 additions & 0 deletions checkout.js
Original file line number Diff line number Diff line change
@@ -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 owner's ratified exception to "no form on www" (SB-10.6, revised Q9); 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 a placeholder for the owner's wording (checkout wording draft, items 2 to 4); the lines marked ratified are exact.
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 };
});
Loading
Loading