sync: 2026-03-14 17:28 — 45 files from Alexandria
Some checks failed
Some checks failed
RoadChain-SHA2048: abc9be08a46f52c2 RoadChain-Identity: alexa@sovereign RoadChain-Full: abc9be08a46f52c20e45ae95233112ee88407ca3a606ec0ef568784041b755a56343cf4d7b817f2f4f7a11ec3f34ce826f607b2150a77248ade06b449e5b7e281b4be665ab148c46e3b71c9c029ee4d77f120e5919a7b87b0b7b6ed45f12c87f420fdda633f3bae4c5f7b851979bb52c725913fa63300772174263d1e64a02aa3356f73819e1110ad94d16836fa9f24b40e60e2da2f252506fbf02f82acc5fb8e03fd6ec08691ea60dea318ce5099a93d8ead7f9ef45b13a1ab533f592b60c702a0ba854b243e94be7eece0bfab14f822a928f8681c8777dc6a881da7e2ec324d6ace471f6c3f77ad83a22bfea01760be75f191128aa0a100d497dd0f0801ea8
This commit is contained in:
295
workers/blackroad-stripe-src/worker.js
Normal file
295
workers/blackroad-stripe-src/worker.js
Normal file
@@ -0,0 +1,295 @@
|
||||
// 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 });
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user