Closer/functions/src/billing/revenueCatWebhook.ts

114 lines
3.4 KiB
TypeScript

import * as crypto from 'crypto'
import * as functions from 'firebase-functions'
import { applyEntitlementEvent, EntitlementEvent } from './entitlementLogic'
/**
* RevenueCat webhook handler.
*
* Path: POST /revenueCatWebhook (registered as an HTTPS function)
* Triggered by RevenueCat server-to-server events.
*
* Authentication:
* - Verifies the Ed25519 signature in the X-Signature request header.
* - REVENUECAT_SIGNING_KEY must be set to the base64-encoded DER/SPKI Ed25519
* public key from your RevenueCat project dashboard.
* - Missing key → 500 (our config error). Invalid/absent signature → 401.
*
* Processing:
* - Parses the RevenueCat event payload.
* - Writes entitlement state to Firestore at users/{userId}/entitlements.
*/
export const revenueCatWebhook = functions.https.onRequest(async (req, res) => {
if (req.method !== 'POST') {
res.status(405).json({ error: 'method_not_allowed' })
return
}
try {
verifySignature(req)
} catch (err) {
if (err instanceof ConfigError) {
console.error('[revenueCatWebhook] configuration error:', err.message)
res.status(500).json({ error: 'internal_error' })
return
}
res.status(401).json({ error: 'unauthorized' })
return
}
const event = req.body?.event as EntitlementEvent | undefined
if (!event || !event.type || !event.app_user_id) {
res.status(400).json({ error: 'malformed_payload' })
return
}
// Acknowledge immediately to avoid RevenueCat retries.
res.status(200).json({ received: true })
try {
await applyEntitlementEvent(event)
} catch (err) {
console.error('[revenueCatWebhook] entitlement sync failed:', err)
}
})
class ConfigError extends Error {}
class AuthError extends Error {}
/**
* Verifies the Ed25519 signature RevenueCat sends in the X-Signature header.
*
* Throws ConfigError when REVENUECAT_SIGNING_KEY is absent or malformed —
* these are deployment problems, not auth failures.
* Throws AuthError for any missing or invalid signature — these are 401s.
*/
function verifySignature(req: functions.https.Request): void {
const signingKey = process.env.REVENUECAT_SIGNING_KEY
if (!signingKey) {
throw new ConfigError('REVENUECAT_SIGNING_KEY environment variable is not set')
}
// Firebase Functions expose the raw request body as req.rawBody (Buffer).
const rawBody = (req as unknown as { rawBody?: Buffer }).rawBody
if (!rawBody || rawBody.length === 0) {
throw new AuthError('request body missing')
}
const sigHeader = req.headers['x-signature']
const signature = Array.isArray(sigHeader) ? sigHeader[0] : sigHeader
if (!signature) {
throw new AuthError('x-signature header missing')
}
let publicKey: crypto.KeyObject
try {
publicKey = crypto.createPublicKey({
key: Buffer.from(signingKey, 'base64'),
format: 'der',
type: 'spki',
})
} catch {
throw new ConfigError(
'REVENUECAT_SIGNING_KEY is malformed — expected base64-encoded DER/SPKI Ed25519 public key'
)
}
let sigBuffer: Buffer
try {
sigBuffer = Buffer.from(signature, 'base64')
} catch {
throw new AuthError('x-signature is not valid base64')
}
let valid: boolean
try {
valid = crypto.verify(null, rawBody, publicKey, sigBuffer)
} catch {
throw new AuthError('signature verification failed')
}
if (!valid) {
throw new AuthError('signature mismatch')
}
}