diff --git a/apps/service/package.json b/apps/service/package.json index f013d165..046ca125 100644 --- a/apps/service/package.json +++ b/apps/service/package.json @@ -47,6 +47,7 @@ "openapi-fetch": "^0.13", "pino": "^10", "pino-pretty": "^13", + "stripe": "^21.0.1", "zod": "^4.3.6" }, "devDependencies": { @@ -55,7 +56,6 @@ "@types/node": "^24.10.1", "openapi-typescript": "^7", "pg": "^8", - "stripe": "^21.0.1", "vitest": "^3.2.4" }, "repository": { diff --git a/apps/service/src/__generated__/openapi.json b/apps/service/src/__generated__/openapi.json index b6ce1865..17edc1b3 100644 --- a/apps/service/src/__generated__/openapi.json +++ b/apps/service/src/__generated__/openapi.json @@ -2375,6 +2375,36 @@ } } } + }, + "400": { + "description": "Missing or invalid signature, or pipeline not configured for webhooks", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": {} + }, + "required": ["error"], + "additionalProperties": false + } + } + } + }, + "404": { + "description": "Pipeline not found", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": {} + }, + "required": ["error"], + "additionalProperties": false + } + } + } } } } diff --git a/apps/service/src/api/app.ts b/apps/service/src/api/app.ts index 7ce5bdfd..b364eb6d 100644 --- a/apps/service/src/api/app.ts +++ b/apps/service/src/api/app.ts @@ -1,6 +1,7 @@ import { OpenAPIHono, createRoute } from '@stripe/sync-hono-zod-openapi' import { z } from 'zod' import { apiReference } from '@scalar/hono-api-reference' +import Stripe from 'stripe' import type { WorkflowClient } from '@temporalio/client' import type { ConnectorResolver } from '@stripe/sync-engine' import { endpointTable, addDiscriminators } from '@stripe/sync-engine/api/openapi-utils' @@ -38,6 +39,9 @@ export interface AppOptions { resolver: ConnectorResolver } +// Shared Stripe instance used only for webhook signature verification (no API calls made). +const stripe = new Stripe('placeholder') + export function createApp(options: AppOptions) { const { client: temporal, taskQueue } = options.temporal const { @@ -415,16 +419,51 @@ export function createApp(options: AppOptions) { content: { 'text/plain': { schema: z.literal('ok') } }, description: 'Event accepted', }, + 400: { + content: { 'application/json': { schema: ErrorSchema } }, + description: 'Missing or invalid signature, or pipeline not configured for webhooks', + }, + 404: { + content: { 'application/json': { schema: ErrorSchema } }, + description: 'Pipeline not found', + }, }, }), async (c) => { const { pipeline_id } = c.req.valid('param') const body = await c.req.text() + + // Look up the pipeline config to get the webhook_secret + let webhookSecret: string | undefined + try { + const handle = temporal.getHandle(pipeline_id) + const pipeline = await handle.query('config') + webhookSecret = (pipeline.source as Record).webhook_secret as + | string + | undefined + } catch { + return c.json({ error: `Pipeline ${pipeline_id} not found` }, 404) + } + if (!webhookSecret) { + return c.json({ error: 'Pipeline has no webhook_secret configured' }, 400) + } + + // Verify Stripe signature + const sig = c.req.header('stripe-signature') ?? '' + try { + stripe.webhooks.constructEvent(body, sig, webhookSecret) + } catch (err) { + return c.json( + { + error: `Webhook signature verification failed: ${err instanceof Error ? err.message : String(err)}`, + }, + 400 + ) + } + + // Enqueue the verified event const headers = Object.fromEntries(c.req.raw.headers.entries()) - temporal - .getHandle(pipeline_id) - .signal('stripe_event', { body, headers }) - .catch(() => {}) + await temporal.getHandle(pipeline_id).signal('stripe_event', { body, headers }) return c.text('ok', 200) } ) diff --git a/apps/service/src/cli.ts b/apps/service/src/cli.ts index 790ad10a..7675bd45 100644 --- a/apps/service/src/cli.ts +++ b/apps/service/src/cli.ts @@ -99,12 +99,14 @@ const workerCmd = defineCommand({ const engineUrl = args['engine-url'] || 'http://localhost:4010' const temporalAddress = args['temporal-address'] - // import.meta.url is the URL of cli.ts/cli.js, NOT the bin entry point: - // tsx: file:///.../apps/service/src/cli.ts → ./temporal/workflows.ts - // compiled: file:///.../apps/service/dist/cli.js → ./temporal/workflows.js + // tsx strips rootDir:"src" from import.meta.url, so paths differ by context: + // tsx: file:///.../apps/service/bin/sync-service.ts → ../src/temporal/workflows.ts + // compiled: file:///.../apps/service/dist/bin/sync-service.js → ../temporal/workflows.js const { fileURLToPath } = await import('node:url') - const ext = import.meta.url.endsWith('.ts') ? '.ts' : '.js' - const workflowsPath = fileURLToPath(new URL(`./temporal/workflows${ext}`, import.meta.url)) + const isTsx = import.meta.url.endsWith('.ts') + const workflowsPath = fileURLToPath( + new URL(isTsx ? '../src/temporal/workflows.ts' : '../temporal/workflows.js', import.meta.url) + ) const worker = await createWorker({ temporalAddress, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c8fcd8f7..50a2ec91 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,7 +115,7 @@ importers: version: link:../engine '@stripe/sync-service': specifier: workspace:* - version: file:apps/service(@aws-sdk/client-sts@3.1013.0)(@aws-sdk/rds-signer@3.1013.0)(tslib@2.8.1) + version: file:apps/service(@aws-sdk/client-sts@3.1013.0)(@aws-sdk/rds-signer@3.1013.0)(@types/node@25.5.0)(tslib@2.8.1) '@stripe/sync-source-stripe': specifier: workspace:* version: link:../../packages/source-stripe @@ -291,6 +291,9 @@ importers: pino-pretty: specifier: ^13 version: 13.1.3 + stripe: + specifier: ^21.0.1 + version: 21.0.1(@types/node@24.10.1) zod: specifier: ^4.3.6 version: 4.3.6 @@ -310,9 +313,6 @@ importers: pg: specifier: ^8 version: 8.16.3 - stripe: - specifier: ^21.0.1 - version: 21.0.1(@types/node@24.10.1) vitest: specifier: ^3.2.4 version: 3.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1) @@ -432,7 +432,7 @@ importers: version: link:../packages/protocol '@stripe/sync-service': specifier: workspace:* - version: link:../apps/service + version: file:apps/service(@aws-sdk/client-sts@3.1013.0)(@aws-sdk/rds-signer@3.1013.0)(@types/node@25.5.0)(tslib@2.8.1) '@stripe/sync-source-stripe': specifier: workspace:* version: link:../packages/source-stripe @@ -7239,7 +7239,7 @@ snapshots: citty: 0.1.6 zod: 4.3.6 - '@stripe/sync-service@file:apps/service(@aws-sdk/client-sts@3.1013.0)(@aws-sdk/rds-signer@3.1013.0)(tslib@2.8.1)': + '@stripe/sync-service@file:apps/service(@aws-sdk/client-sts@3.1013.0)(@aws-sdk/rds-signer@3.1013.0)(@types/node@25.5.0)(tslib@2.8.1)': dependencies: '@hono/node-server': 1.19.11(hono@4.12.8) '@scalar/hono-api-reference': 0.6.0(hono@4.12.8) @@ -7261,13 +7261,14 @@ snapshots: openapi-fetch: 0.13.8 pino: 10.1.0 pino-pretty: 13.1.3 + stripe: 21.0.1(@types/node@25.5.0) zod: 4.3.6 transitivePeerDependencies: - '@aws-sdk/client-sts' - '@aws-sdk/rds-signer' - '@swc/helpers' + - '@types/node' - bufferutil - - debug - encoding - esbuild - pg-native @@ -7741,14 +7742,6 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 3.2.4 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1) - '@vitest/mocker@3.2.4(vite@7.2.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 @@ -9535,6 +9528,10 @@ snapshots: optionalDependencies: '@types/node': 24.10.1 + stripe@21.0.1(@types/node@25.5.0): + optionalDependencies: + '@types/node': 25.5.0 + strnum@2.2.1: {} style-mod@4.1.3: {} @@ -9799,7 +9796,7 @@ snapshots: dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.2.2(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.2.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4