Files
blackroad/workers/blackroad-stripe-src/worker.js
Alexa Amundson 0c1d3bacfc
Some checks failed
Lint & Format / detect (push) Has been cancelled
Lint & Format / js-lint (push) Has been cancelled
Lint & Format / py-lint (push) Has been cancelled
Lint & Format / sh-lint (push) Has been cancelled
Lint & Format / go-lint (push) Has been cancelled
sync: 2026-03-14 17:28 — 45 files from Alexandria
RoadChain-SHA2048: abc9be08a46f52c2
RoadChain-Identity: alexa@sovereign
RoadChain-Full: abc9be08a46f52c20e45ae95233112ee88407ca3a606ec0ef568784041b755a56343cf4d7b817f2f4f7a11ec3f34ce826f607b2150a77248ade06b449e5b7e281b4be665ab148c46e3b71c9c029ee4d77f120e5919a7b87b0b7b6ed45f12c87f420fdda633f3bae4c5f7b851979bb52c725913fa63300772174263d1e64a02aa3356f73819e1110ad94d16836fa9f24b40e60e2da2f252506fbf02f82acc5fb8e03fd6ec08691ea60dea318ce5099a93d8ead7f9ef45b13a1ab533f592b60c702a0ba854b243e94be7eece0bfab14f822a928f8681c8777dc6a881da7e2ec324d6ace471f6c3f77ad83a22bfea01760be75f191128aa0a100d497dd0f0801ea8
2026-03-14 17:31:38 -05:00

296 lines
10 KiB
JavaScript

// BlackRoad Stripe Worker — checkout, portal, prices, webhooks, admin
const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'SAMEORIGIN',
'X-XSS-Protection': '1; mode=block',
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'geolocation=(), microphone=(), camera=()',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains; preload',
};
function corsHeaders(origin) {
return {
'Access-Control-Allow-Origin': origin || '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
}
async function stripeRequest(env, method, path, body = null) {
const url = `https://api.stripe.com/v1${path}`;
const headers = {
'Authorization': `Bearer ${env.STRIPE_SECRET_KEY}`,
'Content-Type': 'application/x-www-form-urlencoded',
};
const options = { method, headers };
if (body) {
options.body = new URLSearchParams(body).toString();
}
const res = await fetch(url, options);
const data = await res.json();
if (!res.ok) {
throw new Error(data.error?.message || `Stripe error: ${res.status}`);
}
return data;
}
// ─── Checkout ─────────────────────────────────────────────────────────
async function handleCheckout(request, env) {
const body = await request.json();
const { price_id, success_url, cancel_url, customer_email, metadata = {} } = body;
if (!price_id) {
return Response.json({ error: 'price_id is required' }, { status: 400 });
}
const origin = request.headers.get('Origin') || '*';
const successUrl = success_url || `${origin}/billing?success=true&session_id={CHECKOUT_SESSION_ID}`;
const cancelUrl = cancel_url || `${origin}/pricing`;
const params = {
mode: 'subscription',
'line_items[0][price]': price_id,
'line_items[0][quantity]': '1',
success_url: successUrl,
cancel_url: cancelUrl,
'automatic_tax[enabled]': 'true',
'subscription_data[metadata][source]': 'blackroad-os',
...Object.fromEntries(
Object.entries(metadata).map(([k, v]) => [`metadata[${k}]`, v])
),
};
if (customer_email) {
params.customer_email = customer_email;
}
const session = await stripeRequest(env, 'POST', '/checkout/sessions', params);
return Response.json({ url: session.url, session_id: session.id });
}
// ─── Billing Portal ───────────────────────────────────────────────────
async function handlePortal(request, env) {
const { customer_id, return_url } = await request.json();
if (!customer_id) {
return Response.json({ error: 'customer_id is required' }, { status: 400 });
}
const origin = request.headers.get('Origin') || '*';
const session = await stripeRequest(env, 'POST', '/billing_portal/sessions', {
customer: customer_id,
return_url: return_url || `${origin}/account`,
});
return Response.json({ url: session.url });
}
// ─── Prices ───────────────────────────────────────────────────────────
async function handlePrices(env) {
const prices = await stripeRequest(env, 'GET', '/prices?active=true&expand[]=data.product&limit=100');
const formatted = prices.data
.filter((p) => p.product && !p.product.deleted)
.map((p) => ({
id: p.id,
amount: p.unit_amount,
currency: p.currency,
interval: p.recurring?.interval,
interval_count: p.recurring?.interval_count,
product: {
id: p.product.id,
name: p.product.name,
description: p.product.description,
metadata: p.product.metadata,
},
}))
.sort((a, b) => (a.amount || 0) - (b.amount || 0));
return Response.json({ prices: formatted, count: formatted.length });
}
// ─── Products (list all) ─────────────────────────────────────────────
async function handleProducts(env) {
const products = await stripeRequest(env, 'GET', '/products?active=true&limit=100');
const formatted = products.data.map((p) => ({
id: p.id,
name: p.name,
description: p.description,
metadata: p.metadata,
images: p.images,
default_price: p.default_price,
created: p.created,
}));
return Response.json({ products: formatted, count: formatted.length });
}
// ─── Webhook ──────────────────────────────────────────────────────────
async function handleWebhook(request, env) {
const signature = request.headers.get('stripe-signature');
const body = await request.text();
if (env.STRIPE_WEBHOOK_SECRET) {
try {
await verifyStripeSignature(body, signature, env.STRIPE_WEBHOOK_SECRET);
} catch (err) {
return new Response(`Webhook signature verification failed: ${err.message}`, { status: 400 });
}
}
let event;
try {
event = JSON.parse(body);
} catch {
return new Response('Invalid JSON', { status: 400 });
}
// Forward to Slack hub
const slackRelay = fetch('https://blackroad-slack.amundsonalexa.workers.dev/stripe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event)
}).catch(() => {});
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object;
console.log(`✓ Checkout completed: ${session.id} | customer: ${session.customer}`);
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object;
console.log(`✓ Subscription ${event.type}: ${sub.id} | status: ${sub.status}`);
break;
}
case 'customer.subscription.deleted': {
const sub = event.data.object;
console.log(`✓ Subscription cancelled: ${sub.id}`);
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
console.log(`✗ Payment failed: ${invoice.id} | customer: ${invoice.customer}`);
break;
}
case 'invoice.payment_succeeded': {
const invoice = event.data.object;
console.log(`✓ Payment succeeded: ${invoice.id}`);
break;
}
default:
console.log(`Unhandled event: ${event.type}`);
}
await slackRelay;
return Response.json({ received: true });
}
async function verifyStripeSignature(payload, sigHeader, secret) {
if (!sigHeader) throw new Error('Missing stripe-signature header');
const parts = sigHeader.split(',').reduce((acc, part) => {
const [k, v] = part.split('=');
acc[k] = v;
return acc;
}, {});
const timestamp = parts.t;
const signatures = Object.entries(parts)
.filter(([k]) => k === 'v1')
.map(([, v]) => v);
if (!timestamp || signatures.length === 0) {
throw new Error('Invalid stripe-signature format');
}
const tolerance = 300;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - parseInt(timestamp)) > tolerance) {
throw new Error('Timestamp outside tolerance window');
}
const signedPayload = `${timestamp}.${payload}`;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const sig = await crypto.subtle.sign('HMAC', key, enc.encode(signedPayload));
const computed = Array.from(new Uint8Array(sig))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
if (!signatures.includes(computed)) {
throw new Error('Signature mismatch');
}
}
// ─── Router ───────────────────────────────────────────────────────────
export default {
async fetch(request, env) {
const url = new URL(request.url);
const origin = request.headers.get('Origin') || '*';
const cors = corsHeaders(origin);
if (request.method === 'OPTIONS') {
return new Response(null, { status: 204, headers: { ...cors, ...SECURITY_HEADERS } });
}
if (!env.STRIPE_SECRET_KEY) {
return Response.json(
{ error: 'Stripe not configured' },
{ status: 503, headers: { ...cors, ...SECURITY_HEADERS } }
);
}
let response;
try {
switch (true) {
case url.pathname === '/health':
response = Response.json({
status: 'ok',
worker: 'blackroad-stripe',
version: '2.0.0',
time: new Date().toISOString(),
});
break;
case request.method === 'POST' && url.pathname === '/checkout':
response = await handleCheckout(request, env);
break;
case request.method === 'POST' && url.pathname === '/portal':
response = await handlePortal(request, env);
break;
case request.method === 'POST' && url.pathname === '/webhook':
return await handleWebhook(request, env);
case request.method === 'GET' && url.pathname === '/prices':
response = await handlePrices(env);
break;
case request.method === 'GET' && url.pathname === '/products':
response = await handleProducts(env);
break;
default:
response = Response.json(
{ error: 'Not found', routes: ['/health', '/checkout', '/portal', '/webhook', '/prices', '/products'] },
{ status: 404 }
);
}
} catch (err) {
console.error('Worker error:', err);
response = Response.json({ error: err.message }, { status: 500 });
}
const headers = new Headers(response.headers);
for (const [k, v] of Object.entries({ ...cors, ...SECURITY_HEADERS })) {
headers.set(k, v);
}
return new Response(response.body, { status: response.status, headers });
},
};