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 <head>; CTA calls SUCH_APP_startCheckout, else
  falls through to the store links (fail-closed). Store/itch buttons unchanged.
This commit is contained in:
jwinterm
2026-08-01 10:34:19 -04:00
parent 96e071350c
commit c5dcf5cd2b
3 changed files with 167 additions and 2 deletions
+14
View File
@@ -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
};
+143
View File
@@ -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);
}
})();
})();
+10 -2
View File
@@ -10,6 +10,11 @@
<!-- Google AdSense — auto ads enabled at account level -->
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-2501747033825166"
crossorigin="anonymous"></script>
<!-- Fail-closed web-app → checkout wiring. Inert until Operations populates
app-config.js (OIDC client + shop tenant); no secrets. -->
<script src="app-config.js"></script>
<script src="checkout.js"></script>
</head>
<body>
@@ -30,10 +35,13 @@
</div>
<div id="nag-store-links">
<!-- PRIMARY: our own website sales funnel -->
<!-- PRIMARY: real checkout when configured; else falls through to the apex
marketing site. checkout.js returns true when it starts OIDC login (we
then preventDefault); false → the href proceeds (fail-closed). -->
<a href="https://bauhausecho.com"
target="_blank" rel="noopener noreferrer"
class="nag-btn nag-btn-primary">
class="nag-btn nag-btn-primary"
onclick="return !(window.SUCH_APP_startCheckout &amp;&amp; window.SUCH_APP_startCheckout())">
Get the Full Game ▸
</a>