mirror of
https://github.com/Such-Software/bauhaus-echo-web.git
synced 2026-10-17 05:51:24 +02:00
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:
@@ -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
@@ -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
@@ -10,6 +10,11 @@
|
|||||||
<!-- Google AdSense — auto ads enabled at account level -->
|
<!-- Google AdSense — auto ads enabled at account level -->
|
||||||
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-2501747033825166"
|
<script async src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-2501747033825166"
|
||||||
crossorigin="anonymous"></script>
|
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>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -30,10 +35,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="nag-store-links">
|
<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"
|
<a href="https://bauhausecho.com"
|
||||||
target="_blank" rel="noopener noreferrer"
|
target="_blank" rel="noopener noreferrer"
|
||||||
class="nag-btn nag-btn-primary">
|
class="nag-btn nag-btn-primary"
|
||||||
|
onclick="return !(window.SUCH_APP_startCheckout && window.SUCH_APP_startCheckout())">
|
||||||
Get the Full Game ▸
|
Get the Full Game ▸
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user