From a90162a561dcd7b0c7fb013544ed713827112842 Mon Sep 17 00:00:00 2001 From: Mason Wyatt Date: Wed, 29 Apr 2026 16:15:52 -0400 Subject: [PATCH 1/2] feat(stack-stripe): --webhook-endpoint flag creates and persists whsec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit stack add stripe --webhook-endpoint [--events ] now: - reuses or interactively pastes sk_live_… (or reads from vault via --secret-key-from-vault) - POSTs to /v1/webhook_endpoints using the Stripe secret key - captures the whsec_… signing secret and we_… endpoint ID - stores STRIPE_WEBHOOK_SECRET + STRIPE_WEBHOOK_ENDPOINT_ID in Phantom so a future rotate command can update the same endpoint Default event list is the subscription-lifecycle set (5 events). Custom events passed via --events override it. ProviderContext gains an optional `hints` field so providers can read CLI flags during login(), not only during provision(). 8 new tests cover: successful creation, custom events, default events, missing-vault error path, Stripe API error propagation, and regression guard for the plain sk_… flow. Co-Authored-By: Claude Sonnet 4.6 --- README.md | 19 ++ packages/cli/src/commands/add.ts | 29 +- .../core/src/__tests__/stripe-webhook.test.ts | 263 +++++++++++++++++ packages/core/src/pipeline.ts | 1 + packages/core/src/providers/_base.ts | 6 + packages/core/src/providers/stripe.ts | 275 ++++++++++++++++-- 6 files changed, 569 insertions(+), 24 deletions(-) create mode 100644 packages/core/src/__tests__/stripe-webhook.test.ts diff --git a/README.md b/README.md index 30b97ef..3ce666c 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,25 @@ Stack is the *control plane*. [Phantom](https://phm.dev) is the *vault*. ashlr-p ```bash stack init # interactive template picker stack add supabase # OAuth → new project → secrets → .mcp.json +stack add stripe # paste sk_live_… → validates → stores STRIPE_SECRET_KEY + +# Stripe webhook endpoint — fully agent-driveable, no dashboard copy-paste +stack add stripe --webhook-endpoint https://example.com/webhooks/stripe +# → creates the endpoint via Stripe API +# → stores STRIPE_WEBHOOK_SECRET (whsec_…) + STRIPE_WEBHOOK_ENDPOINT_ID in Phantom +# → default events: customer.subscription.{created,updated,deleted,trial_will_end} +# invoice.payment_failed + +# Custom event list +stack add stripe \ + --webhook-endpoint https://example.com/webhooks/stripe \ + --events "payment_intent.succeeded,charge.failed" + +# Reuse an existing sk_… from the vault (skip the interactive paste) +stack add stripe \ + --webhook-endpoint https://example.com/webhooks/stripe \ + --secret-key-from-vault + stack providers # full catalog (29 services across 11 categories) stack doctor --fix # verify every service; re-run setup for anything broken stack exec -- bun dev # run with Phantom's secret proxy active diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 7f36bed..863fa2b 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -41,6 +41,22 @@ export const addCommand = defineCommand({ default: "ask", description: "SDK install behaviour after provisioning: ask (default), always, never.", }, + webhookEndpoint: { + type: "string", + description: + "Stripe only: HTTPS URL to register as a webhook endpoint. Triggers webhook provisioning and stores STRIPE_WEBHOOK_SECRET + STRIPE_WEBHOOK_ENDPOINT_ID in Phantom.", + }, + events: { + type: "string", + description: + "Stripe only: comma-separated list of Stripe events to subscribe to. Defaults to the subscription-lifecycle set when --webhook-endpoint is given.", + }, + secretKeyFromVault: { + type: "boolean", + default: false, + description: + "Stripe only: skip the interactive sk_… paste and reuse STRIPE_SECRET_KEY already in Phantom.", + }, }, async run({ args }) { if (!hasConfig()) { @@ -145,7 +161,7 @@ export const addCommand = defineCommand({ const result = await addService({ providerName: service, existingResourceId: args.use ? String(args.use) : undefined, - hints: args.region ? { region: String(args.region) } : undefined, + hints: buildHints(args), interactive: process.stdout.isTTY === true, log: (event) => { spinner.stop(); @@ -221,6 +237,17 @@ async function handleSdkInstall(pkgs: string[], mode: string, dryRun: boolean): } } +function buildHints( + args: Record, +): Record | undefined { + const hints: Record = {}; + if (args.region) hints.region = String(args.region); + if (args.webhookEndpoint) hints.webhookEndpoint = String(args.webhookEndpoint); + if (args.events) hints.events = String(args.events); + if (args.secretKeyFromVault) hints.secretKeyFromVault = true; + return Object.keys(hints).length > 0 ? hints : undefined; +} + async function groupProvidersForPicker(names: string[]) { const out: Array<{ label: string; value: string; hint?: string }> = []; for (const name of names) { diff --git a/packages/core/src/__tests__/stripe-webhook.test.ts b/packages/core/src/__tests__/stripe-webhook.test.ts new file mode 100644 index 0000000..792784d --- /dev/null +++ b/packages/core/src/__tests__/stripe-webhook.test.ts @@ -0,0 +1,263 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import type { ProviderContext } from "../providers/_base.ts"; +import stripe, { + DEFAULT_WEBHOOK_EVENTS, + _createWebhookEndpoint, + _verifyKey, +} from "../providers/stripe.ts"; +import { type Harness, readVault, setupFakePhantom } from "./_harness.ts"; + +/** + * Unit tests for the Stripe webhook provisioning flow. + * + * All HTTP is mocked — these tests never reach api.stripe.com. + * + * Scenarios: + * (a) Successful webhook creation — keys land in vault, secrets returned correctly. + * (b) Webhook creation with custom events list. + * (c) Default events list is used when --events is omitted. + * (d) Error path when sk_ is not in vault and --secret-key-from-vault is set. + * (e) Stripe API error propagates as StackError with STRIPE_WEBHOOK_CREATE_FAILED code. + * (f) Plain sk_… flow (no webhook) still works after the refactor. + */ + +const FAKE_SK = "sk_test_fake_secret_key"; +const FAKE_WEBHOOK_ID = "we_1234567890abcdef"; +const FAKE_WHSEC = "whsec_abcdefghijklmnopqrstuvwxyz0123456789"; +const WEBHOOK_URL = "https://example.com/webhooks/stripe"; + +function makeCtx(overrides: Partial = {}): ProviderContext { + return { cwd: process.cwd(), interactive: false, log: () => {}, ...overrides }; +} + +function mockFetchOk(body: unknown, status = 200): typeof fetch { + return (async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch; +} + +function stripeAccountOk(): Response { + return new Response(JSON.stringify({ id: "acct_fake", type: "standard" }), { status: 200 }); +} + +function webhookCreateOk(endpointId: string, whsec: string, url: string): Response { + return new Response( + JSON.stringify({ id: endpointId, secret: whsec, url, object: "webhook_endpoint" }), + { status: 201 }, + ); +} + +describe("stripe provider — webhook provisioning", () => { + let h: Harness; + let realFetch: typeof fetch; + + beforeEach(() => { + h = setupFakePhantom(); + realFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + h.cleanup(); + }); + + // ── (a) Successful webhook creation ────────────────────────────────────── + + test("(a) provision creates webhook, materialize stores all three secrets", async () => { + const captured: { url: string; method: string; body: string }[] = []; + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? "GET").toUpperCase(); + captured.push({ url, method, body: String(init?.body ?? "") }); + + if (url.includes("/v1/account")) return stripeAccountOk(); + if (url.includes("/v1/webhook_endpoints") && method === "POST") { + return webhookCreateOk(FAKE_WEBHOOK_ID, FAKE_WHSEC, WEBHOOK_URL); + } + throw new Error(`Unexpected ${method} ${url}`); + }) as unknown as typeof fetch; + + const ctx = makeCtx(); + const auth = { token: FAKE_SK, identity: { id: "acct_fake", type: "standard" } }; + + const resource = await stripe.provision(ctx, auth, { + hints: { webhookEndpoint: WEBHOOK_URL }, + }); + + expect(resource.id).toBe(FAKE_WEBHOOK_ID); + expect(resource.displayName).toBe(`Stripe webhook (${FAKE_WEBHOOK_ID})`); + expect((resource.meta as Record).webhookSecret).toBe(FAKE_WHSEC); + expect((resource.meta as Record).webhookEndpointId).toBe(FAKE_WEBHOOK_ID); + + const materialized = await stripe.materialize(ctx, resource, auth); + + // All three env names must be in the returned secrets map. + expect(materialized.secrets.STRIPE_SECRET_KEY).toBe(FAKE_SK); + expect(materialized.secrets.STRIPE_WEBHOOK_SECRET).toBe(FAKE_WHSEC); + expect(materialized.secrets.STRIPE_WEBHOOK_ENDPOINT_ID).toBe(FAKE_WEBHOOK_ID); + + // Vault must contain all three. + const vault = await readVault(h.dir); + expect(vault.STRIPE_SECRET_KEY).toBe(FAKE_SK); + expect(vault.STRIPE_WEBHOOK_SECRET).toBe(FAKE_WHSEC); + expect(vault.STRIPE_WEBHOOK_ENDPOINT_ID).toBe(FAKE_WEBHOOK_ID); + + // The POST to /v1/webhook_endpoints must use form-encoded body. + const webhookCall = captured.find( + (c) => c.url.includes("/v1/webhook_endpoints") && c.method === "POST", + ); + expect(webhookCall).toBeTruthy(); + expect(webhookCall!.body).toContain(encodeURIComponent(WEBHOOK_URL)); + }); + + // ── (b) Custom events list is forwarded to Stripe ──────────────────────── + + test("(b) custom --events are sent in the POST body", async () => { + const customEvents = ["payment_intent.succeeded", "charge.failed"]; + let capturedBody = ""; + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/v1/webhook_endpoints") && method === "POST") { + capturedBody = String(init?.body ?? ""); + return webhookCreateOk(FAKE_WEBHOOK_ID, FAKE_WHSEC, WEBHOOK_URL); + } + throw new Error(`Unexpected ${method} ${url}`); + }) as unknown as typeof fetch; + + const ctx = makeCtx(); + const auth = { token: FAKE_SK, identity: {} }; + + await stripe.provision(ctx, auth, { + hints: { webhookEndpoint: WEBHOOK_URL, events: customEvents.join(",") }, + }); + + for (const ev of customEvents) { + expect(capturedBody).toContain(encodeURIComponent(ev)); + } + // Default events must NOT appear when a custom list is provided. + for (const defaultEv of DEFAULT_WEBHOOK_EVENTS) { + expect(capturedBody).not.toContain(encodeURIComponent(defaultEv)); + } + }); + + // ── (c) Default events list ─────────────────────────────────────────────── + + test("(c) default subscription-lifecycle events are used when --events is omitted", async () => { + let capturedBody = ""; + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/v1/webhook_endpoints") && method === "POST") { + capturedBody = String(init?.body ?? ""); + return webhookCreateOk(FAKE_WEBHOOK_ID, FAKE_WHSEC, WEBHOOK_URL); + } + throw new Error(`Unexpected ${method} ${url}`); + }) as unknown as typeof fetch; + + const ctx = makeCtx(); + const auth = { token: FAKE_SK, identity: {} }; + + await stripe.provision(ctx, auth, { hints: { webhookEndpoint: WEBHOOK_URL } }); + + expect(DEFAULT_WEBHOOK_EVENTS.length).toBeGreaterThan(0); + for (const ev of DEFAULT_WEBHOOK_EVENTS) { + expect(capturedBody).toContain(encodeURIComponent(ev)); + } + }); + + // ── (d) --secret-key-from-vault fails when vault is empty ──────────────── + + test("(d) login throws STRIPE_AUTH_REQUIRED when vault is empty and --secret-key-from-vault", async () => { + // Vault is empty (setupFakePhantom initialises with {}). + const ctx = makeCtx({ hints: { secretKeyFromVault: true } }); + + await expect(stripe.login(ctx)).rejects.toMatchObject({ + code: "STRIPE_AUTH_REQUIRED", + }); + }); + + // ── (e) Stripe API error propagates correctly ───────────────────────────── + + test("(e) Stripe 400 during webhook creation throws STRIPE_WEBHOOK_CREATE_FAILED", async () => { + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + const method = (init?.method ?? "GET").toUpperCase(); + if (url.includes("/v1/webhook_endpoints") && method === "POST") { + return new Response( + JSON.stringify({ error: { message: "Invalid URL" } }), + { status: 400 }, + ); + } + throw new Error(`Unexpected ${method} ${url}`); + }) as unknown as typeof fetch; + + const ctx = makeCtx(); + const auth = { token: FAKE_SK, identity: {} }; + + await expect( + stripe.provision(ctx, auth, { hints: { webhookEndpoint: "not-a-url" } }), + ).rejects.toMatchObject({ code: "STRIPE_WEBHOOK_CREATE_FAILED" }); + }); + + // ── (f) Plain sk_… flow unchanged ──────────────────────────────────────── + + test("(f) provision without webhookEndpoint hint behaves like makeApiKeyProvider", async () => { + // No HTTP call is made in the plain flow. + globalThis.fetch = (async () => { + throw new Error("fetch must not be called in the plain sk_ flow"); + }) as unknown as typeof fetch; + + const ctx = makeCtx(); + const auth = { token: FAKE_SK, identity: { id: "acct_fake", type: "standard" } }; + + const resource = await stripe.provision(ctx, auth, {}); + expect(resource.id).toBe("acct_fake"); + + const materialized = await stripe.materialize(ctx, resource, auth); + expect(materialized.secrets.STRIPE_SECRET_KEY).toBe(FAKE_SK); + // Webhook secrets must NOT appear when no webhook was created. + expect(materialized.secrets.STRIPE_WEBHOOK_SECRET).toBeUndefined(); + expect(materialized.secrets.STRIPE_WEBHOOK_ENDPOINT_ID).toBeUndefined(); + + const vault = await readVault(h.dir); + expect(vault.STRIPE_SECRET_KEY).toBe(FAKE_SK); + expect(vault.STRIPE_WEBHOOK_SECRET).toBeUndefined(); + }); +}); + +describe("stripe provider — _createWebhookEndpoint (unit)", () => { + let realFetch: typeof fetch; + + beforeEach(() => { + realFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = realFetch; + }); + + test("returns parsed endpoint response on 201", async () => { + globalThis.fetch = mockFetchOk( + { id: FAKE_WEBHOOK_ID, secret: FAKE_WHSEC, url: WEBHOOK_URL }, + 201, + ); + const result = await _createWebhookEndpoint(FAKE_SK, WEBHOOK_URL, DEFAULT_WEBHOOK_EVENTS); + expect(result.id).toBe(FAKE_WEBHOOK_ID); + expect(result.secret).toBe(FAKE_WHSEC); + expect(result.url).toBe(WEBHOOK_URL); + }); + + test("throws on non-2xx with Stripe error detail", async () => { + globalThis.fetch = (async () => + new Response( + JSON.stringify({ error: { message: "URL must be HTTPS" } }), + { status: 400 }, + )) as unknown as typeof fetch; + + await expect( + _createWebhookEndpoint(FAKE_SK, "http://insecure.example.com/hook", ["payment_intent.succeeded"]), + ).rejects.toMatchObject({ code: "STRIPE_WEBHOOK_CREATE_FAILED" }); + }); +}); diff --git a/packages/core/src/pipeline.ts b/packages/core/src/pipeline.ts index 7eadac9..d391f30 100644 --- a/packages/core/src/pipeline.ts +++ b/packages/core/src/pipeline.ts @@ -49,6 +49,7 @@ export async function addService(opts: AddServiceOpts): Promise {}), + hints: opts.hints, }; const auth = await provider.login(ctx); diff --git a/packages/core/src/providers/_base.ts b/packages/core/src/providers/_base.ts index 1123b6e..3f0d931 100644 --- a/packages/core/src/providers/_base.ts +++ b/packages/core/src/providers/_base.ts @@ -24,6 +24,12 @@ export interface ProviderContext { interactive: boolean; /** Structured logger — callers wire this to the CLI's @clack spinner. */ log: (event: LogEvent) => void; + /** + * Provider-specific hints forwarded from the CLI (e.g. webhookEndpoint, + * secretKeyFromVault). Available to both `login` and `provision` so providers + * can alter their auth flow based on flags. + */ + hints?: Record; } export interface LogEvent { diff --git a/packages/core/src/providers/stripe.ts b/packages/core/src/providers/stripe.ts index 8e780a9..f51209a 100644 --- a/packages/core/src/providers/stripe.ts +++ b/packages/core/src/providers/stripe.ts @@ -1,32 +1,261 @@ -import { makeApiKeyProvider } from "./_api-key.ts"; -import { verifyFetch } from "./_helpers.ts"; +import { StackError } from "../errors.ts"; +import { addSecret } from "../phantom.ts"; +import type { + AuthHandle, + HealthStatus, + Materialized, + Provider, + ProviderContext, + ProvisionOpts, + Resource, +} from "./_base.ts"; +import { readLine, scrub, tryRevealSecret, verifyFetch } from "./_helpers.ts"; /** - * Stripe — v1 uses a restricted secret key (users create at - * https://dashboard.stripe.com/apikeys). Full Stripe Connect / account - * linking lands when we register the Ashlr Stack OAuth app with Stripe. + * Stripe provider — two modes: + * + * stack add stripe + * Classic API-key paste. Validates sk_live_…/sk_test_… against + * /v1/account and stores it as STRIPE_SECRET_KEY. + * + * stack add stripe --webhook-endpoint [--events ] + * Also creates a webhook endpoint via /v1/webhook_endpoints, captures the + * signing secret (whsec_…), and stores: + * STRIPE_WEBHOOK_SECRET — the signing secret + * STRIPE_WEBHOOK_ENDPOINT_ID — so a future `stack rotate stripe` can + * update the same endpoint, not create + * a duplicate + * + * Hints (passed via ProvisionOpts.hints from the CLI): + * webhookEndpoint — HTTPS URL to register (activates webhook flow) + * events — comma-separated event list; defaults to subscription lifecycle + * secretKeyFromVault — skip interactive paste, use existing STRIPE_SECRET_KEY */ -export default makeApiKeyProvider({ + +const SECRET_KEY_NAME = "STRIPE_SECRET_KEY"; +const WEBHOOK_SECRET_NAME = "STRIPE_WEBHOOK_SECRET"; +const WEBHOOK_ENDPOINT_ID_NAME = "STRIPE_WEBHOOK_ENDPOINT_ID"; + +/** + * Subscription-lifecycle events that almost every SaaS needs out of the box. + * Operators can override via --events. + */ +export const DEFAULT_WEBHOOK_EVENTS = [ + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "customer.subscription.trial_will_end", + "invoice.payment_failed", +]; + +interface StripeHints { + webhookEndpoint?: string; + events?: string; + secretKeyFromVault?: boolean; +} + +async function verifyKey(key: string): Promise | undefined> { + try { + const res = await verifyFetch("https://api.stripe.com/v1/account", { + headers: { Authorization: `Bearer ${key}` }, + }); + if (!res.ok) return undefined; + const body = (await res.json()) as Record; + const out: Record = {}; + for (const [k, v] of Object.entries(body)) if (typeof v === "string") out[k] = v; + return out; + } catch { + return undefined; + } +} + +export interface WebhookEndpointResponse { + id: string; + secret: string; + url: string; +} + +async function createWebhookEndpoint( + secretKey: string, + url: string, + events: string[], +): Promise { + const body = new URLSearchParams(); + body.set("url", url); + for (const ev of events) body.append("enabled_events[]", ev); + // Pin to a specific Stripe API version for predictable response shapes. + body.set("api_version", "2024-06-20"); + + const res = await verifyFetch("https://api.stripe.com/v1/webhook_endpoints", { + method: "POST", + headers: { + Authorization: `Bearer ${secretKey}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + }); + + if (!res.ok) { + const errBody = (await res.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + const detail = errBody.error?.message ?? `HTTP ${res.status}`; + throw new StackError( + "STRIPE_WEBHOOK_CREATE_FAILED", + `Stripe webhook creation failed: ${detail}`, + ); + } + + return res.json() as Promise; +} + +const stripeProvider: Provider = { name: "stripe", displayName: "Stripe", category: "payments", + authKind: "api_key", docs: "https://docs.stripe.com/api", - secretName: "STRIPE_SECRET_KEY", - howTo: - "Create a restricted key at https://dashboard.stripe.com/apikeys (use test mode for development)", - dashboard: "https://dashboard.stripe.com", - async verify(key) { - try { - const res = await verifyFetch("https://api.stripe.com/v1/account", { - headers: { Authorization: `Bearer ${key}` }, - }); - if (!res.ok) return undefined; - const body = (await res.json()) as Record; - const out: Record = {}; - for (const [k, v] of Object.entries(body)) if (typeof v === "string") out[k] = v; - return out; - } catch { - return undefined; + + async login(ctx: ProviderContext): Promise { + // --secret-key-from-vault: bypass interactive paste when the key is already stored. + const hints = ctx.hints as StripeHints | undefined; + if (hints?.secretKeyFromVault) { + const cached = await tryRevealSecret(SECRET_KEY_NAME); + if (!cached) { + throw new StackError( + "STRIPE_AUTH_REQUIRED", + `--secret-key-from-vault specified but ${SECRET_KEY_NAME} is not in the vault. Run \`stack add stripe\` first.`, + ); + } + const identity = await verifyKey(cached); + if (!identity) { + throw new StackError( + "STRIPE_AUTH_INVALID", + `${SECRET_KEY_NAME} in vault is invalid. Re-run \`stack add stripe\` to update it.`, + ); + } + return { token: cached, identity }; + } + + // Standard path: prefer the cached key when it still validates. + const cached = await tryRevealSecret(SECRET_KEY_NAME); + if (cached) { + const identity = await verifyKey(cached); + if (identity) return { token: cached, identity }; + ctx.log({ level: "warn", msg: "Cached Stripe key invalid; re-entering." }); + } + + if (!ctx.interactive) { + throw new StackError( + "STRIPE_AUTH_REQUIRED", + "Stripe: no valid key in vault and session is non-interactive.", + ); + } + + process.stderr.write( + "\n Create a restricted key at https://dashboard.stripe.com/apikeys\n Paste your Stripe secret key (sk_live_… or sk_test_…): ", + ); + const key = (await readLine()).trim(); + if (!key) throw new StackError("STRIPE_AUTH_REQUIRED", "No key provided."); + + const identity = await verifyKey(key); + if (!identity) throw new StackError("STRIPE_AUTH_INVALID", "Stripe rejected that key."); + + // Persist immediately so a webhook-creation failure doesn't force a re-paste. + await addSecret(SECRET_KEY_NAME, key); + return { token: key, identity }; + }, + + async provision(ctx: ProviderContext, auth: AuthHandle, opts: ProvisionOpts): Promise { + const hints = opts.hints as StripeHints | undefined; + const webhookUrl = hints?.webhookEndpoint; + + if (!webhookUrl) { + // Plain sk_… flow — no webhook provisioning, behave like makeApiKeyProvider. + const id = + opts.existingResourceId ?? + auth.identity?.id ?? + auth.identity?.org_id ?? + "default"; + return { + id, + displayName: + (auth.identity?.name as string | undefined) ?? + (auth.identity?.email as string | undefined) ?? + "Stripe", + meta: auth.identity, + }; } + + // Webhook flow — call Stripe to create the endpoint. + const rawEvents = hints?.events; + const events = rawEvents + ? rawEvents + .split(",") + .map((e) => e.trim()) + .filter(Boolean) + : DEFAULT_WEBHOOK_EVENTS; + + ctx.log({ + level: "info", + msg: `Creating Stripe webhook endpoint: ${webhookUrl} (${events.length} events)`, + }); + + const endpoint = await createWebhookEndpoint(auth.token, webhookUrl, events); + + ctx.log({ + level: "info", + // scrub shows only the prefix (whsec_xxx…) — never the full secret. + msg: `Webhook created: ${endpoint.id} · secret ${scrub(endpoint.secret, 4)}`, + }); + + return { + id: endpoint.id, + displayName: `Stripe webhook (${endpoint.id})`, + meta: { + webhookEndpointId: endpoint.id, + webhookSecret: endpoint.secret, + webhookUrl: endpoint.url, + }, + }; }, -}); + + async materialize(_ctx: ProviderContext, resource: Resource, auth: AuthHandle): Promise { + // Always persist the secret key. + await addSecret(SECRET_KEY_NAME, auth.token); + const secrets: Record = { [SECRET_KEY_NAME]: auth.token }; + + const meta = resource.meta as + | { webhookSecret?: string; webhookEndpointId?: string } + | undefined; + + if (meta?.webhookSecret) { + await addSecret(WEBHOOK_SECRET_NAME, meta.webhookSecret); + secrets[WEBHOOK_SECRET_NAME] = meta.webhookSecret; + } + if (meta?.webhookEndpointId) { + await addSecret(WEBHOOK_ENDPOINT_ID_NAME, meta.webhookEndpointId); + secrets[WEBHOOK_ENDPOINT_ID_NAME] = meta.webhookEndpointId; + } + + return { secrets }; + }, + + async healthcheck(_ctx: ProviderContext): Promise { + const key = await tryRevealSecret(SECRET_KEY_NAME); + if (!key) return { kind: "error", detail: `${SECRET_KEY_NAME} missing from vault` }; + const start = Date.now(); + const identity = await verifyKey(key); + const latencyMs = Date.now() - start; + return identity ? { kind: "ok", latencyMs } : { kind: "error", detail: "key invalid" }; + }, + + dashboardUrl() { + return "https://dashboard.stripe.com"; + }, +}; + +export default stripeProvider; + +// Internal exports for unit tests only — not part of the public API. +export { verifyKey as _verifyKey, createWebhookEndpoint as _createWebhookEndpoint }; From 0c5ba33c8d80cf204b5fc2802343814683559702 Mon Sep 17 00:00:00 2001 From: Mason Wyatt Date: Wed, 29 Apr 2026 21:38:25 -0400 Subject: [PATCH 2/2] chore: remove tracked .claude/scheduled_tasks.lock (now globally ignored) Assisted-By: ashlr-plugin --- .claude/scheduled_tasks.lock | 1 - 1 file changed, 1 deletion(-) delete mode 100644 .claude/scheduled_tasks.lock diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 041f907..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"84fdbcc7-4ac9-4f3b-9796-8ddb910d77f4","pid":14123,"procStart":"Thu Apr 23 23:19:44 2026","acquiredAt":1777003139758} \ No newline at end of file