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.
This commit is contained in:
@@ -85,6 +85,7 @@ const eventsRoutes = require('../routes/events');
|
||||
const workflowsRoutes = require('../routes/workflows');
|
||||
const dependenciesRoutes = require('../routes/dependencies');
|
||||
const securityRoutes = require('../routes/security');
|
||||
const billingRoutes = require('../routes/billing');
|
||||
const DependencyManager = require('./managers/dependency-manager');
|
||||
const autoRestartRoutes = require('../routes/auto-restart');
|
||||
const configDriftRoutes = require('../routes/config-drift');
|
||||
@@ -528,6 +529,11 @@ async function createApp() {
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
log: ctx.log,
|
||||
}));
|
||||
// DC-055: billing is PUBLIC (customer hasn't paid yet → no session).
|
||||
// Stripe-session creation only; the webhook side runs in scripts/stripe-license-bridge.js.
|
||||
apiRouter.use('/billing', billingRoutes({
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
}));
|
||||
apiRouter.use('/dns', dnsRoutes({
|
||||
dns: ctx.dns,
|
||||
siteConfig: ctx.siteConfig,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Canonical DashCaddy Pro product catalog.
|
||||
*
|
||||
* Keep product identity, license duration, USD amount, and the Stripe price
|
||||
* environment variable in one place. Checkout (src/billing/stripe-client.js),
|
||||
* the webhook bridge (scripts/stripe-license-bridge.js), the public pricing
|
||||
* page (status/pricing/index.html), and tests must NOT maintain separate
|
||||
* product lists — all of them import from here.
|
||||
*
|
||||
* Pricing source of truth: PRODUCT-SPEC-DECISIONS.md (locked 2026-07-20).
|
||||
*
|
||||
* Lifecycle:
|
||||
* - To add a new tier: append a new frozen product entry below, then add
|
||||
* the matching STRIPE_PRICE_PRO_*<duration>D environment variable to
|
||||
* the deployment. The pricing page (status/pricing/index.html) and
|
||||
* the catalog consistency test (__tests__/billing/pricing-page-catalog.test.js)
|
||||
* will both fail until the pricing page is updated in lockstep — this
|
||||
* is the documented drift guard.
|
||||
* - To change a price: edit the matching product entry AND the pricing
|
||||
* page's hardcoded price label (status/pricing/index.html). The
|
||||
* pricing-page-catalog.test.js enforces the two match.
|
||||
*
|
||||
* Note: The pricing page hard-codes the 4 product IDs, prices, and
|
||||
* duration strings (rather than being server-rendered from this catalog).
|
||||
* The hard-coding is intentional — the page is served as static HTML from
|
||||
* `status.sami/pricing` and never touches the live API. The
|
||||
* pricing-page-catalog.test.js enforces consistency between the two
|
||||
* sources, so any drift fails the test suite.
|
||||
*/
|
||||
|
||||
const PRODUCTS = Object.freeze([
|
||||
Object.freeze({
|
||||
id: 'pro-30d',
|
||||
durationDays: 30,
|
||||
amountCents: 2000,
|
||||
priceEnv: 'STRIPE_PRICE_PRO_30D',
|
||||
label: '1 month',
|
||||
priceLabel: '$20',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'pro-90d',
|
||||
durationDays: 90,
|
||||
amountCents: 5000,
|
||||
priceEnv: 'STRIPE_PRICE_PRO_90D',
|
||||
label: '3 months',
|
||||
priceLabel: '$50',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'pro-180d',
|
||||
durationDays: 180,
|
||||
amountCents: 7000,
|
||||
priceEnv: 'STRIPE_PRICE_PRO_180D',
|
||||
label: '6 months',
|
||||
priceLabel: '$70',
|
||||
}),
|
||||
Object.freeze({
|
||||
id: 'pro-365d',
|
||||
durationDays: 365,
|
||||
amountCents: 9900,
|
||||
priceEnv: 'STRIPE_PRICE_PRO_365D',
|
||||
label: '12 months',
|
||||
priceLabel: '$99',
|
||||
}),
|
||||
]);
|
||||
|
||||
const BY_ID = new Map(PRODUCTS.map((product) => [product.id, product]));
|
||||
|
||||
function listProducts() {
|
||||
return PRODUCTS.slice();
|
||||
}
|
||||
|
||||
function getProduct(productId) {
|
||||
return BY_ID.get(productId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the Stripe Price ID configured for a product. Returns '' if unset
|
||||
* (caller treats empty string as "this tier is not configured").
|
||||
*/
|
||||
function getConfiguredPrice(product, env = process.env) {
|
||||
if (!product) return '';
|
||||
return env[product.priceEnv] || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a Stripe Price ID back to a product. Used by the webhook bridge to
|
||||
* validate that a Checkout session's price matches a configured product
|
||||
* (defense against Stripe price-ID drift / repointing).
|
||||
*
|
||||
* Returns null when the price ID is unset or doesn't match any configured
|
||||
* product.
|
||||
*/
|
||||
function findProductByPriceId(priceId, env = process.env) {
|
||||
if (!priceId || typeof priceId !== 'string') return null;
|
||||
for (const product of PRODUCTS) {
|
||||
const configured = getConfiguredPrice(product, env);
|
||||
if (configured && configured === priceId) return product;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as getConfiguredPrice but returns the full product list with the
|
||||
* resolved Stripe Price ID merged in. Useful for the pricing page renderer.
|
||||
*/
|
||||
function getConfiguredProducts(env = process.env) {
|
||||
return PRODUCTS.map((product) => ({ ...product, priceId: getConfiguredPrice(product, env) }));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PRODUCTS,
|
||||
listProducts,
|
||||
getProduct,
|
||||
getConfiguredPrice,
|
||||
getConfiguredProducts,
|
||||
findProductByPriceId,
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Durable Stripe fulfillment state.
|
||||
*
|
||||
* The bridge and the API share this file through the host data mount. A
|
||||
* generated license is persisted before email delivery so a webhook retry can
|
||||
* resend the same key instead of minting a second valid key.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const crypto = require('crypto');
|
||||
const platformPaths = require('../../platform-paths');
|
||||
|
||||
const DELIVERY_LEASE_MS = 5 * 60 * 1000;
|
||||
|
||||
function createFulfillmentStore(options = {}) {
|
||||
const filePath = options.filePath
|
||||
|| process.env.STRIPE_FULFILLMENT_FILE
|
||||
|| path.join(platformPaths.dataDir, 'stripe-fulfillments.json');
|
||||
let queue = Promise.resolve();
|
||||
|
||||
function emptyState() {
|
||||
return { version: 1, byEventId: {}, bySessionId: {} };
|
||||
}
|
||||
|
||||
function readState() {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return emptyState();
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
if (!parsed || typeof parsed !== 'object'
|
||||
|| !parsed.byEventId || typeof parsed.byEventId !== 'object'
|
||||
|| !parsed.bySessionId || typeof parsed.bySessionId !== 'object') {
|
||||
throw new Error('fulfillment state has an invalid shape');
|
||||
}
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return emptyState();
|
||||
throw new Error(`Stripe fulfillment state unavailable: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeState(state) {
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const tmp = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomBytes(4).toString('hex')}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n', { mode: 0o600 });
|
||||
try {
|
||||
fs.renameSync(tmp, filePath);
|
||||
} catch (error) {
|
||||
try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ }
|
||||
throw error;
|
||||
}
|
||||
try { fs.chmodSync(filePath, 0o600); } catch (_) { /* best effort */ }
|
||||
}
|
||||
|
||||
function mutate(mutator) {
|
||||
const run = queue.then(async () => {
|
||||
const state = readState();
|
||||
const result = await mutator(state);
|
||||
if (result && result.changed) writeState(state);
|
||||
return result;
|
||||
});
|
||||
queue = run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
function readBySession(sessionId) {
|
||||
if (!sessionId) return null;
|
||||
const record = readState().bySessionId[sessionId];
|
||||
return record ? { ...record } : null;
|
||||
}
|
||||
|
||||
function readByEvent(eventId) {
|
||||
if (!eventId) return null;
|
||||
const record = readState().byEventId[eventId];
|
||||
return record ? { ...record } : null;
|
||||
}
|
||||
|
||||
async function claim({ eventId, sessionId, productId, durationDays, email }) {
|
||||
if (!eventId || !sessionId) throw new Error('eventId and sessionId are required');
|
||||
return mutate((state) => {
|
||||
const existing = state.bySessionId[sessionId] || state.byEventId[eventId];
|
||||
const now = Date.now();
|
||||
if (existing) {
|
||||
if (existing.status === 'generating' && existing.leaseUntil > now && existing.claimToken !== eventId) {
|
||||
return { changed: false, claimed: false, busy: true, record: { ...existing } };
|
||||
}
|
||||
if (existing.status === 'generating' && existing.leaseUntil <= now) {
|
||||
existing.claimToken = eventId;
|
||||
existing.leaseUntil = now + DELIVERY_LEASE_MS;
|
||||
state.byEventId[eventId] = existing;
|
||||
return { changed: true, claimed: true, busy: false, record: { ...existing } };
|
||||
}
|
||||
return { changed: false, claimed: false, busy: false, record: { ...existing } };
|
||||
}
|
||||
const record = {
|
||||
eventId, sessionId, productId, durationDays, email,
|
||||
status: 'generating', claimToken: eventId, leaseUntil: now + DELIVERY_LEASE_MS,
|
||||
createdAt: new Date(now).toISOString(), updatedAt: new Date(now).toISOString(),
|
||||
};
|
||||
state.byEventId[eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, claimed: true, busy: false, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function saveLicense({ eventId, sessionId, code, codeId }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId] || state.byEventId[eventId];
|
||||
if (!record || record.claimToken !== eventId) return { changed: false, saved: false, record: record ? { ...record } : null };
|
||||
record.code = code;
|
||||
record.codeId = codeId;
|
||||
record.status = 'pending_email';
|
||||
record.leaseUntil = 0;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.byEventId[eventId] = record;
|
||||
state.bySessionId[record.sessionId] = record;
|
||||
return { changed: true, saved: true, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function claimDelivery({ sessionId, ownerToken }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId];
|
||||
if (!record || !record.code) return { changed: false, claimed: false, record: record ? { ...record } : null };
|
||||
const now = Date.now();
|
||||
if (record.status === 'delivered') return { changed: false, claimed: false, record: { ...record } };
|
||||
if (record.status === 'delivering' && record.leaseUntil > now && record.leaseOwner !== ownerToken) {
|
||||
return { changed: false, claimed: false, busy: true, record: { ...record } };
|
||||
}
|
||||
record.status = 'delivering';
|
||||
record.leaseOwner = ownerToken;
|
||||
record.leaseUntil = now + DELIVERY_LEASE_MS;
|
||||
record.updatedAt = new Date(now).toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, claimed: true, busy: false, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function markDelivered({ sessionId, ownerToken, deliveredVia }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId];
|
||||
if (!record || record.leaseOwner !== ownerToken) return { changed: false, saved: false };
|
||||
record.status = 'delivered';
|
||||
record.deliveredVia = deliveredVia;
|
||||
record.deliveredAt = new Date().toISOString();
|
||||
record.lastError = null;
|
||||
record.leaseUntil = 0;
|
||||
record.leaseOwner = null;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, saved: true, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
async function markDeliveryFailed({ sessionId, ownerToken, error }) {
|
||||
return mutate((state) => {
|
||||
const record = state.bySessionId[sessionId];
|
||||
if (!record || record.leaseOwner !== ownerToken) return { changed: false, saved: false };
|
||||
record.status = 'pending_email';
|
||||
record.lastError = String(error || 'email delivery failed').slice(0, 500);
|
||||
record.leaseUntil = 0;
|
||||
record.leaseOwner = null;
|
||||
record.updatedAt = new Date().toISOString();
|
||||
state.byEventId[record.eventId] = record;
|
||||
state.bySessionId[sessionId] = record;
|
||||
return { changed: true, saved: true, record: { ...record } };
|
||||
});
|
||||
}
|
||||
|
||||
return { filePath, readBySession, readByEvent, claim, saveLicense, claimDelivery, markDelivered, markDeliveryFailed };
|
||||
}
|
||||
|
||||
module.exports = { createFulfillmentStore, DELIVERY_LEASE_MS };
|
||||
@@ -0,0 +1,200 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* DashCaddy Stripe client — DC-055 + DC-057.
|
||||
*
|
||||
* Thin wrapper around the Stripe SDK for the OUTBOUND side of one-time
|
||||
* license purchasing: creating Checkout Sessions that drive customers to
|
||||
* Stripe's hosted payment page.
|
||||
*
|
||||
* Inbound (webhook) handling lives in scripts/stripe-license-bridge.js
|
||||
* (DC-054 + DC-057) — that runs as its own process so the merchant's
|
||||
* Stripe webhook secret doesn't have to be loaded into the DashCaddy API
|
||||
* host process.
|
||||
*
|
||||
* # Pricing contract
|
||||
*
|
||||
* One-time payments keyed by `productId` from src/billing/catalog.js:
|
||||
*
|
||||
* pro-30d → $20 USD, 30-day license
|
||||
* pro-90d → $50 USD, 90-day license
|
||||
* pro-180d → $70 USD, 180-day license
|
||||
* pro-365d → $99 USD, 365-day license
|
||||
*
|
||||
* `mode: 'payment'` (NOT 'subscription'). The license is generated once
|
||||
* per Checkout completion and the customer pastes it into their host.
|
||||
* No recurring billing, no Stripe Customer object retained beyond the
|
||||
* session.
|
||||
*
|
||||
* # Configuration (env vars)
|
||||
*
|
||||
* Required to create sessions — failures are loud:
|
||||
* STRIPE_SECRET_KEY — Stripe API secret (sk_live_... | sk_test_...)
|
||||
* STRIPE_PRICE_PRO_30D — Stripe Price ID for the 30-day product
|
||||
* STRIPE_PRICE_PRO_90D — Stripe Price ID for the 90-day product
|
||||
* STRIPE_PRICE_PRO_180D — Stripe Price ID for the 180-day product
|
||||
* STRIPE_PRICE_PRO_365D — Stripe Price ID for the 365-day product
|
||||
* STRIPE_SUCCESS_URL — (optional) success page URL override
|
||||
* STRIPE_CANCEL_URL — (optional) cancel page URL override
|
||||
*
|
||||
* The Stripe Price IDs map 1:1 to catalog products. A product whose
|
||||
* Stripe Price ID is unset cannot be purchased (returns
|
||||
* STRIPE_NOT_CONFIGURED).
|
||||
*
|
||||
* # Metadata contract (DC-057)
|
||||
*
|
||||
* The Checkout session carries `metadata.productId` (= one of the
|
||||
* catalog IDs). The webhook bridge reads this field back, maps to the
|
||||
* catalog, and generates the matching license duration.
|
||||
*
|
||||
* Why productId and not (e.g.) durationDays: the catalog is the single
|
||||
* source of truth. If pricing changes (e.g. new tier added), only the
|
||||
* catalog and the bridge change — the Checkout metadata stays abstract.
|
||||
*
|
||||
* Tested in __tests__/billing/stripe-client.test.js with mocked Stripe SDK.
|
||||
*/
|
||||
|
||||
let stripeSdk = null;
|
||||
function _loadStripeSdk() {
|
||||
if (stripeSdk) return stripeSdk;
|
||||
// Lazy require so tests can install a mock BEFORE first call.
|
||||
stripeSdk = require('stripe');
|
||||
return stripeSdk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a mock Stripe SDK. Used by tests; never call in production code.
|
||||
* @param {Object} mockSdk - Object with `checkout.sessions.create` (and any other surface) the tests want to stub.
|
||||
*/
|
||||
function _setStripeSdk(mockSdk) {
|
||||
stripeSdk = mockSdk;
|
||||
}
|
||||
|
||||
const catalog = require('./catalog');
|
||||
|
||||
/**
|
||||
* Read the active configuration. Throws if STRIPE_SECRET_KEY is missing
|
||||
* OR if no product has its Stripe Price ID configured — both are loud
|
||||
* failures so an operator notices instead of seeing silent 500s.
|
||||
*
|
||||
* @param {Object} [env] - process.env by default; tests pass custom env.
|
||||
* @returns {Object} config snapshot for this invocation
|
||||
*/
|
||||
function _readConfig(env = process.env) {
|
||||
const secretKey = env.STRIPE_SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
const err = new Error(
|
||||
'Stripe billing is not configured. Missing env var: STRIPE_SECRET_KEY. ' +
|
||||
'Set it in /opt/dashcaddy/.env and restart the API.'
|
||||
);
|
||||
err.code = 'STRIPE_NOT_CONFIGURED';
|
||||
err.statusCode = 503;
|
||||
err.missing = ['STRIPE_SECRET_KEY'];
|
||||
throw err;
|
||||
}
|
||||
return { secretKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the requested productId and resolve its Stripe Price ID.
|
||||
* Throws with a structured error if the productId is unknown OR if the
|
||||
* product's Stripe Price ID env var is not configured.
|
||||
*
|
||||
* @param {string} productId
|
||||
* @param {Object} env
|
||||
* @returns {Object} catalog product entry
|
||||
*/
|
||||
function _resolveProduct(productId, env = process.env) {
|
||||
if (!productId || typeof productId !== 'string') {
|
||||
const err = new Error('productId is required');
|
||||
err.code = 'INVALID_PRODUCT_ID';
|
||||
err.statusCode = 400;
|
||||
err.field = 'productId';
|
||||
throw err;
|
||||
}
|
||||
const product = catalog.getProduct(productId);
|
||||
if (!product) {
|
||||
const err = new Error(`Unknown productId: ${productId}. Valid: ${catalog.PRODUCTS.map(p => p.id).join(', ')}`);
|
||||
err.code = 'INVALID_PRODUCT_ID';
|
||||
err.statusCode = 400;
|
||||
err.field = 'productId';
|
||||
throw err;
|
||||
}
|
||||
const priceId = catalog.getConfiguredPrice(product, env);
|
||||
if (!priceId) {
|
||||
const err = new Error(
|
||||
`Product ${productId} is not configured for purchase. Missing env var: ${product.priceEnv}. ` +
|
||||
`Create the Stripe Price and set the env var in /opt/dashcaddy/.env, then restart the API.`
|
||||
);
|
||||
err.code = 'STRIPE_NOT_CONFIGURED';
|
||||
err.statusCode = 503;
|
||||
err.missing = [product.priceEnv];
|
||||
err.productId = productId;
|
||||
throw err;
|
||||
}
|
||||
return { product, priceId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Stripe Checkout Session for a one-time DashCaddy Pro purchase.
|
||||
*
|
||||
* @param {Object} opts
|
||||
* @param {string} opts.productId - catalog id: 'pro-30d' | 'pro-90d' | 'pro-180d' | 'pro-365d'
|
||||
* @param {string} [opts.customerEmail] - email to prefill on Checkout (optional)
|
||||
* @param {string} [opts.origin] - request origin (e.g. 'https://status.sami') used to build success/cancel URLs
|
||||
* @returns {Promise<{ id: string, url: string }>}
|
||||
* @throws Error with `.code` and `.statusCode` on configuration/validation failure
|
||||
*/
|
||||
async function createCheckoutSession({ productId, customerEmail, origin }) {
|
||||
const config = _readConfig();
|
||||
const { product, priceId } = _resolveProduct(productId);
|
||||
const stripe = _loadStripeSdk();
|
||||
const api = stripe(config.secretKey);
|
||||
|
||||
const successUrl = process.env.STRIPE_SUCCESS_URL
|
||||
|| (origin ? `${origin}/billing/success?session_id={CHECKOUT_SESSION_ID}` : '/billing/success?session_id={CHECKOUT_SESSION_ID}');
|
||||
const cancelUrl = process.env.STRIPE_CANCEL_URL
|
||||
|| (origin ? `${origin}/pricing` : '/pricing');
|
||||
|
||||
// mode: 'payment' (one-time, NOT subscription). The license is generated
|
||||
// once on `checkout.session.completed` and the customer pastes it into
|
||||
// their host. No Customer object, no recurring billing.
|
||||
//
|
||||
// metadata.productId is the contract with the webhook bridge — it maps
|
||||
// back to a catalog entry to get the license duration. If the catalog
|
||||
// grows, only the bridge needs to change.
|
||||
const params = {
|
||||
mode: 'payment',
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata: {
|
||||
productId: product.id,
|
||||
product: 'dashcaddy-pro',
|
||||
},
|
||||
// payment_intent_data carries metadata to the PaymentIntent too, so
|
||||
// any downstream Stripe→bridge plumbing that reads PI metadata still
|
||||
// gets the productId. (Stripe's webhook includes the PI on
|
||||
// checkout.session.completed for retrieval but the canonical metadata
|
||||
// field for session-level events is the top-level metadata.)
|
||||
payment_intent_data: {
|
||||
metadata: { productId: product.id, product: 'dashcaddy-pro' },
|
||||
},
|
||||
allow_promotion_codes: true,
|
||||
};
|
||||
if (customerEmail) {
|
||||
params.customer_email = customerEmail;
|
||||
}
|
||||
|
||||
const session = await api.checkout.sessions.create(params);
|
||||
|
||||
return { id: session.id, url: session.url };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createCheckoutSession,
|
||||
// Test seams
|
||||
_setStripeSdk,
|
||||
_readConfig,
|
||||
_resolveProduct,
|
||||
};
|
||||
@@ -162,6 +162,20 @@ function csrfValidationMiddleware(req, res, next) {
|
||||
// is bounded by the token's TTL + scope. Same model as invite accept.
|
||||
'/api/v1/share/:token/subscribe',
|
||||
'/api/v1/share/:token/redeem-tailscale',
|
||||
// DC-055: Stripe Checkout session creation. Browsers hit this from the
|
||||
// public pricing page (cross-origin from any *.sami subdomain that
|
||||
// serves it); no session cookie exists yet, so a CSRF token can't be
|
||||
// anchored. SameSite=Lax on the session cookie doesn't apply (none
|
||||
// exists). Threat model: an attacker who can trigger checkout sessions
|
||||
// can only force a customer to land on Stripe's hosted page — they
|
||||
// can't extract money. Stripe's session id is single-use and tied to a
|
||||
// chosen price; reusing it requires Stripe's webhook secret.
|
||||
'/api/v1/billing/checkout',
|
||||
// DC-057: success page polls this from the customer's browser after
|
||||
// Stripe redirects them back. Same CSRF argument as above (no session
|
||||
// cookie exists yet) — and the response is the customer's own license
|
||||
// code, not anything an attacker can exploit by triggering the lookup.
|
||||
'/api/v1/billing/lookup/:sessionId',
|
||||
'/health',
|
||||
'/health/live',
|
||||
'/health/ready',
|
||||
|
||||
@@ -402,10 +402,14 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/share/:token/preview', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/share/:token/subscribe', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/share/:token/redeem-tailscale', exact: true, method: 'POST' },
|
||||
// /api/v1/billing/* was REMOVED: billing is handled out-of-process
|
||||
// (the merchant webhook secret never enters the API process). The
|
||||
// PUBLIC_ROUTES allowlist drift test guards against re-adding these
|
||||
// dead entries.
|
||||
// /api/v1/billing/* (DC-055 + DC-057): public checkout session creation
|
||||
// + license lookup for the success page. No DashCaddy account exists
|
||||
// yet at checkout time. The lookup endpoint serves the persisted
|
||||
// license in both `delivered` and `pending_email` states (the SMTP
|
||||
// failure-recovery path); the bearer-style secret is the Stripe
|
||||
// Checkout sessionId (single-use, 24h TTL — see routes/billing.js).
|
||||
{ path: '/api/v1/billing/checkout', exact: true, method: 'POST' },
|
||||
{ path: '/api/v1/billing/lookup/:sessionId', exact: true, method: 'GET' },
|
||||
// /api/v1/services + status: read-only service metadata that the public
|
||||
// dashboard needs before login (services list widget, status pill).
|
||||
// Writes go through the normal auth gate. CSRF applies to writes as usual.
|
||||
|
||||
Reference in New Issue
Block a user