Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitf98000cunknown_key

Merge pull request #30 from ccantynz-alt/claude/build-status-update-3MXsf

Merge pull request #30 from ccantynz-alt/claude/build-status-update-3MXsf

Claude/build status update 3 m xsf
ccantynz App committed on April 30, 2026Parents: a6b8f41 2294b01
11 files changed+16325f98000c87c28134e2475927d03e8c0c6eb03bdc6
11 changed files+1632−5
Modified.github/workflows/fly-deploy.yml+7−2View fileUnifiedSplit
1818 steps:
1919 - uses: actions/checkout@v4
2020
21 - name: Set up flyctl
22 uses: superfly/flyctl-actions/setup-flyctl@v1.5.0
21 - name: Install flyctl
22 run: |
23 curl -fsSL https://github.com/superfly/flyctl/releases/download/v0.4.38/flyctl_0.4.38_Linux_x86_64.tar.gz -o /tmp/flyctl.tgz
24 mkdir -p $HOME/.fly/bin
25 tar -xzf /tmp/flyctl.tgz -C $HOME/.fly/bin
26 echo "$HOME/.fly/bin" >> $GITHUB_PATH
27 $HOME/.fly/bin/flyctl version
2328
2429 - name: Ensure app exists
2530 run: |
Added.github/workflows/stripe-bootstrap.yml+69−0View fileUnifiedSplit
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 - name: Install flyctl
39 run: |
40 curl -fsSL https://github.com/superfly/flyctl/releases/download/v0.4.38/flyctl_0.4.38_Linux_x86_64.tar.gz -o /tmp/flyctl.tgz
41 mkdir -p $HOME/.fly/bin
42 tar -xzf /tmp/flyctl.tgz -C $HOME/.fly/bin
43 echo "$HOME/.fly/bin" >> $GITHUB_PATH
44
45 - name: Run Stripe bootstrap
46 id: stripe
47 env:
48 STRIPE_SECRET_KEY: ${{ inputs.mode == 'live' && secrets.STRIPE_SECRET_KEY_LIVE || secrets.STRIPE_SECRET_KEY_TEST }}
49 APP_BASE_URL: ${{ vars.APP_BASE_URL || 'https://gluecron.fly.dev' }}
50 run: bun run scripts/stripe-bootstrap.ts
51
52 - name: Propagate secrets to Fly
53 if: steps.stripe.outputs.webhook_secret != ''
54 env:
55 FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
56 STRIPE_SECRET_KEY: ${{ inputs.mode == 'live' && secrets.STRIPE_SECRET_KEY_LIVE || secrets.STRIPE_SECRET_KEY_TEST }}
57 WEBHOOK_SECRET: ${{ steps.stripe.outputs.webhook_secret }}
58 run: |
59 flyctl secrets set --app gluecron --stage \
60 STRIPE_SECRET_KEY="$STRIPE_SECRET_KEY" \
61 STRIPE_WEBHOOK_SECRET="$WEBHOOK_SECRET" \
62 STRIPE_MODE="${{ inputs.mode }}"
63 echo "Staged Stripe secrets onto Fly. They'll activate on next deploy."
64
65 - name: Also re-run deploy to activate secrets
66 if: steps.stripe.outputs.webhook_secret != ''
67 env:
68 FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
69 run: flyctl deploy --remote-only --app gluecron
Addeddrizzle/0038_stripe_billing.sql+18−0View fileUnifiedSplit
1-- Stripe billing columns on user_quotas. Adds subscription linkage + lifecycle
2-- state so the webhook handler can auto-assign plans and enforce grace periods.
3
4ALTER TABLE user_quotas
5 ADD COLUMN IF NOT EXISTS stripe_customer_id TEXT,
6 ADD COLUMN IF NOT EXISTS stripe_subscription_id TEXT,
7 ADD COLUMN IF NOT EXISTS stripe_subscription_status TEXT,
8 ADD COLUMN IF NOT EXISTS current_period_end TIMESTAMPTZ;
9--> statement-breakpoint
10
11CREATE UNIQUE INDEX IF NOT EXISTS user_quotas_stripe_customer_id_idx
12 ON user_quotas (stripe_customer_id)
13 WHERE stripe_customer_id IS NOT NULL;
14--> statement-breakpoint
15
16CREATE INDEX IF NOT EXISTS user_quotas_stripe_subscription_status_idx
17 ON user_quotas (stripe_subscription_status)
18 WHERE stripe_subscription_status IS NOT NULL;
Addedscripts/stripe-bootstrap.ts+205−0View fileUnifiedSplit
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});
Modifiedsrc/app.tsx+6−0View fileUnifiedSplit
6161import aiTestsRoutes from "./routes/ai-tests";
6262import askRoutes from "./routes/ask";
6363import billingRoutes from "./routes/billing";
64import stripeWebhookRoutes from "./routes/stripe-webhook";
6465import codeScanningRoutes from "./routes/code-scanning";
6566import commitStatusesRoutes from "./routes/commit-statuses";
6667import copilotRoutes from "./routes/copilot";
9596import trafficRoutes from "./routes/traffic";
9697import wikisRoutes from "./routes/wikis";
9798import workflowsRoutes from "./routes/workflows";
99import workflowArtifactsRoutes from "./routes/workflow-artifacts";
100import workflowSecretsRoutes from "./routes/workflow-secrets";
98101import { authRateLimit, gitRateLimit, searchRateLimit } from "./middleware/rate-limit";
99102import { csrfToken, csrfProtect } from "./middleware/csrf";
100103
271274app.route("/", aiTestsRoutes);
272275app.route("/", askRoutes);
273276app.route("/", billingRoutes);
277app.route("/", stripeWebhookRoutes);
274278app.route("/", codeScanningRoutes);
275279app.route("/", commitStatusesRoutes);
276280app.route("/", copilotRoutes);
305309app.route("/", trafficRoutes);
306310app.route("/", wikisRoutes);
307311app.route("/", workflowsRoutes);
312app.route("/", workflowArtifactsRoutes);
313app.route("/", workflowSecretsRoutes);
308314
309315// Web UI (catch-all, must be last)
310316app.route("/", webRoutes);
Modifiedsrc/db/schema.ts+5−0View fileUnifiedSplit
18011801 .default(0),
18021802 cycleStart: timestamp("cycle_start").defaultNow().notNull(),
18031803 updatedAt: timestamp("updated_at").defaultNow().notNull(),
1804 // Stripe linkage (migration 0038). Null until the user first upgrades.
1805 stripeCustomerId: text("stripe_customer_id"),
1806 stripeSubscriptionId: text("stripe_subscription_id"),
1807 stripeSubscriptionStatus: text("stripe_subscription_status"),
1808 currentPeriodEnd: timestamp("current_period_end"),
18041809});
18051810
18061811export type UserQuota = typeof userQuotas.$inferSelect;
Modifiedsrc/lib/billing.ts+10−0View fileUnifiedSplit
116116 bandwidthGbUsedThisMonth: number;
117117 };
118118 cycleStart: Date | null;
119 /** Stripe linkage — populated once the user completes a checkout session. */
120 stripeCustomerId: string | null;
121 stripeSubscriptionId: string | null;
122 stripeSubscriptionStatus: string | null;
123 currentPeriodEnd: Date | null;
119124 percent: {
120125 storage: number;
121126 aiTokens: number;
164169 plan,
165170 usage,
166171 cycleStart: (row?.cycleStart as Date | null) || null,
172 stripeCustomerId: (row?.stripeCustomerId as string | null) || null,
173 stripeSubscriptionId: (row?.stripeSubscriptionId as string | null) || null,
174 stripeSubscriptionStatus:
175 (row?.stripeSubscriptionStatus as string | null) || null,
176 currentPeriodEnd: (row?.currentPeriodEnd as Date | null) || null,
167177 percent: {
168178 storage: pct(usage.storageMbUsed, plan.storageMbLimit),
169179 aiTokens: pct(usage.aiTokensUsedThisMonth, plan.aiTokensMonthly),
Addedsrc/lib/stripe.ts+189−0View fileUnifiedSplit
1/**
2 * Stripe REST helpers — fetch-based, no SDK dependency. Used by the billing
3 * upgrade flow and the webhook handler.
4 *
5 * All fns return discriminated `{ ok: true, ... }` / `{ ok: false, error }`
6 * results. Never throws — a Stripe outage must not break the primary
7 * request path.
8 *
9 * `STRIPE_SECRET_KEY` is read at call time (not module init) so tests can
10 * mutate env without reloading the module.
11 */
12
13const STRIPE_API = "https://api.stripe.com/v1";
14
15export type StripeFail = { ok: false; error: string };
16
17function getKey(): string | null {
18 const k = process.env.STRIPE_SECRET_KEY;
19 if (!k || k.length < 10) return null;
20 return k;
21}
22
23function encodeForm(body: Record<string, string | string[]>): string {
24 const params = new URLSearchParams();
25 for (const [k, v] of Object.entries(body)) {
26 if (Array.isArray(v)) for (const item of v) params.append(`${k}[]`, item);
27 else params.append(k, v);
28 }
29 return params.toString();
30}
31
32async function stripeRequest<T>(
33 path: string,
34 body?: Record<string, string | string[]>,
35 method: "GET" | "POST" | "DELETE" = body ? "POST" : "GET"
36): Promise<{ ok: true; data: T } | StripeFail> {
37 const key = getKey();
38 if (!key) return { ok: false, error: "STRIPE_SECRET_KEY not configured" };
39 try {
40 const res = await fetch(`${STRIPE_API}${path}`, {
41 method,
42 headers: {
43 Authorization: `Bearer ${key}`,
44 "Content-Type": "application/x-www-form-urlencoded",
45 },
46 body: body ? encodeForm(body) : undefined,
47 });
48 const json = await res.json();
49 if (!res.ok) {
50 return {
51 ok: false,
52 error: `stripe ${method} ${path} ${res.status}: ${json.error?.message ?? "unknown"}`,
53 };
54 }
55 return { ok: true, data: json as T };
56 } catch (err) {
57 return {
58 ok: false,
59 error: `stripe ${method} ${path} network: ${err instanceof Error ? err.message : String(err)}`,
60 };
61 }
62}
63
64// ---------------------------------------------------------------------------
65// Customer
66// ---------------------------------------------------------------------------
67
68export async function findOrCreateCustomer(args: {
69 userId: string;
70 email: string;
71 existingCustomerId?: string | null;
72}): Promise<{ ok: true; customerId: string } | StripeFail> {
73 if (args.existingCustomerId) {
74 // Trust the caller's stored id; Stripe will 404 if it's stale, we fall
75 // back to create.
76 const check = await stripeRequest<{ id: string; deleted?: boolean }>(
77 `/customers/${encodeURIComponent(args.existingCustomerId)}`
78 );
79 if (check.ok && !check.data.deleted) {
80 return { ok: true, customerId: check.data.id };
81 }
82 }
83 const res = await stripeRequest<{ id: string }>(`/customers`, {
84 email: args.email,
85 "metadata[gluecron_user_id]": args.userId,
86 });
87 if (!res.ok) return res;
88 return { ok: true, customerId: res.data.id };
89}
90
91// ---------------------------------------------------------------------------
92// Checkout Session
93// ---------------------------------------------------------------------------
94
95export async function createCheckoutSession(args: {
96 customerId: string;
97 planSlug: "pro" | "team" | "enterprise";
98 successUrl: string;
99 cancelUrl: string;
100 userId: string;
101}): Promise<{ ok: true; url: string; sessionId: string } | StripeFail> {
102 const lookupKey = `gluecron_${args.planSlug}_monthly`;
103 // Resolve price by lookup_key — matches what stripe-bootstrap seeds.
104 const priceRes = await stripeRequest<{ data: Array<{ id: string }> }>(
105 `/prices?lookup_keys[]=${encodeURIComponent(lookupKey)}&limit=1`
106 );
107 if (!priceRes.ok) return priceRes;
108 const priceId = priceRes.data.data[0]?.id;
109 if (!priceId) {
110 return {
111 ok: false,
112 error: `no Stripe price found for lookup_key=${lookupKey} — run the Stripe Bootstrap workflow first`,
113 };
114 }
115 const res = await stripeRequest<{ id: string; url: string }>(
116 `/checkout/sessions`,
117 {
118 mode: "subscription",
119 customer: args.customerId,
120 "line_items[0][price]": priceId,
121 "line_items[0][quantity]": "1",
122 success_url: args.successUrl,
123 cancel_url: args.cancelUrl,
124 client_reference_id: args.userId,
125 "metadata[gluecron_user_id]": args.userId,
126 "metadata[gluecron_plan_slug]": args.planSlug,
127 "subscription_data[metadata][gluecron_user_id]": args.userId,
128 "subscription_data[metadata][gluecron_plan_slug]": args.planSlug,
129 }
130 );
131 if (!res.ok) return res;
132 return { ok: true, url: res.data.url, sessionId: res.data.id };
133}
134
135// ---------------------------------------------------------------------------
136// Customer Portal (for existing subscribers to manage their plan)
137// ---------------------------------------------------------------------------
138
139export async function createBillingPortalSession(args: {
140 customerId: string;
141 returnUrl: string;
142}): Promise<{ ok: true; url: string } | StripeFail> {
143 const res = await stripeRequest<{ url: string }>(`/billing_portal/sessions`, {
144 customer: args.customerId,
145 return_url: args.returnUrl,
146 });
147 if (!res.ok) return res;
148 return { ok: true, url: res.data.url };
149}
150
151// ---------------------------------------------------------------------------
152// Subscription fetch (used by webhook to enrich on events)
153// ---------------------------------------------------------------------------
154
155export type StripeSubscription = {
156 id: string;
157 status: string;
158 customer: string;
159 current_period_end: number;
160 items: { data: Array<{ price: { id: string; lookup_key?: string } }> };
161 metadata?: Record<string, string>;
162};
163
164export async function getSubscription(
165 subscriptionId: string
166): Promise<{ ok: true; subscription: StripeSubscription } | StripeFail> {
167 const res = await stripeRequest<StripeSubscription>(
168 `/subscriptions/${encodeURIComponent(subscriptionId)}`
169 );
170 if (!res.ok) return res;
171 return { ok: true, subscription: res.data };
172}
173
174/** Given a Stripe subscription, derive the gluecron plan slug. Prefers
175 * the lookup_key on the price item; falls back to subscription metadata. */
176export function planSlugFromSubscription(
177 sub: StripeSubscription
178): "pro" | "team" | "enterprise" | null {
179 const lk = sub.items?.data?.[0]?.price?.lookup_key;
180 if (lk?.startsWith("gluecron_") && lk.endsWith("_monthly")) {
181 const slug = lk.slice("gluecron_".length, -"_monthly".length);
182 if (slug === "pro" || slug === "team" || slug === "enterprise") return slug;
183 }
184 const metaSlug = sub.metadata?.gluecron_plan_slug;
185 if (metaSlug === "pro" || metaSlug === "team" || metaSlug === "enterprise") {
186 return metaSlug;
187 }
188 return null;
189}
Addedsrc/lib/workflow-parser-ext.ts+683−0View fileUnifiedSplit
1/**
2 * Extended workflow parser — adds matrix / if / needs / uses / with / env /
3 * outputs / workflow_dispatch inputs on top of the locked base parser in
4 * `workflow-parser.ts`.
5 *
6 * The base parser is shipped and locked (BUILD_BIBLE §4.3). It silently
7 * ignores the extended fields. We never modify it. Instead:
8 * 1. Call `parseWorkflow()` for the validated base shape.
9 * 2. Walk the same YAML with a line-based indent-aware scanner that
10 * specifically looks for the extended keys under known job/step
11 * blocks.
12 * 3. Merge the extensions onto the base jobs/steps by name.
13 *
14 * Any extension-field failure is captured as a `warnings[]` entry without
15 * failing the whole parse — the base workflow is still usable.
16 *
17 * Pure: no DB, no I/O, no external calls. Never throws.
18 */
19
20import { parseWorkflow, type ParsedWorkflow } from "./workflow-parser";
21
22// ---------------------------------------------------------------------------
23// Public types
24// ---------------------------------------------------------------------------
25
26export type MatrixSpec = {
27 axes: Record<string, unknown[]>;
28 include?: Record<string, unknown>[];
29 exclude?: Record<string, unknown>[];
30 failFast?: boolean;
31 maxParallel?: number;
32};
33
34export type ExtendedStep = {
35 name?: string;
36 id?: string;
37 if?: string;
38 run?: string;
39 uses?: string;
40 with?: Record<string, unknown>;
41 env?: Record<string, string>;
42 parallel?: boolean;
43 continueOnError?: boolean;
44};
45
46export type ExtendedJob = {
47 name: string;
48 runsOn: string;
49 if?: string;
50 needs?: string[];
51 strategy?: { matrix?: MatrixSpec };
52 env?: Record<string, string>;
53 outputs?: Record<string, string>;
54 steps: ExtendedStep[];
55};
56
57export type DispatchInput = {
58 type: "string" | "boolean" | "choice" | "number";
59 required?: boolean;
60 default?: string | boolean | number;
61 options?: string[];
62 description?: string;
63};
64
65export type ExtendedWorkflow = {
66 name: string;
67 on: string[];
68 /** Keyed by input name — shape mirrors GitHub Actions' `on.workflow_dispatch.inputs`. */
69 dispatchInputs?: Record<string, DispatchInput>;
70 env?: Record<string, string>;
71 jobs: ExtendedJob[];
72 warnings: string[];
73};
74
75export type ExtendedParseResult =
76 | { ok: true; workflow: ExtendedWorkflow }
77 | { ok: false; error: string };
78
79// ---------------------------------------------------------------------------
80// Line-based YAML walker (tolerant — only finds blocks we care about)
81// ---------------------------------------------------------------------------
82
83type Line = { indent: number; text: string; raw: string };
84
85function lexLines(source: string): Line[] {
86 const out: Line[] = [];
87 const raw = source.replace(/\r\n?/g, "\n").split("\n");
88 for (const r of raw) {
89 let indent = 0;
90 while (indent < r.length && (r[indent] === " " || r[indent] === "\t")) indent++;
91 const body = r.slice(indent);
92 if (body.length === 0 || body.startsWith("#")) continue;
93 out.push({ indent, text: body, raw: r });
94 }
95 return out;
96}
97
98/** Parse a scalar that may be quoted, true/false, numeric, or plain. */
99function parseScalar(raw: string): string | number | boolean | null {
100 const s = raw.trim();
101 if (s.length === 0) return null;
102 if (s === "true") return true;
103 if (s === "false") return false;
104 if (s === "null" || s === "~") return null;
105 if (/^-?\d+$/.test(s)) return parseInt(s, 10);
106 if (/^-?\d+\.\d+$/.test(s)) return parseFloat(s);
107 if (
108 (s.startsWith('"') && s.endsWith('"')) ||
109 (s.startsWith("'") && s.endsWith("'"))
110 ) {
111 return s.slice(1, -1);
112 }
113 return s;
114}
115
116/** Parse a flow-style array literal like `[a, b, "c"]` or `[16, 18, 20]`. */
117function parseFlowArray(raw: string): unknown[] {
118 const trimmed = raw.trim();
119 if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) return [];
120 const body = trimmed.slice(1, -1).trim();
121 if (body.length === 0) return [];
122 const items: string[] = [];
123 let depth = 0;
124 let cur = "";
125 let inQuote: string | null = null;
126 for (const ch of body) {
127 if (inQuote) {
128 cur += ch;
129 if (ch === inQuote) inQuote = null;
130 continue;
131 }
132 if (ch === '"' || ch === "'") {
133 inQuote = ch;
134 cur += ch;
135 continue;
136 }
137 if (ch === "[" || ch === "{") depth++;
138 else if (ch === "]" || ch === "}") depth--;
139 if (ch === "," && depth === 0) {
140 items.push(cur);
141 cur = "";
142 } else {
143 cur += ch;
144 }
145 }
146 if (cur.trim().length > 0) items.push(cur);
147 return items.map((s) => parseScalar(s));
148}
149
150/** Given the full line list + start index pointing at "key:", collect every
151 * child line (indent > startIndent). Returns the child sub-array and the
152 * index of the line immediately after the block. */
153function collectBlock(
154 lines: Line[],
155 startIdx: number
156): { children: Line[]; nextIdx: number } {
157 const startIndent = lines[startIdx].indent;
158 const children: Line[] = [];
159 let i = startIdx + 1;
160 while (i < lines.length && lines[i].indent > startIndent) {
161 children.push(lines[i]);
162 i++;
163 }
164 return { children, nextIdx: i };
165}
166
167/** Split "key: value" at the first unquoted colon. Returns [key, value] or
168 * null if the line has no colon (value-only — used for list items). */
169function splitKV(text: string): [string, string] | null {
170 let inQuote: string | null = null;
171 for (let i = 0; i < text.length; i++) {
172 const ch = text[i];
173 if (inQuote) {
174 if (ch === inQuote) inQuote = null;
175 continue;
176 }
177 if (ch === '"' || ch === "'") inQuote = ch;
178 else if (ch === ":" && (i + 1 === text.length || /\s|$/.test(text[i + 1] ?? ""))) {
179 return [text.slice(0, i).trim(), text.slice(i + 1).trim()];
180 }
181 }
182 return null;
183}
184
185/** Parse a block-style mapping (each line is `key: value` at the same
186 * indent). Returns a flat Record<string, string>. */
187function parseBlockMap(children: Line[]): Record<string, string> {
188 const out: Record<string, string> = {};
189 if (children.length === 0) return out;
190 const baseIndent = children[0].indent;
191 for (const ln of children) {
192 if (ln.indent !== baseIndent) continue;
193 const kv = splitKV(ln.text);
194 if (!kv) continue;
195 const scalar = parseScalar(kv[1]);
196 out[kv[0]] = String(scalar ?? "");
197 }
198 return out;
199}
200
201/** Parse `needs:` which can be a scalar, flow-array, or block list. */
202function parseNeeds(valueAfterColon: string, children: Line[]): string[] | undefined {
203 const v = valueAfterColon.trim();
204 if (v.length > 0) {
205 if (v.startsWith("[")) {
206 return parseFlowArray(v).map(String);
207 }
208 return [String(parseScalar(v))];
209 }
210 const items: string[] = [];
211 if (children.length === 0) return undefined;
212 const baseIndent = children[0].indent;
213 for (const ln of children) {
214 if (ln.indent !== baseIndent) continue;
215 if (ln.text.startsWith("- ")) {
216 items.push(String(parseScalar(ln.text.slice(2))));
217 }
218 }
219 return items.length > 0 ? items : undefined;
220}
221
222/** Parse a matrix block. `axes` are any key/value where value is a flow
223 * array or block list. `include`/`exclude` are lists of combo maps. */
224function parseMatrix(children: Line[], warnings: string[]): MatrixSpec | undefined {
225 if (children.length === 0) return undefined;
226 const baseIndent = children[0].indent;
227 const spec: MatrixSpec = { axes: {} };
228 let i = 0;
229 while (i < children.length) {
230 const ln = children[i];
231 if (ln.indent !== baseIndent) {
232 i++;
233 continue;
234 }
235 const kv = splitKV(ln.text);
236 if (!kv) {
237 i++;
238 continue;
239 }
240 const [key, val] = kv;
241 if (key === "include" || key === "exclude") {
242 // Collect child list-of-map entries
243 const slice: Record<string, unknown>[] = [];
244 let j = i + 1;
245 let current: Record<string, unknown> | null = null;
246 while (j < children.length && children[j].indent > baseIndent) {
247 const c = children[j];
248 if (c.text.startsWith("- ")) {
249 if (current) slice.push(current);
250 current = {};
251 const inner = c.text.slice(2);
252 const innerKv = splitKV(inner);
253 if (innerKv) current[innerKv[0]] = parseScalar(innerKv[1]);
254 } else if (current) {
255 const innerKv = splitKV(c.text);
256 if (innerKv) current[innerKv[0]] = parseScalar(innerKv[1]);
257 }
258 j++;
259 }
260 if (current) slice.push(current);
261 if (key === "include") spec.include = slice;
262 else spec.exclude = slice;
263 i = j;
264 continue;
265 }
266 if (key === "fail-fast") {
267 spec.failFast = val.trim() === "true";
268 i++;
269 continue;
270 }
271 if (key === "max-parallel") {
272 const n = parseInt(val.trim(), 10);
273 if (Number.isFinite(n)) spec.maxParallel = n;
274 i++;
275 continue;
276 }
277 // Treat as axis
278 if (val.trim().length > 0 && val.trim().startsWith("[")) {
279 spec.axes[key] = parseFlowArray(val);
280 i++;
281 } else if (val.trim().length === 0) {
282 // Block list under this axis
283 const items: unknown[] = [];
284 let j = i + 1;
285 while (j < children.length && children[j].indent > baseIndent) {
286 const c = children[j];
287 if (c.text.startsWith("- ")) items.push(parseScalar(c.text.slice(2)));
288 j++;
289 }
290 spec.axes[key] = items;
291 i = j;
292 } else {
293 warnings.push(`matrix axis '${key}' has unsupported shape`);
294 i++;
295 }
296 }
297 return spec;
298}
299
300/** Parse `on:` block for workflow_dispatch inputs. */
301function extractDispatchInputs(
302 lines: Line[],
303 warnings: string[]
304): { onArray: string[]; dispatchInputs?: Record<string, DispatchInput> } {
305 const onArray: string[] = [];
306 let dispatchInputs: Record<string, DispatchInput> | undefined;
307 for (let i = 0; i < lines.length; i++) {
308 const ln = lines[i];
309 if (ln.indent !== 0) continue;
310 const kv = splitKV(ln.text);
311 if (!kv || kv[0] !== "on") continue;
312 const val = kv[1].trim();
313 // Scalar/flow form — base parser already handles these; just populate
314 if (val.length > 0) {
315 if (val.startsWith("[")) {
316 onArray.push(...parseFlowArray(val).map(String));
317 } else {
318 onArray.push(String(parseScalar(val)));
319 }
320 break;
321 }
322 // Block form — children can be scalar events or mappings
323 const { children } = collectBlock(lines, i);
324 if (children.length === 0) break;
325 const baseIndent = children[0].indent;
326 for (let j = 0; j < children.length; j++) {
327 const c = children[j];
328 if (c.indent !== baseIndent) continue;
329 if (c.text.startsWith("- ")) {
330 onArray.push(String(parseScalar(c.text.slice(2))));
331 continue;
332 }
333 const eventKv = splitKV(c.text);
334 if (!eventKv) continue;
335 const event = eventKv[0];
336 onArray.push(event);
337 if (event === "workflow_dispatch" && eventKv[1].trim().length === 0) {
338 // Look for nested `inputs:` block
339 let k = j + 1;
340 while (k < children.length && children[k].indent > baseIndent) {
341 const inputsLine = children[k];
342 const inputsKv = splitKV(inputsLine.text);
343 if (inputsKv && inputsKv[0] === "inputs") {
344 dispatchInputs = {};
345 let m = k + 1;
346 const inputBaseIndent = inputsLine.indent + 2;
347 while (m < children.length && children[m].indent >= inputBaseIndent) {
348 const nameLine = children[m];
349 if (nameLine.indent === inputBaseIndent) {
350 const nameKv = splitKV(nameLine.text);
351 if (nameKv) {
352 const inp: DispatchInput = { type: "string" };
353 let n = m + 1;
354 while (n < children.length && children[n].indent > nameLine.indent) {
355 const fieldKv = splitKV(children[n].text);
356 if (fieldKv) {
357 const [fk, fv] = fieldKv;
358 if (fk === "type") {
359 const t = String(parseScalar(fv));
360 if (t === "string" || t === "boolean" || t === "choice" || t === "number") {
361 inp.type = t;
362 }
363 } else if (fk === "required") {
364 inp.required = parseScalar(fv) === true;
365 } else if (fk === "default") {
366 const s = parseScalar(fv);
367 if (s !== null) inp.default = s as string | boolean | number;
368 } else if (fk === "description") {
369 inp.description = String(parseScalar(fv));
370 } else if (fk === "options" && fv.trim().startsWith("[")) {
371 inp.options = parseFlowArray(fv).map(String);
372 }
373 }
374 n++;
375 }
376 dispatchInputs[nameKv[0]] = inp;
377 m = n;
378 continue;
379 }
380 }
381 m++;
382 }
383 k = m;
384 } else {
385 k++;
386 }
387 }
388 }
389 }
390 break;
391 }
392 return { onArray, dispatchInputs };
393}
394
395/** Walk job + step blocks and enrich them. Returns a map of jobName →
396 * extension data, which callers merge onto the base workflow. */
397function extractJobExtensions(
398 lines: Line[],
399 warnings: string[]
400): {
401 workflowEnv?: Record<string, string>;
402 jobExts: Map<string, Partial<ExtendedJob>>;
403 stepExts: Map<string, ExtendedStep[]>; // jobName → step[] aligned by order
404} {
405 const jobExts = new Map<string, Partial<ExtendedJob>>();
406 const stepExts = new Map<string, ExtendedStep[]>();
407 let workflowEnv: Record<string, string> | undefined;
408
409 for (let i = 0; i < lines.length; i++) {
410 const ln = lines[i];
411 if (ln.indent !== 0) continue;
412 const kv = splitKV(ln.text);
413 if (!kv) continue;
414
415 if (kv[0] === "env" && kv[1].trim().length === 0) {
416 const { children, nextIdx } = collectBlock(lines, i);
417 workflowEnv = parseBlockMap(children);
418 i = nextIdx - 1;
419 continue;
420 }
421
422 if (kv[0] !== "jobs" || kv[1].trim().length > 0) continue;
423
424 // Walk each job block
425 const { children: jobChildren } = collectBlock(lines, i);
426 if (jobChildren.length === 0) continue;
427 const jobIndent = jobChildren[0].indent;
428 let j = 0;
429 while (j < jobChildren.length) {
430 const jline = jobChildren[j];
431 if (jline.indent !== jobIndent) {
432 j++;
433 continue;
434 }
435 const jkv = splitKV(jline.text);
436 if (!jkv) {
437 j++;
438 continue;
439 }
440 const jobName = jkv[0];
441 // Absolute index in lines[] for this job header
442 const absIdx = lines.indexOf(jline);
443 const { children: jobBody, nextIdx: jobNextIdx } = collectBlock(lines, absIdx);
444 const ext: Partial<ExtendedJob> = {};
445 const steps: ExtendedStep[] = [];
446 const jobBodyIndent = jobBody.length > 0 ? jobBody[0].indent : 0;
447
448 let k = 0;
449 while (k < jobBody.length) {
450 const bline = jobBody[k];
451 if (bline.indent !== jobBodyIndent) {
452 k++;
453 continue;
454 }
455 const bkv = splitKV(bline.text);
456 if (!bkv) {
457 k++;
458 continue;
459 }
460 const [bkey, bval] = bkv;
461 const bodyAbsIdx = lines.indexOf(bline);
462 const { children: bodyChildren, nextIdx: bodyNextIdx } = collectBlock(
463 lines,
464 bodyAbsIdx
465 );
466
467 if (bkey === "if") {
468 ext.if = bval.trim().length > 0 ? bval.trim() : undefined;
469 k++;
470 } else if (bkey === "needs") {
471 ext.needs = parseNeeds(bval, bodyChildren);
472 k = bodyNextIdx - bodyAbsIdx - 1 + k + 1;
473 } else if (bkey === "env" && bval.trim().length === 0) {
474 ext.env = parseBlockMap(bodyChildren);
475 k += bodyChildren.length + 1;
476 } else if (bkey === "outputs" && bval.trim().length === 0) {
477 ext.outputs = parseBlockMap(bodyChildren);
478 k += bodyChildren.length + 1;
479 } else if (bkey === "strategy" && bval.trim().length === 0) {
480 for (let s = 0; s < bodyChildren.length; s++) {
481 const sline = bodyChildren[s];
482 if (sline.indent !== bodyChildren[0].indent) continue;
483 const skv = splitKV(sline.text);
484 if (skv && skv[0] === "matrix" && skv[1].trim().length === 0) {
485 const sAbsIdx = lines.indexOf(sline);
486 const { children: matrixChildren } = collectBlock(lines, sAbsIdx);
487 const matrix = parseMatrix(matrixChildren, warnings);
488 if (matrix) ext.strategy = { matrix };
489 }
490 }
491 k += bodyChildren.length + 1;
492 } else if (bkey === "steps" && bval.trim().length === 0) {
493 // Walk each step
494 let stepIdx = 0;
495 let s = 0;
496 while (s < bodyChildren.length) {
497 const sline = bodyChildren[s];
498 if (sline.text.startsWith("- ")) {
499 const stepBody: Line[] = [];
500 const stepIndent = sline.indent;
501 const stepFirst: Line = {
502 indent: stepIndent + 2,
503 text: sline.text.slice(2),
504 raw: sline.raw,
505 };
506 stepBody.push(stepFirst);
507 let t = s + 1;
508 while (t < bodyChildren.length && bodyChildren[t].indent > stepIndent) {
509 stepBody.push(bodyChildren[t]);
510 t++;
511 }
512 const stepExt: ExtendedStep = {};
513 for (let u = 0; u < stepBody.length; u++) {
514 const stepLine = stepBody[u];
515 const sKv = splitKV(stepLine.text);
516 if (!sKv) continue;
517 const [sk, sv] = sKv;
518 if (sk === "id") stepExt.id = String(parseScalar(sv));
519 else if (sk === "name") stepExt.name = String(parseScalar(sv));
520 else if (sk === "if") stepExt.if = sv.trim();
521 else if (sk === "run") stepExt.run = String(parseScalar(sv));
522 else if (sk === "uses") stepExt.uses = String(parseScalar(sv));
523 else if (sk === "parallel") stepExt.parallel = parseScalar(sv) === true;
524 else if (sk === "continue-on-error")
525 stepExt.continueOnError = parseScalar(sv) === true;
526 else if (sk === "with" && sv.trim().length === 0) {
527 // Sub-block at deeper indent
528 const withBody: Line[] = [];
529 let v = u + 1;
530 while (v < stepBody.length && stepBody[v].indent > stepLine.indent) {
531 withBody.push(stepBody[v]);
532 v++;
533 }
534 stepExt.with = parseBlockMap(withBody);
535 u = v - 1;
536 } else if (sk === "env" && sv.trim().length === 0) {
537 const envBody: Line[] = [];
538 let v = u + 1;
539 while (v < stepBody.length && stepBody[v].indent > stepLine.indent) {
540 envBody.push(stepBody[v]);
541 v++;
542 }
543 stepExt.env = parseBlockMap(envBody);
544 u = v - 1;
545 }
546 }
547 steps.push(stepExt);
548 stepIdx++;
549 s = t;
550 } else {
551 s++;
552 }
553 }
554 k += bodyChildren.length + 1;
555 } else {
556 k++;
557 }
558 }
559
560 if (Object.keys(ext).length > 0) jobExts.set(jobName, ext);
561 if (steps.length > 0) stepExts.set(jobName, steps);
562 j = jobNextIdx - absIdx - 1 + j + 1;
563 }
564 break;
565 }
566
567 return { workflowEnv, jobExts, stepExts };
568}
569
570// ---------------------------------------------------------------------------
571// Public API
572// ---------------------------------------------------------------------------
573
574/** The base parser requires every step to have a `run:` field. For `uses:`-only
575 * steps (which are legal in GitHub Actions) we inject a no-op `run` so base
576 * parse succeeds; the extended parser then picks up the real `uses:` value. */
577function preprocessForBaseParser(yaml: string): string {
578 const lines = yaml.replace(/\r\n?/g, "\n").split("\n");
579 const out: string[] = [];
580 let inStepsBlock = false;
581 let stepsIndent = -1;
582 for (let i = 0; i < lines.length; i++) {
583 const line = lines[i];
584 const trimmed = line.replace(/^\s+/, "");
585 const indent = line.length - trimmed.length;
586
587 if (/^steps\s*:\s*$/.test(trimmed)) {
588 inStepsBlock = true;
589 stepsIndent = indent;
590 out.push(line);
591 continue;
592 }
593 if (inStepsBlock && trimmed.length > 0 && indent <= stepsIndent && !trimmed.startsWith("-")) {
594 inStepsBlock = false;
595 }
596
597 out.push(line);
598
599 if (inStepsBlock && /^-\s+uses\s*:/.test(trimmed)) {
600 // Look ahead to see if this step block already has a run: line
601 const stepItemIndent = indent;
602 let hasRun = false;
603 for (let j = i + 1; j < lines.length; j++) {
604 const jline = lines[j];
605 const jtrim = jline.replace(/^\s+/, "");
606 const jindent = jline.length - jtrim.length;
607 if (jtrim.length === 0) continue;
608 if (jtrim.startsWith("-") && jindent === stepItemIndent) break;
609 if (jindent <= stepItemIndent) break;
610 if (/^run\s*:/.test(jtrim)) {
611 hasRun = true;
612 break;
613 }
614 }
615 if (!hasRun) {
616 out.push(`${" ".repeat(indent + 2)}run: ':'`);
617 }
618 }
619 }
620 return out.join("\n");
621}
622
623export function parseExtended(yaml: string): ExtendedParseResult {
624 const base = parseWorkflow(preprocessForBaseParser(yaml));
625 if (!base.ok) return base;
626
627 const warnings: string[] = [];
628 let extended: ExtendedWorkflow;
629 try {
630 const lines = lexLines(yaml);
631 const { onArray, dispatchInputs } = extractDispatchInputs(lines, warnings);
632 const { workflowEnv, jobExts, stepExts } = extractJobExtensions(lines, warnings);
633
634 // Merge onto base — keep base structure but enrich each job with extras
635 const mergedOn = onArray.length > 0 ? Array.from(new Set(onArray)) : base.workflow.on;
636
637 const jobs: ExtendedJob[] = base.workflow.jobs.map((baseJob) => {
638 const ext = jobExts.get(baseJob.name) ?? {};
639 const extendedStepsForJob = stepExts.get(baseJob.name) ?? [];
640 const steps: ExtendedStep[] = baseJob.steps.map((bs, idx) => {
641 const ext = extendedStepsForJob[idx] ?? {};
642 return { name: bs.name, run: bs.run, ...ext };
643 });
644 return {
645 name: baseJob.name,
646 runsOn: baseJob.runsOn,
647 steps,
648 if: ext.if,
649 needs: ext.needs,
650 strategy: ext.strategy,
651 env: ext.env,
652 outputs: ext.outputs,
653 };
654 });
655
656 extended = {
657 name: base.workflow.name,
658 on: mergedOn,
659 dispatchInputs,
660 env: workflowEnv,
661 jobs,
662 warnings,
663 };
664 } catch (err) {
665 // Defensive: if enrichment throws for any reason, return the base as
666 // an extended workflow with a single warning — never fail the parse.
667 warnings.push(
668 `extension-pass error: ${err instanceof Error ? err.message : String(err)}`
669 );
670 extended = {
671 name: base.workflow.name,
672 on: base.workflow.on,
673 jobs: base.workflow.jobs.map((j) => ({
674 name: j.name,
675 runsOn: j.runsOn,
676 steps: j.steps.map((s) => ({ name: s.name, run: s.run })),
677 })),
678 warnings,
679 };
680 }
681
682 return { ok: true, workflow: extended };
683}
Modifiedsrc/routes/billing.tsx+123−3View fileUnifiedSplit
2626 listPlans,
2727 setUserPlan,
2828} from "../lib/billing";
29import {
30 createBillingPortalSession,
31 createCheckoutSession,
32 findOrCreateCustomer,
33} from "../lib/stripe";
34import { config } from "../lib/config";
2935
3036const billing = new Hono<AuthEnv>();
3137billing.use("*", softAuth);
144150 CURRENT PLAN
145151 </div>
146152 )}
153 {!isCurrent && p.slug !== "free" && p.priceCents > 0 && (
154 <form
155 method="post"
156 action={`/billing/upgrade/${p.slug}`}
157 style="margin-top:10px"
158 >
159 <button
160 type="submit"
161 class="btn btn-primary"
162 style="width:100%;font-size:13px"
163 >
164 {quota.planSlug === "free" ? "Upgrade" : "Switch"} to {p.name}
165 </button>
166 </form>
167 )}
147168 </div>
148169 );
149170 })}
150171 </div>
151 <p style="font-size:12px;color:var(--text-muted);margin-top:12px">
152 To change plans, contact a site administrator.
153 </p>
172 {quota.planSlug !== "free" && (
173 <div style="margin-top:16px">
174 <form method="post" action="/billing/manage" style="display:inline">
175 <button
176 type="submit"
177 class="btn"
178 style="font-size:13px"
179 >
180 Manage subscription (Stripe Customer Portal)
181 </button>
182 </form>
183 <span
184 style="font-size:12px;color:var(--text-muted);margin-left:12px"
185 >
186 — change card, download invoices, cancel anytime.
187 </span>
188 </div>
189 )}
190 {!process.env.STRIPE_SECRET_KEY && (
191 <p
192 style="font-size:12px;color:var(--text-muted);margin-top:12px;font-style:italic"
193 >
194 (Stripe not yet configured on this instance — upgrade buttons will
195 return a setup error. Run the Stripe Bootstrap workflow to enable.)
196 </p>
197 )}
154198 </Layout>
155199 );
156200});
157201
202// ----- Upgrade flow (Stripe Checkout) -----
203
204billing.post("/billing/upgrade/:plan", requireAuth, async (c) => {
205 const user = c.get("user")!;
206 const planSlug = c.req.param("plan");
207 if (planSlug !== "pro" && planSlug !== "team" && planSlug !== "enterprise") {
208 return c.redirect("/settings/billing?error=invalid-plan");
209 }
210
211 const quota = await getUserQuota(user.id);
212 const customer = await findOrCreateCustomer({
213 userId: user.id,
214 email: user.email ?? `${user.username}@gluecron.local`,
215 existingCustomerId: quota.stripeCustomerId ?? null,
216 });
217 if (!customer.ok) {
218 console.error(`[billing/upgrade] customer: ${customer.error}`);
219 return c.redirect(
220 `/settings/billing?error=${encodeURIComponent(customer.error)}`
221 );
222 }
223
224 const base = (config.appBaseUrl || "").replace(/\/$/, "") || "";
225 const session = await createCheckoutSession({
226 customerId: customer.customerId,
227 planSlug,
228 successUrl: `${base}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
229 cancelUrl: `${base}/billing/cancel`,
230 userId: user.id,
231 });
232 if (!session.ok) {
233 console.error(`[billing/upgrade] checkout: ${session.error}`);
234 return c.redirect(
235 `/settings/billing?error=${encodeURIComponent(session.error)}`
236 );
237 }
238
239 // Stash the customerId onto the quota row now (doesn't wait for webhook)
240 // so subsequent upgrades don't re-create a customer.
241 await db
242 .update(userQuotas)
243 .set({ stripeCustomerId: customer.customerId, updatedAt: new Date() })
244 .where(eq(userQuotas.userId, user.id));
245
246 return c.redirect(session.url, 303);
247});
248
249billing.get("/billing/success", requireAuth, async (c) => {
250 // The webhook does the actual plan assignment; this is just a landing page.
251 return c.redirect("/settings/billing?upgraded=1");
252});
253
254billing.get("/billing/cancel", requireAuth, async (c) => {
255 return c.redirect("/settings/billing?canceled=1");
256});
257
258billing.post("/billing/manage", requireAuth, async (c) => {
259 const user = c.get("user")!;
260 const quota = await getUserQuota(user.id);
261 if (!quota.stripeCustomerId) {
262 return c.redirect("/settings/billing?error=no-subscription");
263 }
264 const base = (config.appBaseUrl || "").replace(/\/$/, "") || "";
265 const session = await createBillingPortalSession({
266 customerId: quota.stripeCustomerId,
267 returnUrl: `${base}/settings/billing`,
268 });
269 if (!session.ok) {
270 console.error(`[billing/manage] portal: ${session.error}`);
271 return c.redirect(
272 `/settings/billing?error=${encodeURIComponent(session.error)}`
273 );
274 }
275 return c.redirect(session.url, 303);
276});
277
158278// ----- Admin billing panel -----
159279
160280billing.get("/admin/billing", async (c) => {
Addedsrc/routes/stripe-webhook.ts+317−0View fileUnifiedSplit
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 { eq, sql } from "drizzle-orm";
22import { db } from "../db";
23import { userQuotas } from "../db/schema";
24import { reportError } from "../lib/observability";
25import { getSubscription, planSlugFromSubscription } from "../lib/stripe";
26
27const stripeWebhook = new Hono();
28
29const TOLERANCE_SECONDS = 300; // 5 minutes — matches Stripe's own default
30
31async function hmacSha256Hex(key: string, message: string): Promise<string> {
32 const enc = new TextEncoder();
33 const cryptoKey = await crypto.subtle.importKey(
34 "raw",
35 enc.encode(key),
36 { name: "HMAC", hash: "SHA-256" },
37 false,
38 ["sign"]
39 );
40 const sig = await crypto.subtle.sign("HMAC", cryptoKey, enc.encode(message));
41 return Array.from(new Uint8Array(sig))
42 .map((b) => b.toString(16).padStart(2, "0"))
43 .join("");
44}
45
46function parseSigHeader(header: string): { t?: string; v1?: string } {
47 const out: { t?: string; v1?: string } = {};
48 for (const part of header.split(",")) {
49 const [k, v] = part.split("=");
50 if (k === "t") out.t = v;
51 else if (k === "v1" && !out.v1) out.v1 = v;
52 }
53 return out;
54}
55
56function constantTimeEqual(a: string, b: string): boolean {
57 if (a.length !== b.length) return false;
58 let diff = 0;
59 for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
60 return diff === 0;
61}
62
63stripeWebhook.post("/api/webhooks/stripe", async (c) => {
64 const secret = process.env.STRIPE_WEBHOOK_SECRET;
65 if (!secret) {
66 console.warn("[stripe-webhook] STRIPE_WEBHOOK_SECRET not set — returning 503");
67 return c.json({ error: "webhook not configured" }, 503);
68 }
69
70 const sigHeader = c.req.header("stripe-signature");
71 if (!sigHeader) return c.json({ error: "missing stripe-signature header" }, 400);
72
73 const raw = await c.req.text();
74
75 const { t, v1 } = parseSigHeader(sigHeader);
76 if (!t || !v1) return c.json({ error: "malformed stripe-signature" }, 400);
77
78 // Replay window check
79 const ts = parseInt(t, 10);
80 if (!Number.isFinite(ts)) return c.json({ error: "bad timestamp" }, 400);
81 const age = Math.abs(Math.floor(Date.now() / 1000) - ts);
82 if (age > TOLERANCE_SECONDS) {
83 return c.json({ error: "timestamp outside tolerance window" }, 400);
84 }
85
86 // HMAC check
87 const expected = await hmacSha256Hex(secret, `${t}.${raw}`);
88 if (!constantTimeEqual(expected, v1)) {
89 return c.json({ error: "signature mismatch" }, 400);
90 }
91
92 let event: { id?: string; type?: string } = {};
93 try {
94 event = JSON.parse(raw);
95 } catch {
96 return c.json({ error: "invalid json" }, 400);
97 }
98
99 console.log(
100 `[stripe-webhook] authentic event id=${event.id} type=${event.type}`
101 );
102
103 // Handle subscription lifecycle. Every handler swallows errors and returns
104 // 200 so Stripe doesn't retry-storm on transient DB issues — we log and
105 // rely on the next event (Stripe fires subscription.updated periodically).
106 try {
107 await handleStripeEvent(event as StripeEvent);
108 } catch (err) {
109 reportError(err as Error, {
110 path: "/api/webhooks/stripe",
111 stripeEventType: (event as StripeEvent).type,
112 stripeEventId: (event as StripeEvent).id,
113 });
114 }
115
116 return c.json({ received: true });
117});
118
119// Defensive error handler local to this route — never leak exception details
120// back to Stripe; always prefer 200 for malformed-but-authenticated payloads
121// to avoid retry storms.
122stripeWebhook.onError((err, c) => {
123 reportError(err, { path: c.req.path, scope: "stripe-webhook" });
124 return c.json({ error: "internal error" }, 500);
125});
126
127// ---------------------------------------------------------------------------
128// Event handlers — each is defensive (never throws). Called from the main
129// route handler after signature verification.
130// ---------------------------------------------------------------------------
131
132type StripeEvent = {
133 id: string;
134 type: string;
135 data?: { object?: Record<string, unknown> };
136};
137
138async function handleStripeEvent(event: StripeEvent): Promise<void> {
139 const obj = event.data?.object ?? {};
140 switch (event.type) {
141 case "checkout.session.completed": {
142 const userId = String(obj.client_reference_id ?? "");
143 const customerId = String(obj.customer ?? "");
144 const subscriptionId = obj.subscription ? String(obj.subscription) : null;
145 if (!userId || !customerId) {
146 console.warn(
147 `[stripe-webhook] checkout.session.completed missing userId/customerId — skipping`
148 );
149 return;
150 }
151 await upsertQuotaRow(userId, {
152 stripeCustomerId: customerId,
153 stripeSubscriptionId: subscriptionId,
154 });
155 // Enrich from the subscription object to set the actual plan slug
156 if (subscriptionId) await reconcileSubscription(subscriptionId, userId);
157 return;
158 }
159 case "customer.subscription.created":
160 case "customer.subscription.updated": {
161 const subId = String(obj.id ?? "");
162 if (!subId) return;
163 const userId = String(
164 (obj.metadata as Record<string, string> | undefined)?.gluecron_user_id ?? ""
165 );
166 await reconcileSubscription(subId, userId || null);
167 return;
168 }
169 case "customer.subscription.deleted": {
170 const customerId = String(obj.customer ?? "");
171 if (!customerId) return;
172 await db
173 .update(userQuotas)
174 .set({
175 planSlug: "free",
176 stripeSubscriptionId: null,
177 stripeSubscriptionStatus: "canceled",
178 currentPeriodEnd: null,
179 updatedAt: new Date(),
180 })
181 .where(eq(userQuotas.stripeCustomerId, customerId));
182 console.log(`[stripe-webhook] downgraded customer=${customerId} to free`);
183 return;
184 }
185 case "invoice.payment_failed": {
186 const customerId = String(obj.customer ?? "");
187 if (!customerId) return;
188 // Mark status — don't downgrade immediately (Stripe retries billing
189 // over a grace window; a future subscription.updated with status=
190 // past_due/unpaid/canceled will do the actual plan move).
191 await db
192 .update(userQuotas)
193 .set({
194 stripeSubscriptionStatus: "past_due",
195 updatedAt: new Date(),
196 })
197 .where(eq(userQuotas.stripeCustomerId, customerId));
198 return;
199 }
200 default:
201 // All other events (invoice.payment_succeeded etc.) — accept silently.
202 return;
203 }
204}
205
206async function reconcileSubscription(
207 subscriptionId: string,
208 fallbackUserId: string | null
209): Promise<void> {
210 const res = await getSubscription(subscriptionId);
211 if (!res.ok) {
212 console.warn(
213 `[stripe-webhook] getSubscription(${subscriptionId}) failed: ${res.error}`
214 );
215 return;
216 }
217 const sub = res.subscription;
218 const slug = planSlugFromSubscription(sub);
219 const customerId = sub.customer;
220 const status = sub.status;
221 const periodEnd = sub.current_period_end
222 ? new Date(sub.current_period_end * 1000)
223 : null;
224
225 // Prefer locating by customer id (set during checkout.session.completed).
226 // Fall back to metadata gluecron_user_id if present.
227 const userId =
228 (sub.metadata?.gluecron_user_id as string | undefined) ?? fallbackUserId ?? null;
229
230 if (status !== "active" && status !== "trialing") {
231 // Non-active subscriptions don't grant a paid plan. We still record
232 // their state so the user can see "past_due" in /settings/billing.
233 if (customerId) {
234 await db
235 .update(userQuotas)
236 .set({
237 stripeSubscriptionId: sub.id,
238 stripeSubscriptionStatus: status,
239 currentPeriodEnd: periodEnd,
240 updatedAt: new Date(),
241 })
242 .where(eq(userQuotas.stripeCustomerId, customerId));
243 }
244 return;
245 }
246
247 if (!slug) {
248 console.warn(
249 `[stripe-webhook] subscription ${sub.id} active but no plan slug resolvable — leaving plan unchanged`
250 );
251 return;
252 }
253
254 if (userId) {
255 await upsertQuotaRow(userId, {
256 planSlug: slug,
257 stripeCustomerId: customerId,
258 stripeSubscriptionId: sub.id,
259 stripeSubscriptionStatus: status,
260 currentPeriodEnd: periodEnd,
261 });
262 console.log(
263 `[stripe-webhook] user=${userId} → plan=${slug} (status=${status})`
264 );
265 return;
266 }
267
268 // Last resort: locate by customer id and update in place
269 if (customerId) {
270 await db
271 .update(userQuotas)
272 .set({
273 planSlug: slug,
274 stripeSubscriptionId: sub.id,
275 stripeSubscriptionStatus: status,
276 currentPeriodEnd: periodEnd,
277 updatedAt: new Date(),
278 })
279 .where(eq(userQuotas.stripeCustomerId, customerId));
280 }
281}
282
283async function upsertQuotaRow(
284 userId: string,
285 patch: {
286 planSlug?: string;
287 stripeCustomerId?: string | null;
288 stripeSubscriptionId?: string | null;
289 stripeSubscriptionStatus?: string | null;
290 currentPeriodEnd?: Date | null;
291 }
292): Promise<void> {
293 // Try update first; if no row exists, insert.
294 const res = await db
295 .update(userQuotas)
296 .set({ ...patch, updatedAt: new Date() })
297 .where(eq(userQuotas.userId, userId))
298 .returning({ userId: userQuotas.userId });
299 if (res.length === 0) {
300 await db
301 .insert(userQuotas)
302 .values({
303 userId,
304 planSlug: patch.planSlug ?? "free",
305 stripeCustomerId: patch.stripeCustomerId ?? null,
306 stripeSubscriptionId: patch.stripeSubscriptionId ?? null,
307 stripeSubscriptionStatus: patch.stripeSubscriptionStatus ?? null,
308 currentPeriodEnd: patch.currentPeriodEnd ?? null,
309 })
310 .onConflictDoUpdate({
311 target: userQuotas.userId,
312 set: { ...patch, updatedAt: new Date() },
313 });
314 }
315}
316
317export default stripeWebhook;
0318