114 lines
3.4 KiB
TypeScript
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')
|
|
}
|
|
}
|