From 956163ebf0a72abfc991b1a1e6be141d4b07e2ec Mon Sep 17 00:00:00 2001 From: VeldtJumper Date: Mon, 27 Jul 2026 18:24:24 -0500 Subject: [PATCH 1/2] feat: add Stripe webhook support --- README.md | 2 + THIRD_PARTY_NOTICES.md | 12 + examples/stripe/stripe.json | 50 ++++ src/provider/ProviderRegistry.ts | 2 + src/provider/Stripe.ts | 368 +++++++++++++++++++++++ test/examples/examples-spec.ts | 1 + test/provider/provider-registry-spec.ts | 1 + test/stripe/stripe-spec.ts | 375 ++++++++++++++++++++++++ web/public/providers/stripe.svg | 1 + web/src/pages/index.astro | 20 +- web/src/scripts/form.ts | 8 + web/src/styles/global.css | 19 ++ web/test/provider-order.test.mjs | 14 + 13 files changed, 868 insertions(+), 5 deletions(-) create mode 100644 examples/stripe/stripe.json create mode 100644 src/provider/Stripe.ts create mode 100644 test/stripe/stripe-spec.ts create mode 100644 web/public/providers/stripe.svg diff --git a/README.md b/README.md index 099fbcc..6d8edd9 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ https://skyhookapi.com/api/webhooks/firstPartOfWebhook/secondPartOfWebhook/provi - [Rollbar](https://docs.rollbar.com/docs/webhooks) - `/rollbar` - [Shopify](https://shopify.dev/docs/api/webhooks/latest) - `/shopify` - [Square](https://developer.squareup.com/docs/webhooks/overview) - `/square` +- [Stripe](https://docs.stripe.com/webhooks) - `/stripe` (informational notifications only; Skyhook's public relay cannot + verify the endpoint-specific `Stripe-Signature`, so don't use it for fulfillment, entitlements, or billing state) - [Travis](https://docs.travis-ci.com/user/notifications/#Webhooks-Delivery-Format) - `/travis` - [Trello](https://developers.trello.com/apis/webhooks) - `/trello` - [Unity Cloud](https://build-api.cloud.unity3d.com/docs/1.0.0/index.html#operation-webhooks-intro) - `/unity` diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3c33a03..11b4381 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -12,6 +12,18 @@ Buildkite is a trademark of Buildkite Pty Ltd. The icon is used only to identify the supported provider. +## Simple Icons — Stripe icon + +- File: `web/public/providers/stripe.svg` +- Project: Simple Icons +- Source: https://github.com/simple-icons/simple-icons/blob/25d6e5b39bc55bc446e147700294628af1734f7e/icons/stripe.svg +- Revision: `25d6e5b39bc55bc446e147700294628af1734f7e` +- Imported size: 588 bytes +- Imported SHA-256: `130c6d957b8977f5eda2928267b9df531ca038a400a801765d263801bb1bd870` +- License: CC0 1.0 Universal; a copy is included at `web/public/providers/LICENSE.simple-icons.md` + +Stripe is a trademark of Stripe, Inc. The icon is used only to identify the supported provider. + ## Simple Icons — Linear icon - File: `web/public/providers/linear.svg` diff --git a/examples/stripe/stripe.json b/examples/stripe/stripe.json new file mode 100644 index 0000000..f048a84 --- /dev/null +++ b/examples/stripe/stripe.json @@ -0,0 +1,50 @@ +{ + "id": "evt_1NG8Du2eZvKYlo2CUI79vXWy", + "object": "event", + "api_version": "2019-02-19", + "created": 1686089970, + "data": { + "object": { + "id": "seti_1NG8Du2eZvKYlo2C9XMqbR0x", + "object": "setup_intent", + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1NG8Du2eZvKYlo2C9XMqbR0x_secret_O2CdhLwGFh2Aej7bCY7qp8jlIuyR8DJ", + "created": 1686089970, + "customer": null, + "description": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": null, + "livemode": false, + "mandate": null, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1NG8Du2eZvKYlo2CYzzldNr7", + "payment_method_options": { + "acss_debit": { + "currency": "cad", + "mandate_options": { + "interval_description": "First day of every month", + "payment_schedule": "interval", + "transaction_type": "personal" + }, + "verification_method": "automatic" + } + }, + "payment_method_types": ["acss_debit"], + "single_use_mandate": null, + "status": "requires_confirmation", + "usage": "off_session" + } + }, + "livemode": false, + "pending_webhooks": 0, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "setup_intent.created" +} diff --git a/src/provider/ProviderRegistry.ts b/src/provider/ProviderRegistry.ts index 6472b22..e5b6f3b 100644 --- a/src/provider/ProviderRegistry.ts +++ b/src/provider/ProviderRegistry.ts @@ -23,6 +23,7 @@ import type { ProviderDefinition } from './Provider.ts' import { Rollbar } from './Rollbar.ts' import { Shopify } from './Shopify.ts' import { Square } from './Square.ts' +import { Stripe } from './Stripe.ts' import { Travis } from './Travis.ts' import { Trello } from './Trello.ts' import { Unity } from './Unity.ts' @@ -91,6 +92,7 @@ const providerDefinitions: readonly ProviderDefinition[] = [ Rollbar, Shopify, Square, + Stripe, Travis, Trello, Unity, diff --git a/src/provider/Stripe.ts b/src/provider/Stripe.ts new file mode 100644 index 0000000..aa7d687 --- /dev/null +++ b/src/provider/Stripe.ts @@ -0,0 +1,368 @@ +import type { Embed, EmbedField } from '../model/DiscordApi.ts' +import { DISCORD_EMBED_LIMITS, fitLiteralEmbedFields } from '../util/DiscordEmbed.ts' +import { escapeDiscordMarkdownLiteral, humanizeWords, truncateText } from '../util/DiscordText.ts' +import { canonicalizeIso8601Timestamp, firstScalar, isRecord, safeId, scalarText } from '../util/WebhookValue.ts' +import { defineProvider, type ProviderOutput } from './Provider.ts' + +const EVENT_TYPE_PATTERN = + /^[a-z0-9_]+(?:\[[a-z0-9_]+(?:\.[a-z0-9_]+)*\])?(?:\.[a-z0-9_]+(?:\[[a-z0-9_]+(?:\.[a-z0-9_]+)*\])?)+$/ +const OBJECT_TYPE_PATTERN = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/ +const STRIPE_ID_PATTERN = /^[A-Za-z0-9_]+$/ +const MAX_EVENT_TYPE_CHARACTERS = 200 +const MAX_OBJECT_TYPE_CHARACTERS = 200 +const MAX_ID_CHARACTERS = 255 +const MAX_CHANGED_FIELDS = 20 +const MAX_CHANGED_FIELD_LABEL_CHARACTERS = 80 +const MAX_UNIX_TIMESTAMP_SECONDS = 253_402_300_799 +const ZERO_DECIMAL_CURRENCIES = new Set([ + 'BIF', + 'CLP', + 'DJF', + 'GNF', + 'JPY', + 'KMF', + 'KRW', + 'MGA', + 'PYG', + 'RWF', + 'VND', + 'VUV', + 'XAF', + 'XOF', + 'XPF', +]) + +const AMOUNT_FIELDS = [ + ['amount_received', 'Amount received'], + ['amount_paid', 'Amount paid'], + ['amount_due', 'Amount due'], + ['amount_refunded', 'Amount refunded'], + ['amount_captured', 'Amount captured'], + ['amount_total', 'Amount total'], + ['total', 'Total'], + ['amount', 'Amount'], +] as const + +const PAYMENT_INTENT_SUCCEEDED_AMOUNT_FIELDS = [ + ['amount_received', 'Amount received'], + ['amount', 'Amount'], +] as const +const PAYMENT_INTENT_AMOUNT_FIELDS = [ + ['amount', 'Amount'], + ['amount_received', 'Amount received'], +] as const +const INVOICE_PAID_AMOUNT_FIELDS = [ + ['amount_paid', 'Amount paid'], + ['amount_due', 'Amount due'], + ['amount_remaining', 'Amount remaining'], + ['total', 'Total'], +] as const +const INVOICE_OUTSTANDING_AMOUNT_FIELDS = [ + ['amount_due', 'Amount due'], + ['amount_remaining', 'Amount remaining'], + ['amount_paid', 'Amount paid'], + ['total', 'Total'], +] as const +const CHARGE_AMOUNT_FIELDS = [ + ['amount', 'Amount'], + ['amount_captured', 'Amount captured'], + ['amount_refunded', 'Amount refunded'], +] as const +const CHARGE_REFUNDED_AMOUNT_FIELDS = [ + ['amount_refunded', 'Amount refunded'], + ['amount', 'Amount'], +] as const +const CHECKOUT_SESSION_AMOUNT_FIELDS = [ + ['amount_total', 'Amount total'], + ['amount_subtotal', 'Amount subtotal'], +] as const + +/** + * Converts Stripe snapshot Event objects and v2 thin event notifications into bounded Discord embeds. + * + * Stripe requires webhook signature verification over the exact raw body using an endpoint-specific secret. + * Skyhook neither possesses that secret nor dispatches the untouched raw body to providers, so this adapter cannot + * verify Stripe-Signature. It is suitable for informational notifications only and treats every value as untrusted. + * + * @see https://docs.stripe.com/webhooks + * @see https://docs.stripe.com/api/events/object + * @see https://docs.stripe.com/event-destinations#events-overview + */ +export const Stripe = defineProvider({ + path: 'stripe', + name: 'Stripe', + example: { body: 'stripe/stripe.json' }, + defaults: { + username: 'Stripe', + embedColor: 0x635bff, + }, + map({ body }, output) { + const eventId = stripeId(body.id) + const eventType = boundedType(body.type, EVENT_TYPE_PATTERN, MAX_EVENT_TYPE_CHARACTERS) + if (eventId == null || eventType == null || typeof body.livemode !== 'boolean') { + output.ignore() + return + } + + if (body.object === 'event') { + mapSnapshotEvent(body, eventId, eventType, output) + return + } + if (body.object === 'v2.core.event') { + mapThinEvent(body, eventId, eventType, output) + return + } + output.ignore() + }, +}) + +function mapSnapshotEvent(body: Record, eventId: string, eventType: string, output: ProviderOutput): void { + const timestamp = stripeUnixTimestamp(body.created) + const data = isRecord(body.data) ? body.data : null + const object = isRecord(data?.object) ? data.object : null + const objectType = boundedType(object?.object, OBJECT_TYPE_PATTERN, MAX_OBJECT_TYPE_CHARACTERS) + const hasObjectId = object != null && Object.hasOwn(object, 'id') + const objectId = hasObjectId ? stripeId(object.id) : null + const accountId = optionalStripeId(body, 'account') + const context = optionalStripeId(body, 'context') + if ( + timestamp == null || + data == null || + object == null || + objectType == null || + (hasObjectId && objectId == null) || + accountId === false || + context === false + ) { + output.ignore() + return + } + + const embed: Embed = { + title: boundedLiteral(eventTitle(eventType), DISCORD_EMBED_LIMITS.title, true), + timestamp, + } + const description = firstScalar(object.description, object.name) + if (description != null) { + embed.description = boundedLiteral(description, DISCORD_EMBED_LIMITS.description, false) + } + + const fields = snapshotFields(body, data, object, objectType, eventType, objectId, eventId, accountId, context) + embed.fields = fitLiteralEmbedFields(embed, fields) + output.addEmbed(embed) +} + +function mapThinEvent(body: Record, eventId: string, eventType: string, output: ProviderOutput): void { + const timestamp = canonicalizeIso8601Timestamp(body.created) + const context = optionalStripeId(body, 'context') + const hasRelatedObject = Object.hasOwn(body, 'related_object') && body.related_object != null + const relatedObject = hasRelatedObject && isRecord(body.related_object) ? body.related_object : null + const relatedObjectId = relatedObject == null ? null : stripeId(relatedObject.id) + const relatedObjectType = + relatedObject == null ? null : boundedType(relatedObject.type, OBJECT_TYPE_PATTERN, MAX_OBJECT_TYPE_CHARACTERS) + if ( + timestamp == null || + context === false || + (hasRelatedObject && (relatedObject == null || relatedObjectId == null || relatedObjectType == null)) + ) { + output.ignore() + return + } + + const embed: Embed = { + title: boundedLiteral(eventTitle(eventType), DISCORD_EMBED_LIMITS.title, true), + timestamp, + } + const fields: EmbedField[] = [{ name: 'Mode', value: body.livemode ? 'Live' : 'Test', inline: true }] + if (relatedObjectType != null && relatedObjectId != null) { + fields.push( + { name: 'Related object', value: humanizeStripeType(relatedObjectType), inline: true }, + { name: 'Related object ID', value: relatedObjectId, inline: true }, + ) + } + if (context != null) { + fields.push({ name: 'Context', value: context, inline: true }) + } + fields.push({ name: 'Event ID', value: eventId, inline: true }) + embed.fields = fitLiteralEmbedFields(embed, fields) + output.addEmbed(embed) +} + +function snapshotFields( + body: Record, + data: Record, + object: Record, + objectType: string, + eventType: string, + objectId: string | null, + eventId: string, + accountId: string | null, + context: string | null, +): EmbedField[] { + const fields: EmbedField[] = [{ name: 'Mode', value: body.livemode ? 'Live' : 'Test', inline: true }] + + const status = scalarText(object.status) + if (status != null) { + fields.push({ name: 'Status', value: humanizeEnum(status), inline: true }) + } + + const amount = firstAmount(object, eventType, objectType) + if (amount != null) { + fields.push({ name: amount.label, value: amount.value, inline: true }) + } + + if (accountId != null) addIdField(fields, 'Account ID', accountId) + if (context != null) addIdField(fields, 'Context', context) + addReferenceField(fields, 'Customer ID', object.customer) + addReferenceField(fields, 'Subscription ID', object.subscription) + addReferenceField(fields, 'Invoice ID', object.invoice) + addReferenceField(fields, 'Payment intent ID', object.payment_intent) + addReferenceField(fields, 'Charge ID', object.charge) + + const failure = firstScalar(object.failure_message, nestedScalar(object.last_payment_error, 'message')) + if (failure != null) { + fields.push({ name: 'Failure', value: failure, inline: false }) + } + + if (objectId != null) { + addIdField(fields, `${humanizeStripeType(objectType)} ID`, objectId) + } + + const changedFields = changedFieldSummary(data.previous_attributes) + if (changedFields != null) { + fields.push({ name: 'Changed fields', value: changedFields, inline: false }) + } + fields.push({ name: 'Event ID', value: eventId, inline: true }) + return fields +} + +function firstAmount( + object: Record, + eventType: string, + objectType: string, +): { label: string; value: string } | null { + const currency = boundedCurrency(object.currency) + if (currency == null) { + return null + } + for (const [property, label] of amountFieldsFor(eventType, objectType)) { + const value = formatStripeAmount(object[property], currency) + if (value != null) { + return { label, value } + } + } + return null +} + +function amountFieldsFor(eventType: string, objectType: string): readonly (readonly [string, string])[] { + if (objectType === 'payment_intent') { + return eventType === 'payment_intent.succeeded' + ? PAYMENT_INTENT_SUCCEEDED_AMOUNT_FIELDS + : PAYMENT_INTENT_AMOUNT_FIELDS + } + if (objectType === 'invoice') { + return eventType === 'invoice.paid' || eventType === 'invoice.payment_succeeded' + ? INVOICE_PAID_AMOUNT_FIELDS + : INVOICE_OUTSTANDING_AMOUNT_FIELDS + } + if (objectType === 'charge') { + return eventType === 'charge.refunded' ? CHARGE_REFUNDED_AMOUNT_FIELDS : CHARGE_AMOUNT_FIELDS + } + if (objectType === 'checkout.session') { + return CHECKOUT_SESSION_AMOUNT_FIELDS + } + return AMOUNT_FIELDS +} + +function formatStripeAmount(value: unknown, currency: string): string | null { + if (!Number.isSafeInteger(value)) { + return null + } + const fractionDigits = ZERO_DECIMAL_CURRENCIES.has(currency) ? 0 : 2 + return `${(Number(value) / 10 ** fractionDigits).toFixed(fractionDigits)} ${currency}` +} + +function boundedCurrency(value: unknown): string | null { + return typeof value === 'string' && /^[A-Za-z]{3}$/.test(value) ? value.toUpperCase() : null +} + +function addReferenceField(fields: EmbedField[], name: string, value: unknown): void { + const id = isRecord(value) ? stripeId(value.id) : stripeId(value) + if (id != null) { + addIdField(fields, name, id) + } +} + +function addIdField(fields: EmbedField[], name: string, value: string): void { + if (!fields.some((field) => field.name === name && field.value === value)) { + fields.push({ name, value, inline: true }) + } +} + +function changedFieldSummary(value: unknown): string | null { + if (!isRecord(value)) { + return null + } + const keys = Object.keys(value) + if (keys.length === 0) { + return null + } + const labels = keys + .slice(0, MAX_CHANGED_FIELDS) + .map((key) => + truncateText(humanizeWords(key).replace(/\burl\b/gi, 'URL'), MAX_CHANGED_FIELD_LABEL_CHARACTERS, true), + ) + .sort((left, right) => left.localeCompare(right)) + const summary = labels.join(', ') + return keys.length <= labels.length ? summary : `${summary} (+${keys.length - labels.length} more)` +} + +function nestedScalar(value: unknown, key: string): string | null { + return isRecord(value) ? scalarText(value[key]) : null +} + +function optionalStripeId(body: Record, property: string): string | null | false { + if (!Object.hasOwn(body, property) || body[property] == null) { + return null + } + return stripeId(body[property]) ?? false +} + +function stripeId(value: unknown): string | null { + if (typeof value !== 'string') { + return null + } + const id = safeId(value, MAX_ID_CHARACTERS) + return id != null && STRIPE_ID_PATTERN.test(id) ? id : null +} + +function boundedType(value: unknown, pattern: RegExp, maxLength: number): string | null { + if (typeof value !== 'string' || value.length === 0 || value.length > maxLength) { + return null + } + return pattern.test(value) ? value : null +} + +function stripeUnixTimestamp(value: unknown): string | null { + if (!Number.isSafeInteger(value) || Number(value) < 0 || Number(value) > MAX_UNIX_TIMESTAMP_SECONDS) { + return null + } + return new Date(Number(value) * 1000).toISOString() +} + +function eventTitle(eventType: string): string { + return humanizeStripeType(eventType) +} + +function humanizeStripeType(value: string): string { + const withoutVersion = value.replace(/^v\d+\./, '').replace(/[[\]]/g, '.') + return humanizeWords(withoutVersion) +} + +function humanizeEnum(value: string): string { + return /^[A-Za-z][A-Za-z0-9_-]{0,100}$/.test(value) ? humanizeWords(value) : value +} + +function boundedLiteral(value: string, maxLength: number, singleLine: boolean): string { + return truncateText(escapeDiscordMarkdownLiteral(value), maxLength, singleLine) +} diff --git a/test/examples/examples-spec.ts b/test/examples/examples-spec.ts index af8eb9f..c197cc3 100644 --- a/test/examples/examples-spec.ts +++ b/test/examples/examples-spec.ts @@ -37,6 +37,7 @@ const expectedProviderPaths = [ 'rollbar', 'shopify', 'square', + 'stripe', 'travis', 'trello', 'unity', diff --git a/test/provider/provider-registry-spec.ts b/test/provider/provider-registry-spec.ts index e2bfc54..bf54825 100644 --- a/test/provider/provider-registry-spec.ts +++ b/test/provider/provider-registry-spec.ts @@ -64,6 +64,7 @@ const expectedMetadata = [ example: { body: 'shopify/shopify.json', headers: 'shopify/shopify.headers.json' }, }, { path: 'square', name: 'Square', example: { body: 'square/square.json' } }, + { path: 'stripe', name: 'Stripe', example: { body: 'stripe/stripe.json' } }, { path: 'travis', name: 'Travis', example: { body: 'travis/travis.json' } }, { path: 'trello', name: 'Trello', example: { body: 'trello/trello.json' } }, { path: 'unity', name: 'Unity Cloud', example: { body: 'unity/unity.json' } }, diff --git a/test/stripe/stripe-spec.ts b/test/stripe/stripe-spec.ts new file mode 100644 index 0000000..7f6099a --- /dev/null +++ b/test/stripe/stripe-spec.ts @@ -0,0 +1,375 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { app } from '../../src/index.ts' +import { loadProviderExample } from '../../src/ProviderExamples.ts' +import { Stripe } from '../../src/provider/Stripe.ts' +import { validateDiscordPayload } from '../../src/util/DiscordPayloadValidator.ts' +import { Tester } from '../Tester.ts' + +const snapshotEvent = { + id: 'evt_123', + object: 'event', + api_version: '2026-06-24.dahlia', + created: 1785175200, + livemode: false, + pending_webhooks: 1, + request: { id: 'req_123', idempotency_key: null }, +} + +describe('/POST stripe', () => { + it('advertises and accepts the Stripe webhook endpoint', async () => { + const providersResponse = await app.request('/api/providers') + assert.equal(providersResponse.status, 200) + const providers = (await providersResponse.json()) as { name: string; path: string }[] + + assert.deepEqual( + providers.find((provider) => provider.path === 'stripe'), + { name: 'Stripe', path: 'stripe' }, + ) + assert.equal((await app.request('/api/webhooks/example-id/example-secret/stripe')).status, 200) + }) + + it('exposes provider metadata and formats the packaged Stripe snapshot example', async () => { + assert.equal(Stripe.name, 'Stripe') + assert.equal(Stripe.path, 'stripe') + + const example = loadProviderExample('stripe') + const result = await Tester.testWithBody(Stripe, example.body, example.headers, example.query) + + assert.notEqual(result, null) + assert.equal(result!.username, 'Stripe') + assert.deepEqual(result!.allowed_mentions, { parse: [] }) + assert.equal(result!.embeds?.length, 1) + assert.equal(result!.embeds![0].title, 'Setup intent created') + assert.equal(result!.embeds![0].timestamp, '2023-06-06T22:19:30.000Z') + assert.equal(result!.embeds![0].color, 0x635bff) + assert.deepEqual(result!.embeds![0].fields, [ + { name: 'Mode', value: 'Test', inline: true }, + { name: 'Status', value: 'Requires confirmation', inline: true }, + { + name: 'Setup intent ID', + value: 'seti\\_1NG8Du2eZvKYlo2C9XMqbR0x', + inline: true, + }, + { name: 'Event ID', value: 'evt\\_1NG8Du2eZvKYlo2CUI79vXWy', inline: true }, + ]) + assert.doesNotMatch(JSON.stringify(result), /client_secret|O2CdhLwGFh2Aej7bCY7qp8jlIuyR8DJ/) + assert.deepEqual(validateDiscordPayload(result!), []) + }) + + it('summarizes successful payments with amount, customer, description, and IDs', async () => { + const result = await Tester.testWithBody(Stripe, { + ...snapshotEvent, + type: 'payment_intent.succeeded', + data: { + object: { + id: 'pi_123', + object: 'payment_intent', + amount: 2599, + amount_received: 2599, + currency: 'usd', + customer: 'cus_123', + description: 'Order **#42** from @everyone', + status: 'succeeded', + }, + }, + }) + + assert.notEqual(result, null) + const embed = result!.embeds![0] + assert.equal(embed.title, 'Payment intent succeeded') + assert.equal(embed.description, 'Order \\*\\*\\#42\\*\\* from @everyone') + assert.equal(embed.timestamp, '2026-07-27T18:00:00.000Z') + assert.deepEqual(embed.fields, [ + { name: 'Mode', value: 'Test', inline: true }, + { name: 'Status', value: 'Succeeded', inline: true }, + { name: 'Amount received', value: '25.99 USD', inline: true }, + { name: 'Customer ID', value: 'cus\\_123', inline: true }, + { name: 'Payment intent ID', value: 'pi\\_123', inline: true }, + { name: 'Event ID', value: 'evt\\_123', inline: true }, + ]) + assert.deepEqual(result!.allowed_mentions, { parse: [] }) + }) + + it('summarizes failed invoice payments and changed attributes without dumping private data', async () => { + const result = await Tester.testWithBody(Stripe, { + ...snapshotEvent, + id: 'evt_invoice_failed', + livemode: true, + type: 'invoice.payment_failed', + account: 'acct_123', + data: { + previous_attributes: { + status: 'draft', + hosted_invoice_url: 'https://example.invalid/private', + }, + object: { + id: 'in_123', + object: 'invoice', + amount_due: 4500, + amount_paid: 0, + amount_remaining: 4500, + currency: 'eur', + customer: 'cus_456', + subscription: 'sub_456', + status: 'open', + customer_email: 'private@example.com', + }, + }, + }) + + assert.notEqual(result, null) + const embed = result!.embeds![0] + assert.equal(embed.title, 'Invoice payment failed') + assert.deepEqual(embed.fields, [ + { name: 'Mode', value: 'Live', inline: true }, + { name: 'Status', value: 'Open', inline: true }, + { name: 'Amount due', value: '45.00 EUR', inline: true }, + { name: 'Account ID', value: 'acct\\_123', inline: true }, + { name: 'Customer ID', value: 'cus\\_456', inline: true }, + { name: 'Subscription ID', value: 'sub\\_456', inline: true }, + { name: 'Invoice ID', value: 'in\\_123', inline: true }, + { name: 'Changed fields', value: 'Hosted invoice URL, Status', inline: false }, + { name: 'Event ID', value: 'evt\\_invoice\\_failed', inline: true }, + ]) + assert.doesNotMatch(JSON.stringify(result), /private@example\.com|example\.invalid/) + }) + + it('shows amount paid for successful invoice payments', async () => { + const result = await Tester.testWithBody(Stripe, { + ...snapshotEvent, + type: 'invoice.payment_succeeded', + data: { + object: { + id: 'in_paid', + object: 'invoice', + amount_due: 4500, + amount_paid: 4500, + amount_remaining: 0, + currency: 'eur', + status: 'paid', + }, + }, + }) + + assert.notEqual(result, null) + assert.ok(result!.embeds![0].fields?.some(({ name, value }) => name === 'Amount paid' && value === '45.00 EUR')) + }) + + it('shows the intended amount for failed PaymentIntents instead of a zero received amount', async () => { + const result = await Tester.testWithBody(Stripe, { + ...snapshotEvent, + type: 'payment_intent.payment_failed', + data: { + object: { + id: 'pi_failed', + object: 'payment_intent', + amount: 2599, + amount_received: 0, + currency: 'usd', + status: 'requires_payment_method', + }, + }, + }) + + assert.notEqual(result, null) + assert.ok(result!.embeds![0].fields?.some(({ name, value }) => name === 'Amount' && value === '25.99 USD')) + assert.ok(!result!.embeds![0].fields?.some(({ name }) => name === 'Amount received')) + }) + + it('formats zero-decimal currencies using Stripe minor-unit rules', async () => { + const result = await Tester.testWithBody(Stripe, { + ...snapshotEvent, + type: 'charge.succeeded', + data: { + object: { + id: 'ch_123', + object: 'charge', + amount: 500, + currency: 'jpy', + status: 'succeeded', + }, + }, + }) + + assert.notEqual(result, null) + assert.ok(result!.embeds![0].fields?.some(({ name, value }) => name === 'Amount' && value === '500 JPY')) + + const ugx = await Tester.testWithBody(Stripe, { + ...snapshotEvent, + type: 'charge.succeeded', + data: { object: { id: 'ch_ugx', object: 'charge', amount: 500, currency: 'ugx' } }, + }) + assert.notEqual(ugx, null) + assert.ok(ugx!.embeds![0].fields?.some(({ name, value }) => name === 'Amount' && value === '5.00 UGX')) + }) + + it('supports Stripe thin event notifications without requiring an API fetch', async () => { + const result = await Tester.testWithBody(Stripe, { + id: 'evt_test_65UIRNU7G1XbhCfOim416TgmEI4ASQ3jHxXt8RFwXoeVwO', + object: 'v2.core.event', + type: 'v2.core.account.updated', + livemode: false, + created: '2026-03-09T13:00:28.435Z', + context: 'acct_context_123', + related_object: { + id: 'acct_1T93Q4Pmpb34Vto6', + type: 'v2.core.account', + url: '/v2/core/accounts/acct_1T93Q4Pmpb34Vto6', + }, + }) + + assert.notEqual(result, null) + const embed = result!.embeds![0] + assert.equal(embed.title, 'Core account updated') + assert.equal(embed.timestamp, '2026-03-09T13:00:28.435Z') + assert.deepEqual(embed.fields, [ + { name: 'Mode', value: 'Test', inline: true }, + { name: 'Related object', value: 'Core account', inline: true }, + { name: 'Related object ID', value: 'acct\\_1T93Q4Pmpb34Vto6', inline: true }, + { name: 'Context', value: 'acct\\_context\\_123', inline: true }, + { + name: 'Event ID', + value: 'evt\\_test\\_65UIRNU7G1XbhCfOim416TgmEI4ASQ3jHxXt8RFwXoeVwO', + inline: true, + }, + ]) + }) + + it('accepts documented v2 event types with bracketed selectors', async () => { + for (const [eventType, expectedTitle] of [ + [ + 'v2.core.account[configuration.customer].capability_status_updated', + 'Core account configuration customer capability status updated', + ], + ['v2.core.account[requirements].updated', 'Core account requirements updated'], + ]) { + const result = await Tester.testWithBody(Stripe, { + id: 'evt_test_bracketed_selector', + object: 'v2.core.event', + type: eventType, + livemode: false, + created: '2026-03-09T13:00:28.435Z', + related_object: { + id: 'acct_1T93Q4Pmpb34Vto6', + type: 'v2.core.account', + url: '/v2/core/accounts/acct_1T93Q4Pmpb34Vto6', + }, + }) + + assert.notEqual(result, null, eventType) + assert.equal(result!.embeds![0].title, expectedTitle) + } + }) + + it('handles future snapshot event families and deleted object snapshots generically', async () => { + const result = await Tester.testWithBody(Stripe, { + ...snapshotEvent, + type: 'future_resource.reviewed', + data: { + object: { + id: 'fr_123', + object: 'future_resource', + deleted: true, + }, + }, + }) + + assert.notEqual(result, null) + assert.equal(result!.embeds![0].title, 'Future resource reviewed') + assert.ok( + result!.embeds![0].fields?.some(({ name, value }) => name === 'Future resource ID' && value === 'fr\\_123'), + ) + }) + + it('accepts snapshot singleton resources without IDs and thin events without related objects', async () => { + const singleton = await Tester.testWithBody(Stripe, { + ...snapshotEvent, + type: 'balance.available', + data: { object: { object: 'balance', livemode: false } }, + }) + assert.notEqual(singleton, null) + assert.equal(singleton!.embeds![0].title, 'Balance available') + assert.doesNotMatch(JSON.stringify(singleton), /Balance ID/) + + const thin = await Tester.testWithBody(Stripe, { + id: 'evt_test_without_related_object', + object: 'v2.core.event', + type: 'v2.core.event_destination.ping', + livemode: false, + created: '2025-01-01T00:00:00.000Z', + related_object: null, + }) + assert.notEqual(thin, null) + assert.equal(thin!.embeds![0].title, 'Core event destination ping') + assert.deepEqual(thin!.embeds![0].fields, [ + { name: 'Mode', value: 'Test', inline: true }, + { name: 'Event ID', value: 'evt\\_test\\_without\\_related\\_object', inline: true }, + ]) + }) + + it('rejects malformed snapshot and thin event envelopes', async () => { + const validSnapshot = { + ...snapshotEvent, + type: 'customer.created', + data: { object: { id: 'cus_123', object: 'customer' } }, + } + const validThin = { + id: 'evt_test_123', + object: 'v2.core.event', + type: 'v2.core.account.updated', + livemode: false, + created: '2026-03-09T13:00:28.435Z', + related_object: { id: 'acct_123', type: 'v2.core.account', url: '/v2/core/accounts/acct_123' }, + } + + for (const body of [ + null, + {}, + { ...validSnapshot, id: '' }, + { ...validSnapshot, object: 'not_event' }, + { ...validSnapshot, type: 'invalid event' }, + { ...validThin, type: 'v2.core.account[].updated' }, + { ...validThin, type: 'v2.core.account[requirements.updated' }, + { ...validThin, type: 'v2.core.account[[requirements]].updated' }, + { ...validSnapshot, created: -1 }, + { ...validSnapshot, created: Number.MAX_SAFE_INTEGER }, + { ...validSnapshot, livemode: 'false' }, + { ...validSnapshot, data: null }, + { ...validSnapshot, data: { object: null } }, + { ...validSnapshot, data: { object: { id: null, object: 'customer' } } }, + { ...validSnapshot, data: { object: { id: 'cus_123', object: '' } } }, + { ...validThin, created: '2026-02-31T00:00:00Z' }, + { ...validThin, related_object: { id: null, type: 'v2.core.account' } }, + { ...validThin, related_object: { id: '', type: 'v2.core.account' } }, + ]) { + assert.equal(await Tester.testWithBody(Stripe, body), null) + } + }) + + it('stays within Discord limits for long untrusted values', async () => { + const longText = '@everyone [click](https://evil.example) ' + 'x'.repeat(7000) + const result = await Tester.testWithBody(Stripe, { + ...snapshotEvent, + type: 'payment_intent.payment_failed', + data: { + previous_attributes: Object.fromEntries( + Array.from({ length: 100 }, (_, index) => [`private_field_${index}_${longText}`, 'old']), + ), + object: { + id: 'pi_123', + object: 'payment_intent', + description: longText, + status: longText, + amount: 1234, + currency: 'usd', + }, + }, + }) + + assert.notEqual(result, null) + assert.deepEqual(result!.allowed_mentions, { parse: [] }) + assert.deepEqual(validateDiscordPayload(result!), []) + assert.doesNotMatch(result!.embeds![0].description ?? '', /\[click\]\(https:\/\/evil\.example\)/) + }) +}) diff --git a/web/public/providers/stripe.svg b/web/public/providers/stripe.svg new file mode 100644 index 0000000..8ebadf7 --- /dev/null +++ b/web/public/providers/stripe.svg @@ -0,0 +1 @@ +Stripe \ No newline at end of file diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 8224935..3d91a36 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -31,6 +31,7 @@ const providers: Provider[] = [ { name: 'Rollbar', slug: 'rollbar', color: '#5c7cf5' }, { name: 'Shopify', slug: 'shopify', color: '#95bf47' }, { name: 'Square', slug: 'square', color: '#006aff', monogram: 'SQ' }, + { name: 'Stripe', slug: 'stripe', color: '#635bff' }, { name: 'Travis CI', slug: 'travis', color: '#3eaaaf' }, { name: 'Trello', slug: 'trello', color: '#2684ff' }, { name: 'Unity Cloud', slug: 'unity', color: '#e8e8e8' }, @@ -48,9 +49,9 @@ const year = new Date().getFullYear() const SITE = Astro.site ?? new URL('https://www.skyhookapi.com') const canonical = new URL(base, SITE).href const ogImage = new URL(`${base}og.png`, SITE).href -const pageTitle = 'skyhook — Discord Notifications for Shopify, GitLab, Jira & 20+ Tools' +const pageTitle = 'skyhook — Discord Notifications for Stripe, Shopify, GitLab & 20+ Tools' const pageDescription = - 'skyhook turns webhooks from Shopify, GitLab, Hugging Face, Jira, Docker Hub, CI tools and 20+ other services into clean, native Discord notifications — no bot to host, no code to write.' + 'skyhook turns webhooks from Stripe, Shopify, GitLab, Hugging Face, Jira, Docker Hub, CI tools and 20+ other services into clean, native Discord notifications — no bot to host, no code to write.' const faqs = [ { @@ -59,12 +60,16 @@ const faqs = [ }, { q: 'Which services does skyhook support?', - a: `${providers.length} and counting — including Buildkite, Shopify, Square, Zendesk, Linear, GitLab, Hugging Face, Jira, Confluence, Bitbucket, Docker Hub, Travis CI, CircleCI, Jenkins, Heroku, New Relic, Rollbar, Pingdom, Uptime Robot, Trello, Patreon and Azure DevOps.`, + a: `${providers.length} and counting — including Stripe, Buildkite, Shopify, Square, Zendesk, Linear, GitLab, Hugging Face, Jira, Confluence, Bitbucket, Docker Hub, Travis CI, CircleCI, Jenkins, Heroku, New Relic, Rollbar, Pingdom, Uptime Robot, Trello, Patreon and Azure DevOps.`, }, { q: 'Do I need to host a bot or write any code?', a: 'No. skyhook is a hosted service, so there is no Discord bot to run and no code to write. You generate a URL, paste it into your provider, and notifications start arriving in your channel.', }, + { + q: 'Can I use Stripe notifications for billing automation?', + a: "No. skyhook's Stripe integration is for informational Discord notifications only. The public relay doesn't know your endpoint-specific signing secret, so it can't verify Stripe-Signature. Use an authenticated backend for fulfillment, entitlements and billing state.", + }, { q: 'Does skyhook work with GitHub?', a: 'GitHub can already post to Discord natively — just add /github to the end of your Discord webhook URL. skyhook focuses on the many services that do not have built-in Discord support.', @@ -160,7 +165,7 @@ const structuredData = [

Turn webhooks into beautiful Discord notifications.

- skyhook translates webhooks from Shopify, GitLab, Hugging Face, Jira, Docker Hub and 20+ other + skyhook translates webhooks from Stripe, Shopify, GitLab, Hugging Face, Jira, Docker Hub and 20+ other services into clean, native Discord notifications. No bot to host, no code to write — generate a URL, paste it into your provider, and you're done.

@@ -287,7 +292,7 @@ const structuredData = [ Supported providers

Discord notifications for the tools you already use

- From GitLab pushes and Jira issues to Docker Hub builds, CI results, and Pingdom or Uptime + From Stripe payments, GitLab pushes, and Jira issues to Docker Hub builds, CI results, and Pingdom or Uptime Robot alerts — skyhook delivers a clean Discord notification for every event, across {providers.length} {' '}services and counting.

@@ -361,6 +366,11 @@ const structuredData = [ +
diff --git a/web/src/scripts/form.ts b/web/src/scripts/form.ts index a4f4c89..d66ca22 100644 --- a/web/src/scripts/form.ts +++ b/web/src/scripts/form.ts @@ -6,6 +6,7 @@ const DISCORD_HOST = 'discord.com' const $ = (sel: string) => document.querySelector(sel) const providerSelect = $('#provider')! +const providerWarning = $('#provider-warning')! const urlField = $('#url-field')! const urlInput = $('#discord-url')! const urlError = $('#url-error')! @@ -18,6 +19,7 @@ let toastTimer: number | undefined let testHandler: ((e: MouseEvent) => void) | null = null urlInput.addEventListener('input', () => clearError()) +providerSelect.addEventListener('change', updateProviderWarning) generateBtn.addEventListener('click', generate) @@ -35,6 +37,7 @@ async function loadProviders(): Promise { failed.textContent = 'Failed to load providers. Refresh to try again.' providerSelect.replaceChildren(failed) providerSelect.disabled = true + updateProviderWarning() showToast('Something went wrong') } } @@ -55,6 +58,11 @@ function renderProviders(providers: ProviderInfo[]): void { providerSelect.replaceChildren(placeholder, ...options) providerSelect.disabled = false + updateProviderWarning() +} + +function updateProviderWarning(): void { + providerWarning.hidden = providerSelect.value !== 'stripe' } function generate(): void { diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 257d1cc..566c978 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -641,6 +641,25 @@ header nav a:focus-visible { min-height: 1em; } +.provider-warning { + margin: 6px 0 0; + padding: 12px 14px; + border: 1px solid rgba(245, 199, 107, 0.45); + border-radius: var(--radius); + background: rgba(245, 199, 107, 0.08); + color: #f5c76b; + font-size: 0.85rem; + line-height: 1.45; +} + +.provider-warning[hidden] { + display: none; +} + +.provider-warning code { + color: inherit; +} + .field select { appearance: none; background-color: var(--bg); diff --git a/web/test/provider-order.test.mjs b/web/test/provider-order.test.mjs index 6bd256f..9625dbc 100644 --- a/web/test/provider-order.test.mjs +++ b/web/test/provider-order.test.mjs @@ -12,6 +12,7 @@ test('supported providers are rendered alphabetically by display name', () => { }) const html = readFileSync(new URL('../dist/index.html', import.meta.url), 'utf8') + const formScript = readFileSync(new URL('../src/scripts/form.ts', import.meta.url), 'utf8') const providerSection = html.match(/
([\s\S]*?)<\/div>\s*

/) assert.ok(providerSection, 'provider grid should be present in the built page') @@ -31,6 +32,19 @@ test('supported providers are rendered alphabetically by display name', () => { ) assert.ok(names.includes('Square'), 'Square should appear in the supported-provider grid') assert.match(providerSection[1], /title="\/square"/, 'Square should use the /square endpoint') + assert.ok(names.includes('Stripe'), 'Stripe should appear in the supported-provider grid') + assert.match(providerSection[1], /title="\/stripe"/, 'Stripe should use the /stripe endpoint') + assert.ok(existsSync(new URL('../public/providers/stripe.svg', import.meta.url)), 'Stripe should have a logo asset') + assert.match( + html, + /id="provider-warning"[^>]*hidden[^>]*>[\s\S]*?Stripe-Signature[\s\S]*?<\/p>/, + 'the generator should include a hidden Stripe-specific authentication warning', + ) + assert.match( + formScript, + /providerWarning\.hidden = providerSelect\.value !== 'stripe'/, + 'the Stripe warning should become visible when Stripe is selected', + ) assert.ok(names.includes('Azure DevOps'), 'Azure DevOps should appear in the supported-provider grid') assert.match(providerSection[1], /title="\/azure"/, 'Azure DevOps should use the /azure endpoint') assert.doesNotMatch(providerSection[1], /title="\/vsts"/, 'the retired /vsts endpoint should not be shown') From 1d3f9ecac1f2ef830db96d33dca5efbf5ffc506c Mon Sep 17 00:00:00 2001 From: VeldtJumper Date: Mon, 27 Jul 2026 18:37:41 -0500 Subject: [PATCH 2/2] chore: remove Stripe usage disclaimers --- README.md | 3 +-- src/provider/Stripe.ts | 4 ---- web/src/pages/index.astro | 9 --------- web/src/scripts/form.ts | 8 -------- web/src/styles/global.css | 19 ------------------- web/test/provider-order.test.mjs | 11 ----------- 6 files changed, 1 insertion(+), 53 deletions(-) diff --git a/README.md b/README.md index 6d8edd9..88807db 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,7 @@ https://skyhookapi.com/api/webhooks/firstPartOfWebhook/secondPartOfWebhook/provi - [Rollbar](https://docs.rollbar.com/docs/webhooks) - `/rollbar` - [Shopify](https://shopify.dev/docs/api/webhooks/latest) - `/shopify` - [Square](https://developer.squareup.com/docs/webhooks/overview) - `/square` -- [Stripe](https://docs.stripe.com/webhooks) - `/stripe` (informational notifications only; Skyhook's public relay cannot - verify the endpoint-specific `Stripe-Signature`, so don't use it for fulfillment, entitlements, or billing state) +- [Stripe](https://docs.stripe.com/webhooks) - `/stripe` - [Travis](https://docs.travis-ci.com/user/notifications/#Webhooks-Delivery-Format) - `/travis` - [Trello](https://developers.trello.com/apis/webhooks) - `/trello` - [Unity Cloud](https://build-api.cloud.unity3d.com/docs/1.0.0/index.html#operation-webhooks-intro) - `/unity` diff --git a/src/provider/Stripe.ts b/src/provider/Stripe.ts index aa7d687..46f57af 100644 --- a/src/provider/Stripe.ts +++ b/src/provider/Stripe.ts @@ -80,10 +80,6 @@ const CHECKOUT_SESSION_AMOUNT_FIELDS = [ /** * Converts Stripe snapshot Event objects and v2 thin event notifications into bounded Discord embeds. * - * Stripe requires webhook signature verification over the exact raw body using an endpoint-specific secret. - * Skyhook neither possesses that secret nor dispatches the untouched raw body to providers, so this adapter cannot - * verify Stripe-Signature. It is suitable for informational notifications only and treats every value as untrusted. - * * @see https://docs.stripe.com/webhooks * @see https://docs.stripe.com/api/events/object * @see https://docs.stripe.com/event-destinations#events-overview diff --git a/web/src/pages/index.astro b/web/src/pages/index.astro index 3d91a36..93ad293 100644 --- a/web/src/pages/index.astro +++ b/web/src/pages/index.astro @@ -66,10 +66,6 @@ const faqs = [ q: 'Do I need to host a bot or write any code?', a: 'No. skyhook is a hosted service, so there is no Discord bot to run and no code to write. You generate a URL, paste it into your provider, and notifications start arriving in your channel.', }, - { - q: 'Can I use Stripe notifications for billing automation?', - a: "No. skyhook's Stripe integration is for informational Discord notifications only. The public relay doesn't know your endpoint-specific signing secret, so it can't verify Stripe-Signature. Use an authenticated backend for fulfillment, entitlements and billing state.", - }, { q: 'Does skyhook work with GitHub?', a: 'GitHub can already post to Discord natively — just add /github to the end of your Discord webhook URL. skyhook focuses on the many services that do not have built-in Discord support.', @@ -366,11 +362,6 @@ const structuredData = [ -

diff --git a/web/src/scripts/form.ts b/web/src/scripts/form.ts index d66ca22..a4f4c89 100644 --- a/web/src/scripts/form.ts +++ b/web/src/scripts/form.ts @@ -6,7 +6,6 @@ const DISCORD_HOST = 'discord.com' const $ = (sel: string) => document.querySelector(sel) const providerSelect = $('#provider')! -const providerWarning = $('#provider-warning')! const urlField = $('#url-field')! const urlInput = $('#discord-url')! const urlError = $('#url-error')! @@ -19,7 +18,6 @@ let toastTimer: number | undefined let testHandler: ((e: MouseEvent) => void) | null = null urlInput.addEventListener('input', () => clearError()) -providerSelect.addEventListener('change', updateProviderWarning) generateBtn.addEventListener('click', generate) @@ -37,7 +35,6 @@ async function loadProviders(): Promise { failed.textContent = 'Failed to load providers. Refresh to try again.' providerSelect.replaceChildren(failed) providerSelect.disabled = true - updateProviderWarning() showToast('Something went wrong') } } @@ -58,11 +55,6 @@ function renderProviders(providers: ProviderInfo[]): void { providerSelect.replaceChildren(placeholder, ...options) providerSelect.disabled = false - updateProviderWarning() -} - -function updateProviderWarning(): void { - providerWarning.hidden = providerSelect.value !== 'stripe' } function generate(): void { diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 566c978..257d1cc 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -641,25 +641,6 @@ header nav a:focus-visible { min-height: 1em; } -.provider-warning { - margin: 6px 0 0; - padding: 12px 14px; - border: 1px solid rgba(245, 199, 107, 0.45); - border-radius: var(--radius); - background: rgba(245, 199, 107, 0.08); - color: #f5c76b; - font-size: 0.85rem; - line-height: 1.45; -} - -.provider-warning[hidden] { - display: none; -} - -.provider-warning code { - color: inherit; -} - .field select { appearance: none; background-color: var(--bg); diff --git a/web/test/provider-order.test.mjs b/web/test/provider-order.test.mjs index 9625dbc..c028105 100644 --- a/web/test/provider-order.test.mjs +++ b/web/test/provider-order.test.mjs @@ -12,7 +12,6 @@ test('supported providers are rendered alphabetically by display name', () => { }) const html = readFileSync(new URL('../dist/index.html', import.meta.url), 'utf8') - const formScript = readFileSync(new URL('../src/scripts/form.ts', import.meta.url), 'utf8') const providerSection = html.match(/
([\s\S]*?)<\/div>\s*

/) assert.ok(providerSection, 'provider grid should be present in the built page') @@ -35,16 +34,6 @@ test('supported providers are rendered alphabetically by display name', () => { assert.ok(names.includes('Stripe'), 'Stripe should appear in the supported-provider grid') assert.match(providerSection[1], /title="\/stripe"/, 'Stripe should use the /stripe endpoint') assert.ok(existsSync(new URL('../public/providers/stripe.svg', import.meta.url)), 'Stripe should have a logo asset') - assert.match( - html, - /id="provider-warning"[^>]*hidden[^>]*>[\s\S]*?Stripe-Signature[\s\S]*?<\/p>/, - 'the generator should include a hidden Stripe-specific authentication warning', - ) - assert.match( - formScript, - /providerWarning\.hidden = providerSelect\.value !== 'stripe'/, - 'the Stripe warning should become visible when Stripe is selected', - ) assert.ok(names.includes('Azure DevOps'), 'Azure DevOps should appear in the supported-provider grid') assert.match(providerSection[1], /title="\/azure"/, 'Azure DevOps should use the /azure endpoint') assert.doesNotMatch(providerSection[1], /title="\/vsts"/, 'the retired /vsts endpoint should not be shown')