Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/service/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"openapi-fetch": "^0.13",
"pino": "^10",
"pino-pretty": "^13",
"stripe": "^21.0.1",
"zod": "^4.3.6"
},
"devDependencies": {
Expand All @@ -55,7 +56,6 @@
"@types/node": "^24.10.1",
"openapi-typescript": "^7",
"pg": "^8",
"stripe": "^21.0.1",
"vitest": "^3.2.4"
},
"repository": {
Expand Down
30 changes: 30 additions & 0 deletions apps/service/src/__generated__/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

47 changes: 43 additions & 4 deletions apps/service/src/api/app.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Pipeline>('config')
webhookSecret = (pipeline.source as Record<string, unknown>).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)
}
)
Expand Down
12 changes: 7 additions & 5 deletions apps/service/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 13 additions & 16 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading