From c5dcf5cd2b2de6b46c430bb8d78743541cd0c5c7 Mon Sep 17 00:00:00 2001 From: jwinterm Date: Sat, 1 Aug 2026 10:34:19 -0400 Subject: [PATCH] web checkout: fail-closed CTA -> OIDC PKCE -> shop -> redemption - app-config.js: public config placeholder (all null/false -> INERT). Operations populates OIDC client + shop tenant + checkout URL at deploy; no secrets. - checkout.js: Authorization Code + S256 PKCE (public client, no secret), state CSRF guard, token in sessionStorage, code/state scrubbed from URL. Resolves premium via the ledger's per-user /me/entitlements (user's own OIDC token, never the shared read token) and sets window.SUCH_APP.premium for WebBillingManager. Redemption also resolves on page reload / next login (crypto is webhook-driven). - index.html: load config+checkout in ; CTA calls SUCH_APP_startCheckout, else falls through to the store links (fail-closed). Store/itch buttons unchanged. --- app-config.js | 14 +++++ checkout.js | 143 ++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 12 ++++- 3 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 app-config.js create mode 100644 checkout.js diff --git a/app-config.js b/app-config.js new file mode 100644 index 0000000..2856d30 --- /dev/null +++ b/app-config.js @@ -0,0 +1,14 @@ +// Public web-app config. NO SECRETS — only public values Operations populates at +// deploy time once the OIDC client + shop tenant exist. All null/false here → the +// checkout flow is INERT (fail-closed): the "Get the Full Game" CTA falls through +// to the store links, premium stays false, guest-first play is untouched. +// +// Operations replaces this file (or the values) at deploy; the client never holds +// a secret — per-user premium is resolved via the user's own OIDC session. +window.SUCH_APP_CONFIG = { + oidcIssuer: null, // e.g. https://id.such.software (SUCH_APP_OIDC_ISSUER) + oidcClientId: null, // PUBLIC native/SPA client id (SUCH_APP_OIDC_CLIENT_ID) + ledgerBase: null, // e.g. https://entitlements.such.software (such-entitlement-ledger) + checkoutUrl: null, // bauhaus-scoped shop entry, e.g. https://shop.bauhausecho.com/... + checkoutEnabled: false, // master flag — CLOSED until the tenant is provisioned + activated +}; diff --git a/checkout.js b/checkout.js new file mode 100644 index 0000000..6eab5e6 --- /dev/null +++ b/checkout.js @@ -0,0 +1,143 @@ +/* + * Fail-closed web-app → checkout wiring for Bauhaus Echo. + * + * Flow (only when window.SUCH_APP_CONFIG is fully populated + checkoutEnabled): + * CTA "Get the Full Game" → neutral OIDC login (Authorization Code + S256 PKCE, + * PUBLIC client, no secret) → hand off to the bauhaus-scoped Medusa storefront → + * Stripe / approved-crypto checkout → shop webhook → the entitlement ledger grants + * neutral `premium` → this script polls the ledger's per-user /me/entitlements with + * the user's OWN OIDC token and sets window.SUCH_APP.premium, which WebBillingManager + * (GWT) reads to flip the client (kills the AdSense nag, unlocks packs). Crypto is + * webhook-driven, so premium also resolves on the next page load / login. + * + * Everything is INERT until Operations populates the public config: unconfigured → + * the CTA falls through to the App Store / Google Play / itch links, premium stays + * false. No secrets here; the client never holds the ledger's shared read token. + */ +(function () { + 'use strict'; + + var CFG = window.SUCH_APP_CONFIG || {}; + var ISSUER = CFG.oidcIssuer || null; + var CLIENT = CFG.oidcClientId || null; + var LEDGER = CFG.ledgerBase || null; + var SHOP_URL = CFG.checkoutUrl || null; + var ENABLED = CFG.checkoutEnabled === true; + var REDIRECT = window.location.origin + window.location.pathname; + var SCOPE = 'openid'; + + // configured = every piece needed to run the real flow is present. + var configured = !!(ENABLED && ISSUER && CLIENT && LEDGER && SHOP_URL); + + // The single source WebBillingManager (GWT) reads. premium left undefined until + // resolved → GWT reads false (fail-closed). + window.SUCH_APP = window.SUCH_APP || {}; + window.SUCH_APP.checkoutEnabled = configured; + window.SUCH_APP.checkoutUrl = SHOP_URL; + + var enc = encodeURIComponent; + function form(obj) { + return Object.keys(obj).filter(function (k) { return obj[k] != null; }) + .map(function (k) { return enc(k) + '=' + enc(obj[k]); }).join('&'); + } + function trimSlash(s) { return String(s).replace(/\/+$/, ''); } + + // base64url of raw bytes + function b64url(bytes) { + var s = ''; + for (var i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + } + function randToken() { + var a = new Uint8Array(32); + crypto.getRandomValues(a); + return b64url(a); + } + async function sha256b64url(str) { + var digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); + return b64url(new Uint8Array(digest)); + } + + var K_VERIFIER = 'such_pkce_verifier'; + var K_STATE = 'such_oidc_state'; + var K_TOKEN = 'such_access_token'; + var K_PENDING = 'such_pending_checkout'; + + // Begin OIDC login (redirects away). Marks the intent to buy so we hand off to + // the shop after the token round-trip. + async function startLogin() { + var verifier = randToken(); + var state = randToken(); + sessionStorage.setItem(K_VERIFIER, verifier); + sessionStorage.setItem(K_STATE, state); + sessionStorage.setItem(K_PENDING, '1'); + var challenge = await sha256b64url(verifier); + var url = trimSlash(ISSUER) + '/authorize?' + form({ + client_id: CLIENT, redirect_uri: REDIRECT, response_type: 'code', + scope: SCOPE, code_challenge: challenge, code_challenge_method: 'S256', state: state, + }); + window.location.assign(url); + } + + // Complete the code→token exchange if we returned from the IdP with ?code. + async function completeLoginIfCallback() { + var q = new URLSearchParams(window.location.search); + var code = q.get('code'); + var state = q.get('state'); + if (!code) return; + var ok = state && state === sessionStorage.getItem(K_STATE); // CSRF guard + var verifier = sessionStorage.getItem(K_VERIFIER); + // Always scrub code/state from the URL + one-time PKCE material. + history.replaceState({}, document.title, REDIRECT); + sessionStorage.removeItem(K_VERIFIER); + sessionStorage.removeItem(K_STATE); + if (!ok || !verifier || !configured) return; + try { + var res = await fetch(trimSlash(ISSUER) + '/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: form({ grant_type: 'authorization_code', code: code, redirect_uri: REDIRECT, + client_id: CLIENT, code_verifier: verifier }), + }); + if (res.ok) { + var tok = await res.json(); + if (tok && tok.access_token) sessionStorage.setItem(K_TOKEN, tok.access_token); + } + } catch (e) { /* fail-closed */ } + } + + // Resolve the neutral premium capability from the user's own OIDC token. + async function resolvePremium() { + var token = sessionStorage.getItem(K_TOKEN); + if (!token || !LEDGER) return; + try { + var res = await fetch(trimSlash(LEDGER) + '/me/entitlements', { + headers: { Authorization: 'Bearer ' + token, Accept: 'application/json' }, + }); + if (res.ok) { + var data = await res.json(); + window.SUCH_APP.premium = !!(data && data.premium === true); + } + } catch (e) { /* fail-closed */ } + } + + // Called by the CTA. Returns true if it handled the click (login started), false + // to let the anchor's store links proceed (unconfigured / fail-closed). + window.SUCH_APP_startCheckout = function () { + if (!configured) return false; + startLogin(); + return true; + }; + + // On load: finish any OIDC callback, resolve premium (returning buyers + the + // post-purchase page reload flip premium before the GWT game boots), then — if we + // logged in intending to buy and don't already own it — hand off to the shop. + (async function () { + await completeLoginIfCallback(); + await resolvePremium(); + if (configured && sessionStorage.getItem(K_PENDING) === '1') { + sessionStorage.removeItem(K_PENDING); + if (window.SUCH_APP.premium !== true) window.location.assign(SHOP_URL); + } + })(); +})(); diff --git a/index.html b/index.html index 83aa891..6a8ecb5 100644 --- a/index.html +++ b/index.html @@ -10,6 +10,11 @@ + + + + @@ -30,10 +35,13 @@