d25d482dc2
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
279 lines
11 KiB
TypeScript
279 lines
11 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { safeCompare } from '@sim/security/compare'
|
|
import { generateId } from '@sim/utils/id'
|
|
import { NextResponse } from 'next/server'
|
|
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
|
|
import type {
|
|
AuthContext,
|
|
DeleteSubscriptionContext,
|
|
EventMatchContext,
|
|
FormatInputContext,
|
|
FormatInputResult,
|
|
SubscriptionContext,
|
|
SubscriptionResult,
|
|
WebhookProviderHandler,
|
|
} from '@/lib/webhooks/providers/types'
|
|
|
|
const logger = createLogger('WebhookProvider:RevenueCat')
|
|
|
|
/** Base URL for the RevenueCat REST API v2. */
|
|
const REVENUECAT_API_BASE = 'https://api.revenuecat.com/v2'
|
|
|
|
/**
|
|
* RevenueCat webhook handler.
|
|
*
|
|
* RevenueCat does not sign payloads. Instead, an Authorization header value is sent
|
|
* verbatim on every request. Sim generates this secret and sets it when it creates the
|
|
* webhook integration (see `createSubscription`), so it is always present once the
|
|
* trigger is deployed. We verify the incoming `Authorization` header against it using a
|
|
* timing-safe comparison and fail closed if the secret is missing from config.
|
|
*
|
|
* @see https://www.revenuecat.com/docs/integrations/webhooks
|
|
*/
|
|
export const revenueCatHandler: WebhookProviderHandler = {
|
|
verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null {
|
|
const secret = providerConfig.authHeaderSecret as string | undefined
|
|
|
|
if (!secret) {
|
|
logger.warn(
|
|
`[${requestId}] RevenueCat webhook missing Authorization secret in provider configuration`
|
|
)
|
|
return new NextResponse('Unauthorized - RevenueCat Authorization secret is required', {
|
|
status: 401,
|
|
})
|
|
}
|
|
|
|
const authHeader = request.headers.get('authorization')
|
|
if (!authHeader) {
|
|
logger.warn(`[${requestId}] RevenueCat webhook missing Authorization header`)
|
|
return new NextResponse('Unauthorized - Missing RevenueCat Authorization header', {
|
|
status: 401,
|
|
})
|
|
}
|
|
|
|
if (!safeCompare(authHeader, secret)) {
|
|
logger.warn(`[${requestId}] RevenueCat Authorization header verification failed`)
|
|
return new NextResponse('Unauthorized - Invalid RevenueCat Authorization header', {
|
|
status: 401,
|
|
})
|
|
}
|
|
|
|
return null
|
|
},
|
|
|
|
/**
|
|
* Create the webhook integration in RevenueCat via the REST API v2.
|
|
*
|
|
* Sim generates the Authorization header secret, registers the integration
|
|
* with that secret, and stores both the returned integration id (`externalId`)
|
|
* and the secret (`authHeaderSecret`) so {@link verifyAuth} can authenticate
|
|
* incoming deliveries.
|
|
*
|
|
* @see https://www.revenuecat.com/docs/api-v2 (Integration > Create a webhook integration)
|
|
*/
|
|
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
|
|
const config = getProviderConfig(ctx.webhook)
|
|
const { apiKey, projectId, triggerId, environment } = config as {
|
|
apiKey?: string
|
|
projectId?: string
|
|
triggerId?: string
|
|
environment?: string
|
|
}
|
|
|
|
if (!apiKey) {
|
|
throw new Error(
|
|
'RevenueCat Secret API key is required to create the webhook. Provide a v2 Secret API key with the project_configuration:integrations:read_write permission.'
|
|
)
|
|
}
|
|
|
|
if (!projectId) {
|
|
throw new Error('RevenueCat Project ID is required to create the webhook.')
|
|
}
|
|
|
|
const { REVENUECAT_TRIGGER_TO_API_EVENT_TYPE } = await import('@/triggers/revenuecat/utils')
|
|
const eventType = triggerId ? REVENUECAT_TRIGGER_TO_API_EVENT_TYPE[triggerId] : undefined
|
|
|
|
const authHeaderSecret = generateId()
|
|
const requestBody: Record<string, unknown> = {
|
|
name: `Sim webhook (${triggerId ?? 'revenuecat'})`,
|
|
url: getNotificationUrl(ctx.webhook),
|
|
authorization_header: authHeaderSecret,
|
|
}
|
|
if (eventType) {
|
|
requestBody.event_types = [eventType]
|
|
}
|
|
if (environment === 'production' || environment === 'sandbox') {
|
|
requestBody.environment = environment
|
|
}
|
|
|
|
const response = await fetch(
|
|
`${REVENUECAT_API_BASE}/projects/${encodeURIComponent(projectId)}/integrations/webhooks`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(requestBody),
|
|
}
|
|
)
|
|
|
|
if (!response.ok) {
|
|
const errorBody = (await response.json().catch(() => ({}))) as Record<string, unknown>
|
|
logger.error(
|
|
`[${ctx.requestId}] Failed to create RevenueCat webhook for webhook ${ctx.webhook.id}. Status: ${response.status}`,
|
|
{ response: errorBody }
|
|
)
|
|
|
|
let message = 'Failed to create webhook integration in RevenueCat'
|
|
if (response.status === 401) {
|
|
message = 'RevenueCat authentication failed. Verify your v2 Secret API key is correct.'
|
|
} else if (response.status === 403) {
|
|
message =
|
|
'RevenueCat access denied. Ensure the API key has the project_configuration:integrations:read_write permission.'
|
|
} else if (response.status === 404) {
|
|
message = 'RevenueCat project not found. Verify the Project ID is correct.'
|
|
} else if (typeof errorBody.message === 'string' && errorBody.message.length > 0) {
|
|
message = `RevenueCat error: ${errorBody.message}`
|
|
}
|
|
throw new Error(message)
|
|
}
|
|
|
|
const responseBody = (await response.json()) as Record<string, unknown>
|
|
const integrationId = responseBody.id as string | undefined
|
|
|
|
if (!integrationId) {
|
|
logger.error(
|
|
`[${ctx.requestId}] RevenueCat webhook created but no integration id was returned for webhook ${ctx.webhook.id}`,
|
|
{ response: responseBody }
|
|
)
|
|
throw new Error('RevenueCat webhook creation succeeded but no integration id was returned')
|
|
}
|
|
|
|
logger.info(
|
|
`[${ctx.requestId}] Created RevenueCat webhook integration ${integrationId} for webhook ${ctx.webhook.id}`
|
|
)
|
|
|
|
return { providerConfigUpdates: { externalId: integrationId, authHeaderSecret } }
|
|
},
|
|
|
|
/**
|
|
* Delete the webhook integration in RevenueCat during undeploy.
|
|
*
|
|
* Cleanup is best-effort: a missing integration (404) or a transient failure
|
|
* is logged non-fatally unless strict outbox cleanup is requested.
|
|
*
|
|
* @see https://www.revenuecat.com/docs/api-v2 (Integration > Delete a webhook integration)
|
|
*/
|
|
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
|
|
try {
|
|
const config = getProviderConfig(ctx.webhook)
|
|
const { apiKey, projectId, externalId } = config as {
|
|
apiKey?: string
|
|
projectId?: string
|
|
externalId?: string
|
|
}
|
|
|
|
if (!apiKey || !projectId || !externalId) {
|
|
logger.warn(
|
|
`[${ctx.requestId}] Missing apiKey/projectId/externalId for RevenueCat webhook deletion ${ctx.webhook.id}, skipping cleanup`
|
|
)
|
|
if (ctx.strict) throw new Error('Missing RevenueCat credentials for webhook deletion')
|
|
return
|
|
}
|
|
|
|
const response = await fetch(
|
|
`${REVENUECAT_API_BASE}/projects/${encodeURIComponent(projectId)}/integrations/webhooks/${encodeURIComponent(externalId)}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: { Authorization: `Bearer ${apiKey}` },
|
|
}
|
|
)
|
|
|
|
if (!response.ok && response.status !== 404) {
|
|
const errorBody = await response.json().catch(() => ({}))
|
|
logger.warn(
|
|
`[${ctx.requestId}] Failed to delete RevenueCat webhook (non-fatal): ${response.status}`,
|
|
{ response: errorBody }
|
|
)
|
|
if (ctx.strict) {
|
|
throw new Error(`Failed to delete RevenueCat webhook: ${response.status}`)
|
|
}
|
|
} else {
|
|
logger.info(`[${ctx.requestId}] Deleted RevenueCat webhook integration ${externalId}`)
|
|
}
|
|
} catch (error) {
|
|
logger.warn(`[${ctx.requestId}] Error deleting RevenueCat webhook (non-fatal)`, error)
|
|
if (ctx.strict) throw error
|
|
}
|
|
},
|
|
|
|
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
|
|
const triggerId = providerConfig.triggerId as string | undefined
|
|
if (!triggerId) {
|
|
return true
|
|
}
|
|
|
|
const { isRevenueCatEventMatch } = await import('@/triggers/revenuecat/utils')
|
|
if (!isRevenueCatEventMatch(triggerId, (body as Record<string, unknown>) || {})) {
|
|
const event = (body as Record<string, unknown>)?.event as Record<string, unknown> | undefined
|
|
logger.debug(
|
|
`[${requestId}] RevenueCat event type '${event?.type as string | undefined}' does not match trigger ${triggerId}, skipping`
|
|
)
|
|
return false
|
|
}
|
|
|
|
return true
|
|
},
|
|
|
|
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
|
const payload = (body as Record<string, unknown>) || {}
|
|
const event = (payload.event as Record<string, unknown>) || {}
|
|
|
|
return {
|
|
input: {
|
|
type: event.type ?? null,
|
|
id: event.id ?? null,
|
|
app_id: event.app_id ?? null,
|
|
event_timestamp_ms: event.event_timestamp_ms ?? null,
|
|
app_user_id: event.app_user_id ?? null,
|
|
original_app_user_id: event.original_app_user_id ?? null,
|
|
aliases: event.aliases ?? null,
|
|
product_id: event.product_id ?? null,
|
|
new_product_id: event.new_product_id ?? null,
|
|
period_type: event.period_type ?? null,
|
|
purchased_at_ms: event.purchased_at_ms ?? null,
|
|
expiration_at_ms: event.expiration_at_ms ?? null,
|
|
environment: event.environment ?? null,
|
|
entitlement_id: event.entitlement_id ?? null,
|
|
entitlement_ids: event.entitlement_ids ?? null,
|
|
presented_offering_id: event.presented_offering_id ?? null,
|
|
transaction_id: event.transaction_id ?? null,
|
|
original_transaction_id: event.original_transaction_id ?? null,
|
|
is_family_share: event.is_family_share ?? null,
|
|
country_code: event.country_code ?? null,
|
|
currency: event.currency ?? null,
|
|
price: event.price ?? null,
|
|
price_in_purchased_currency: event.price_in_purchased_currency ?? null,
|
|
store: event.store ?? null,
|
|
takehome_percentage: event.takehome_percentage ?? null,
|
|
tax_percentage: event.tax_percentage ?? null,
|
|
commission_percentage: event.commission_percentage ?? null,
|
|
offer_code: event.offer_code ?? null,
|
|
subscriber_attributes: event.subscriber_attributes ?? null,
|
|
experiments: event.experiments ?? null,
|
|
cancel_reason: event.cancel_reason ?? null,
|
|
expiration_reason: event.expiration_reason ?? null,
|
|
api_version: payload.api_version ?? null,
|
|
event,
|
|
},
|
|
}
|
|
},
|
|
|
|
extractIdempotencyId(body: unknown): string | null {
|
|
const event = (body as Record<string, unknown>)?.event as Record<string, unknown> | undefined
|
|
const id = event?.id
|
|
return typeof id === 'string' && id.length > 0 ? id : null
|
|
},
|
|
}
|