Commit5f7c71eunknown_key
feat(billing): Stripe bootstrap workflow + minimal webhook stub
feat(billing): Stripe bootstrap workflow + minimal webhook stub Adds three pieces for iPad-friendly Stripe setup: 1. scripts/stripe-bootstrap.ts — idempotent Bun script that creates the 3 paid products (Pro/Team/Enterprise), their monthly prices (with lookup_keys gluecron_<slug>_monthly for plan mapping), and the /api/webhooks/stripe endpoint. Safe to re-run. 2. .github/workflows/stripe-bootstrap.yml — manual-trigger workflow that runs the script with GitHub-secret STRIPE_SECRET_KEY, then propagates STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET + STRIPE_MODE into Fly secrets and rolls a deploy so they activate. 3. src/routes/stripe-webhook.ts — minimal signed webhook endpoint (constant-time HMAC-SHA256 verification, 5-min replay window, accept-and-log). Full handling ships in the billing integration sprint; this stub ensures Stripe's retries don't storm during the gap and that dashboard "Send test webhook" succeeds. https://claude.ai/code/session_017Do52tMX1P9jPTWXhEohXH
3 files changed+381−05f7c71ea843bb4721bfcfa18a02b9b8b2db9e595
3 changed files+381−0
Added.github/workflows/stripe-bootstrap.yml+64−0View fileUnifiedSplit
@@ -0,0 +1,64 @@
1name: Stripe Bootstrap
2
3# Idempotent one-shot: creates Stripe products + prices + webhook endpoint
4# matching gluecron's billing schema. Re-runnable. Propagates the
5# STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET into Fly so the live site
6# can authenticate calls and verify webhooks.
7#
8# Trigger manually via Actions tab → "Stripe Bootstrap" → Run workflow.
9# Pick mode=test (default) before going live.
10
11on:
12 workflow_dispatch:
13 inputs:
14 mode:
15 description: "test or live"
16 type: choice
17 options: [test, live]
18 default: test
19
20concurrency:
21 group: stripe-bootstrap
22 cancel-in-progress: false
23
24jobs:
25 bootstrap:
26 name: Bootstrap Stripe
27 runs-on: ubuntu-latest
28 steps:
29 - uses: actions/checkout@v4
30
31 - uses: oven-sh/setup-bun@v1
32 with:
33 bun-version: latest
34
35 - name: Install deps
36 run: bun install --frozen-lockfile
37
38 - uses: superfly/flyctl-actions/setup-flyctl@master
39
40 - name: Run Stripe bootstrap
41 id: stripe
42 env:
43 STRIPE_SECRET_KEY: ${{ inputs.mode == 'live' && secrets.STRIPE_SECRET_KEY_LIVE || secrets.STRIPE_SECRET_KEY_TEST }}
44 APP_BASE_URL: ${{ vars.APP_BASE_URL || 'https://gluecron.fly.dev' }}
45 run: bun run scripts/stripe-bootstrap.ts
46
47 - name: Propagate secrets to Fly
48 if: steps.stripe.outputs.webhook_secret != ''
49 env:
50 FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
51 STRIPE_SECRET_KEY: ${{ inputs.mode == 'live' && secrets.STRIPE_SECRET_KEY_LIVE || secrets.STRIPE_SECRET_KEY_TEST }}
52 WEBHOOK_SECRET: ${{ steps.stripe.outputs.webhook_secret }}
53 run: |
54 flyctl secrets set --app gluecron --stage \
55 STRIPE_SECRET_KEY="$STRIPE_SECRET_KEY" \
56 STRIPE_WEBHOOK_SECRET="$WEBHOOK_SECRET" \
57 STRIPE_MODE="${{ inputs.mode }}"
58 echo "Staged Stripe secrets onto Fly. They'll activate on next deploy."
59
60 - name: Also re-run deploy to activate secrets
61 if: steps.stripe.outputs.webhook_secret != ''
62 env:
63 FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
64 run: flyctl deploy --remote-only --app gluecron
Addedscripts/stripe-bootstrap.ts+205−0View fileUnifiedSplit
@@ -0,0 +1,205 @@
1/**
2 * Stripe bootstrap — idempotent setup of products, prices, and webhook.
3 *
4 * Run via `.github/workflows/stripe-bootstrap.yml` (manual dispatch). Reads
5 * STRIPE_SECRET_KEY + APP_BASE_URL from env. Creates the 4 plan tiers
6 * (free is a no-op; pro/team/enterprise each get a product + monthly price
7 * with a lookup_key matching `gluecron_${slug}_monthly`). Then creates/
8 * updates the webhook endpoint at `${APP_BASE_URL}/api/webhooks/stripe`.
9 *
10 * Idempotent: re-running is safe. Prices are matched by lookup_key, products
11 * by name, webhooks by URL.
12 *
13 * Outputs (printed to stdout, masked in GH Actions):
14 * - The webhook signing secret (whsec_...) — must be stored as a
15 * Fly secret STRIPE_WEBHOOK_SECRET.
16 *
17 * This script uses only fetch + URLSearchParams — no Stripe SDK dependency.
18 */
19
20const STRIPE_SECRET_KEY = process.env.STRIPE_SECRET_KEY;
21const APP_BASE_URL = process.env.APP_BASE_URL || "https://gluecron.fly.dev";
22
23if (!STRIPE_SECRET_KEY) {
24 console.error("STRIPE_SECRET_KEY not set — aborting.");
25 process.exit(1);
26}
27
28const PLANS = [
29 { slug: "pro", name: "Gluecron Pro", priceCents: 900 },
30 { slug: "team", name: "Gluecron Team", priceCents: 2900 },
31 { slug: "enterprise", name: "Gluecron Enterprise", priceCents: 9900 },
32] as const;
33
34const WEBHOOK_URL = `${APP_BASE_URL.replace(/\/$/, "")}/api/webhooks/stripe`;
35const WEBHOOK_EVENTS = [
36 "checkout.session.completed",
37 "customer.subscription.created",
38 "customer.subscription.updated",
39 "customer.subscription.deleted",
40 "invoice.payment_failed",
41 "invoice.payment_succeeded",
42];
43
44type StripeResult<T> = { ok: true; data: T } | { ok: false; error: string };
45
46async function stripe<T = any>(
47 path: string,
48 body?: Record<string, string | string[]>,
49 method: "GET" | "POST" | "DELETE" = body ? "POST" : "GET"
50): Promise<StripeResult<T>> {
51 const url = `https://api.stripe.com/v1${path}`;
52 const init: RequestInit = {
53 method,
54 headers: {
55 Authorization: `Bearer ${STRIPE_SECRET_KEY}`,
56 "Content-Type": "application/x-www-form-urlencoded",
57 },
58 };
59 if (body) {
60 const params = new URLSearchParams();
61 for (const [k, v] of Object.entries(body)) {
62 if (Array.isArray(v)) {
63 for (const item of v) params.append(`${k}[]`, item);
64 } else {
65 params.append(k, v);
66 }
67 }
68 init.body = params.toString();
69 }
70 try {
71 const res = await fetch(url, init);
72 const json = await res.json();
73 if (!res.ok) {
74 return { ok: false, error: `${res.status} ${json.error?.message || JSON.stringify(json)}` };
75 }
76 return { ok: true, data: json as T };
77 } catch (err) {
78 return { ok: false, error: err instanceof Error ? err.message : String(err) };
79 }
80}
81
82async function findProductByName(name: string): Promise<string | null> {
83 const res = await stripe<{ data: Array<{ id: string; name: string; active: boolean }> }>(
84 `/products?limit=100&active=true`
85 );
86 if (!res.ok) return null;
87 const match = res.data.data.find((p) => p.name === name);
88 return match?.id ?? null;
89}
90
91async function findPriceByLookupKey(lookupKey: string): Promise<string | null> {
92 const res = await stripe<{ data: Array<{ id: string; lookup_key: string }> }>(
93 `/prices?limit=100&lookup_keys[]=${encodeURIComponent(lookupKey)}`
94 );
95 if (!res.ok) return null;
96 return res.data.data[0]?.id ?? null;
97}
98
99async function findWebhookByUrl(url: string): Promise<string | null> {
100 const res = await stripe<{ data: Array<{ id: string; url: string }> }>(
101 `/webhook_endpoints?limit=100`
102 );
103 if (!res.ok) return null;
104 return res.data.data.find((w) => w.url === url)?.id ?? null;
105}
106
107async function ensureProduct(plan: (typeof PLANS)[number]): Promise<string> {
108 const existing = await findProductByName(plan.name);
109 if (existing) {
110 console.log(` ✓ Product already exists: ${plan.name} (${existing})`);
111 return existing;
112 }
113 const res = await stripe<{ id: string }>(`/products`, {
114 name: plan.name,
115 "metadata[gluecron_plan_slug]": plan.slug,
116 });
117 if (!res.ok) throw new Error(`Failed to create product ${plan.name}: ${res.error}`);
118 console.log(` + Created product: ${plan.name} (${res.data.id})`);
119 return res.data.id;
120}
121
122async function ensurePrice(
123 productId: string,
124 plan: (typeof PLANS)[number]
125): Promise<string> {
126 const lookupKey = `gluecron_${plan.slug}_monthly`;
127 const existing = await findPriceByLookupKey(lookupKey);
128 if (existing) {
129 console.log(` ✓ Price already exists: ${lookupKey} (${existing})`);
130 return existing;
131 }
132 const res = await stripe<{ id: string }>(`/prices`, {
133 product: productId,
134 unit_amount: String(plan.priceCents),
135 currency: "usd",
136 "recurring[interval]": "month",
137 lookup_key: lookupKey,
138 "metadata[gluecron_plan_slug]": plan.slug,
139 });
140 if (!res.ok) throw new Error(`Failed to create price ${lookupKey}: ${res.error}`);
141 console.log(` + Created price: ${lookupKey} (${res.data.id})`);
142 return res.data.id;
143}
144
145async function ensureWebhook(): Promise<{ id: string; secret: string | null }> {
146 const existing = await findWebhookByUrl(WEBHOOK_URL);
147 if (existing) {
148 console.log(` ✓ Webhook already exists for ${WEBHOOK_URL} (${existing})`);
149 console.log(` (signing secret is only shown at creation; not re-retrievable)`);
150 return { id: existing, secret: null };
151 }
152 const res = await stripe<{ id: string; secret: string }>(`/webhook_endpoints`, {
153 url: WEBHOOK_URL,
154 enabled_events: WEBHOOK_EVENTS,
155 description: "gluecron billing webhook (auto-created by stripe-bootstrap.ts)",
156 });
157 if (!res.ok) throw new Error(`Failed to create webhook: ${res.error}`);
158 console.log(` + Created webhook: ${WEBHOOK_URL} (${res.data.id})`);
159 return { id: res.data.id, secret: res.data.secret };
160}
161
162async function main() {
163 const mode = STRIPE_SECRET_KEY.startsWith("sk_live_") ? "LIVE" : "TEST";
164 console.log(`\nStripe bootstrap — ${mode} mode`);
165 console.log(`App base URL: ${APP_BASE_URL}`);
166 console.log(`Webhook URL: ${WEBHOOK_URL}\n`);
167
168 console.log("== Products + prices ==");
169 for (const plan of PLANS) {
170 const productId = await ensureProduct(plan);
171 await ensurePrice(productId, plan);
172 }
173
174 console.log("\n== Webhook endpoint ==");
175 const webhook = await ensureWebhook();
176
177 console.log("\n== Summary ==");
178 console.log(`Mode: ${mode}`);
179 console.log(`Products: ${PLANS.length} (pro/team/enterprise)`);
180 console.log(`Webhook: ${webhook.id}`);
181
182 if (webhook.secret) {
183 console.log("\n⚠️ WEBHOOK SIGNING SECRET (save this — only shown once):");
184 console.log(`STRIPE_WEBHOOK_SECRET=${webhook.secret}`);
185 // GitHub Actions masking so it doesn't land in logs
186 console.log(`::add-mask::${webhook.secret}`);
187 // Emit for workflow step to capture
188 if (process.env.GITHUB_OUTPUT) {
189 const fs = await import("fs");
190 fs.appendFileSync(
191 process.env.GITHUB_OUTPUT,
192 `webhook_secret=${webhook.secret}\n`
193 );
194 }
195 } else {
196 console.log("\n(Webhook already existed — signing secret not re-retrievable.)");
197 console.log("If you need to rotate it, delete the webhook in the Stripe dashboard");
198 console.log("and re-run this script.");
199 }
200}
201
202main().catch((err) => {
203 console.error("Bootstrap failed:", err instanceof Error ? err.message : err);
204 process.exit(1);
205});
Addedsrc/routes/stripe-webhook.ts+112−0View fileUnifiedSplit
@@ -0,0 +1,112 @@
1/**
2 * Minimal Stripe webhook endpoint — verifies signature + returns 200.
3 *
4 * v1 scope: authentic, logged, ignored. Full handling (auto-assign plan
5 * on subscription.created, downgrade on subscription.deleted, grace
6 * period on invoice.payment_failed) ships in the Stripe integration
7 * sprint. This stub exists so the Stripe dashboard's "Send test webhook"
8 * succeeds (returns 200) and so live webhook deliveries during the gap
9 * don't fail-retry-fail forever.
10 *
11 * Signature verification follows Stripe's documented scheme:
12 * Header: Stripe-Signature: t=<ts>,v1=<hmac>
13 * Signed payload: <ts> . <raw body>
14 * HMAC: SHA-256 with STRIPE_WEBHOOK_SECRET as key
15 *
16 * We use the Web Crypto API (available in Bun + workers) — no Node
17 * crypto, no @stripe/sdk dependency.
18 */
19
20import { Hono } from "hono";
21import { reportError } from "../lib/observability";
22
23const stripeWebhook = new Hono();
24
25const TOLERANCE_SECONDS = 300; // 5 minutes — matches Stripe's own default
26
27async function hmacSha256Hex(key: string, message: string): Promise<string> {
28 const enc = new TextEncoder();
29 const cryptoKey = await crypto.subtle.importKey(
30 "raw",
31 enc.encode(key),
32 { name: "HMAC", hash: "SHA-256" },
33 false,
34 ["sign"]
35 );
36 const sig = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(message));
37 return Array.from(new Uint8Array(sig))
38 .map((b) => b.toString(16).padStart(2, "0"))
39 .join("");
40}
41
42function parseSigHeader(header: string): { t?: string; v1?: string } {
43 const out: { t?: string; v1?: string } = {};
44 for (const part of header.split(",")) {
45 const [k, v] = part.split("=");
46 if (k === "t") out.t = v;
47 else if (k === "v1" && !out.v1) out.v1 = v;
48 }
49 return out;
50}
51
52function constantTimeEqual(a: string, b: string): boolean {
53 if (a.length !== b.length) return false;
54 let diff = 0;
55 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
56 return diff === 0;
57}
58
59stripeWebhook.post("/api/webhooks/stripe", async (c) => {
60 const secret = process.env.STRIPE_WEBHOOK_SECRET;
61 if (!secret) {
62 console.warn("[stripe-webhook] STRIPE_WEBHOOK_SECRET not set — returning 503");
63 return c.json({ error: "webhook not configured" }, 503);
64 }
65
66 const sigHeader = c.req.header("stripe-signature");
67 if (!sigHeader) return c.json({ error: "missing stripe-signature header" }, 400);
68
69 const raw = await c.req.text();
70
71 const { t, v1 } = parseSigHeader(sigHeader);
72 if (!t || !v1) return c.json({ error: "malformed stripe-signature" }, 400);
73
74 // Replay window check
75 const ts = parseInt(t, 10);
76 if (!Number.isFinite(ts)) return c.json({ error: "bad timestamp" }, 400);
77 const age = Math.abs(Math.floor(Date.now() / 1000) - ts);
78 if (age > TOLERANCE_SECONDS) {
79 return c.json({ error: "timestamp outside tolerance window" }, 400);
80 }
81
82 // HMAC check
83 const expected = await hmacSha256Hex(secret, `${t}.${raw}`);
84 if (!constantTimeEqual(expected, v1)) {
85 return c.json({ error: "signature mismatch" }, 400);
86 }
87
88 let event: { id?: string; type?: string } = {};
89 try {
90 event = JSON.parse(raw);
91 } catch {
92 return c.json({ error: "invalid json" }, 400);
93 }
94
95 console.log(
96 `[stripe-webhook] authentic event id=${event.id} type=${event.type}`
97 );
98
99 // v1: accept-and-log only. Full handling ships with the billing integration
100 // sprint. Stripe retries non-200s — returning 200 here prevents that.
101 return c.json({ received: true });
102});
103
104// Defensive error handler local to this route — never leak exception details
105// back to Stripe; always prefer 200 for malformed-but-authenticated payloads
106// to avoid retry storms.
107stripeWebhook.onError((err, c) => {
108 reportError(err, { path: c.req.path, scope: "stripe-webhook" });
109 return c.json({ error: "internal error" }, 500);
110});
111
112export default stripeWebhook;
0113