Files
Hermes 9b9711bf24
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
DC-057: close checkout-to-license contract drift (grade B)
Canonical product catalog at src/billing/catalog.js shared by Stripe
Checkout client (src/billing/stripe-client.js), webhook bridge
(scripts/stripe-license-bridge.js), and pricing page
(status/pricing/index.html). One-time payment keyed by productId at
$20/$50/$70/$99 — no more monthly/annual subscription drift.

Bridge resolves duration via metadata.productId (single contract),
requires payment_status === 'paid' before fulfillment (rejects
unpaid/no_payment_required/missing with ack 200), handles
async_payment_succeeded for ACH/SEPA delayed-payment flow. License
persisted to fulfillment-store BEFORE email — SMTP failure path serves
the persisted code via the new /api/v1/billing/lookup/:sessionId
endpoint (the documented customer recovery path).

Layer-1 (event-id) + layer-2 (session-id) idempotency prevent
duplicate issuance. Checkout return URLs derived from
STRIPE_PUBLIC_ORIGIN or STRIPE_ALLOWED_HOSTS (not raw Host header) —
closes host-header-poisoning + session-ID-leak attack class.

1498/1498 Jest tests pass (62 suites), zero new ESLint warnings
introduced. Test files:
  - stripe-license-bridge.test.js (24 tests)
  - billing-lookup.test.js (8 tests, HTTP-level)
  - bridge-lookup-http.test.js (5 tests, uses exported createServer)
  - pricing-page-catalog.test.js (9 tests, per-tier consistency)
  - checkout-origin.test.js (6 tests, host injection rejection)
  - stripe-client.test.js (rewrite for productId + mode:payment)

Bridge code refactored: handleWebhook decomposed into verifySignature +
parseEventBody + checkEventIdempotency + fulfillCheckout +
ensureLicensePersisted (under ESLint complexity=20 cap). New
createServer()/createRequestHandler() factories guarded by
require.main === module.

Removed 3 stale test files from the rolled-back DC-055 attempt.
2026-08-04 14:18:49 -07:00

232 lines
10 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DashCaddy — Your Pro License</title>
<link rel="canonical" href="/billing/success">
<link rel="stylesheet" href="/assets/dashboard.css">
<style>
:root { color-scheme: dark; --bg:#09111f; --card:#111c2e; --text:#e8edf5; --muted:#aab7ca; --accent:#68a4ff; --border:#263750; --pro:#7cf2c0; --danger:#ff9090; --warn:#ffd07f; }
* { box-sizing: border-box; }
body { margin: 0; background: linear-gradient(145deg,#07101d,#101b31); color: var(--text); font: 16px/1.7 system-ui,-apple-system,Segoe UI,Roboto,sans-serif; }
main { width: min(720px, calc(100% - 32px)); margin: 48px auto; padding: clamp(24px,5vw,48px); }
.eyebrow { color: var(--accent); font-weight: 700; text-transform: uppercase; letter-spacing: .12em; font-size: .85rem; }
h1 { margin: 8px 0 0; font-size: clamp(1.8rem,5vw,2.5rem); }
.lede { color: var(--muted); }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 28px; margin-top: 24px; }
.card.ready { border-color: var(--pro); box-shadow: 0 0 0 1px rgba(124,242,192,.25); }
.card.warn { border-color: var(--warn); }
.card.error { border-color: var(--danger); }
.key { font: 600 1.2rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; background: #06101e; padding: 16px; border-radius: 10px; border: 1px dashed var(--border); word-break: break-all; user-select: all; }
.row { display: flex; gap: 12px; align-items: center; margin-top: 16px; }
button { cursor: pointer; border: 0; padding: 10px 16px; border-radius: 10px; font: inherit; font-weight: 600; background: #1a2742; color: var(--text); border: 1px solid var(--border); }
button.primary { background: var(--pro); color: #052016; border-color: var(--pro); }
button:disabled { opacity: .6; cursor: not-allowed; }
.meta { color: var(--muted); font-size: .9rem; margin-top: 8px; }
.next-steps { margin-top: 24px; }
.next-steps ol { padding-left: 20px; }
.next-steps li { margin: 6px 0; color: var(--muted); }
.next-steps li strong { color: var(--text); }
a { color: var(--accent); }
.copied { color: var(--pro); font-size: .9rem; margin-left: 8px; }
.spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--muted); border-top-color: var(--accent); border-radius: 50%; animation: spin .8s linear infinite; margin-right: 8px; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 600px) { .card { padding: 20px; } }
</style>
</head>
<body>
<main>
<div class="eyebrow">DashCaddy</div>
<h1>Thanks for your purchase!</h1>
<p class="lede">Your Pro license code is shown below. We've also sent it to your email as a backup — keep it safe.</p>
<div id="card" class="card">
<div id="loading"><span class="spinner"></span>Generating your license…</div>
<div id="ready" hidden>
<div class="meta" id="meta"></div>
<div class="key" id="key" aria-live="polite"></div>
<div class="row">
<button id="copy" class="primary" type="button">Copy license key</button>
<span id="copied" class="copied" hidden>Copied!</span>
</div>
</div>
<div id="processing" hidden>
Your payment was received. The license is being generated — this page will update automatically. You can also check your email.
</div>
<div id="warn" hidden>
<strong>Email delivery didn't complete.</strong> Your license code is below — save it now. We're retrying email delivery on our side.
<div class="meta" id="warn-meta" style="margin-top:12px"></div>
<div class="key" id="key-warn" aria-live="polite"></div>
<div class="row">
<button id="copy-warn" type="button">Copy license key</button>
</div>
</div>
<div id="error" hidden>
<strong>We can't find a record for this session yet.</strong> This page refreshes every 1.5 seconds. If you closed Stripe before being redirected back, your license has been emailed to you.
<div class="meta" id="error-meta" style="margin-top:12px"></div>
</div>
</div>
<div class="next-steps">
<h2 style="font-size:1.25rem;margin-bottom:8px">How to install your key</h2>
<ol>
<li>Open your DashCaddy host: <strong>https://&lt;your-host&gt;</strong></li>
<li>Sign in (TOTP or email magic link)</li>
<li>Go to <strong>Settings → License</strong> (path: <code>/admin/license</code>)</li>
<li>Paste the key and click <strong>Activate license</strong></li>
<li>Pro features (unlimited users, public share links, Tailscale-mediated share) unlock immediately</li>
</ol>
<p class="meta">Need help? Reply to the receipt email or open an issue at <a href="https://github.com/sami7777/dashcaddy/issues" rel="noopener">github.com/sami7777/dashcaddy</a>. 14-day pro-rated refunds per the <a href="/legal/terms">Terms of Service</a>.</p>
</div>
</main>
<script>
(function () {
'use strict';
var sessionId = new URLSearchParams(window.location.search).get('session_id');
if (!sessionId) {
// No session_id — Stripe didn't redirect here from a checkout. Show a friendly fallback.
document.getElementById('card').classList.add('error');
document.getElementById('loading').hidden = true;
document.getElementById('error').hidden = false;
document.getElementById('error-meta').textContent = 'Missing session_id in URL.';
return;
}
var card = document.getElementById('card');
var loadingEl = document.getElementById('loading');
var readyEl = document.getElementById('ready');
var processingEl = document.getElementById('processing');
var warnEl = document.getElementById('warn');
var errorEl = document.getElementById('error');
var metaEl = document.getElementById('meta');
var warnMetaEl = document.getElementById('warn-meta');
var keyEl = document.getElementById('key');
var keyWarnEl = document.getElementById('key-warn');
var copyBtn = document.getElementById('copy');
var copyWarnBtn = document.getElementById('copy-warn');
var copiedEl = document.getElementById('copied');
var POLL_INTERVAL_MS = 1500;
var POLL_TIMEOUT_MS = 60 * 1000;
var startTime = Date.now();
function showReady(code, durationDays, productId, deliveredVia) {
loadingEl.hidden = true;
readyEl.hidden = false;
card.classList.add('ready');
keyEl.textContent = code;
metaEl.textContent = durationDays + '-day Pro license (product ' + productId + ')' +
(deliveredVia ? ' · delivered via ' + deliveredVia : '') +
' · also emailed to you';
}
function showProcessing() {
loadingEl.hidden = true;
processingEl.hidden = false;
}
function showWarn(code, durationDays, productId, lastError, deliveredVia) {
loadingEl.hidden = true;
warnEl.hidden = false;
card.classList.add('warn');
keyWarnEl.textContent = code;
warnMetaEl.textContent = durationDays + '-day Pro license (product ' + productId + ')' +
(lastError ? ' · last email error: ' + lastError : '') +
(deliveredVia ? ' · attempted via ' + deliveredVia : '');
}
function showError(reason) {
loadingEl.hidden = true;
errorEl.hidden = false;
card.classList.add('error');
if (reason) document.getElementById('error-meta').textContent = reason;
}
function copyFromTextarea(text, cb) {
// Use the modern Clipboard API; fall back to a hidden textarea + execCommand.
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text).then(cb).catch(function () {
fallbackCopy(text, cb);
});
} else {
fallbackCopy(text, cb);
}
}
function fallbackCopy(text, cb) {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'absolute';
ta.style.left = '-9999px';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); cb(); } catch (_) { /* swallow */ }
document.body.removeChild(ta);
}
copyBtn.addEventListener('click', function () {
copyFromTextarea(keyEl.textContent, function () {
copiedEl.hidden = false;
setTimeout(function () { copiedEl.hidden = true; }, 2000);
});
});
copyWarnBtn.addEventListener('click', function () {
copyFromTextarea(keyWarnEl.textContent, function () { /* no-op */ });
});
function tick() {
if (Date.now() - startTime > POLL_TIMEOUT_MS) {
showError('Timed out waiting for the bridge to generate the license. Your license has been emailed to you — please check your inbox.');
return;
}
fetch('/api/v1/billing/lookup/' + encodeURIComponent(sessionId), { cache: 'no-store' })
.then(function (r) { return r.json().then(function (b) { return { status: r.status, body: b }; }); })
.then(function (resp) {
var data = resp.body && resp.body.data ? resp.body.data : null;
if (resp.status === 200 && data) {
if (data.status === 'delivered') {
showReady(data.code, data.durationDays, data.productId, data.deliveredVia);
return;
}
if (data.status === 'pending_email' && data.code) {
showWarn(data.code, data.durationDays, data.productId, data.lastError, data.deliveredVia);
return;
}
if (data.status === 'processing') {
showProcessing();
setTimeout(tick, POLL_INTERVAL_MS);
return;
}
}
if (resp.status === 404 && resp.body && /expired/i.test(resp.body.error || '')) {
showError('License lookup window has expired. Your key was emailed to you.');
return;
}
if (resp.status === 404) {
// Payment still processing — keep polling.
showProcessing();
setTimeout(tick, POLL_INTERVAL_MS);
return;
}
showError('Unexpected response: ' + (resp.body && resp.body.error ? resp.body.error : 'HTTP ' + resp.status));
})
.catch(function () {
// Transient network error — keep trying.
setTimeout(tick, POLL_INTERVAL_MS);
});
}
tick();
})();
</script>
</body>
</html>