d3ro-voice/server/supabase/functions/google-play-rtdn/index.ts

141 lines
5.4 KiB
TypeScript

import { createServiceRoleClient } from '../_shared/quota.ts'
import {
createGooglePlayPurchaseApi,
fetchGooglePlaySubscription,
GOOGLE_PLAY_PRODUCT_TIERS,
GooglePlayVerificationError,
verifyGooglePlaySubscriptionPayload,
type NormalizedGooglePlayPurchase,
} from '../_shared/google-play.ts'
import { applyGooglePlayPurchase } from '../_shared/google-play-apply.ts'
import {
createSupabaseGooglePlayPurchaseStore,
type RegisteredGooglePlayPurchase,
} from '../_shared/google-play-purchase-store.ts'
import {
GooglePubSubError,
parseGooglePlayRtdn,
verifyGooglePubSubIdentity,
} from '../_shared/google-pubsub.ts'
function jsonResponse(body: Record<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
Deno.serve(async (req: Request) => {
if (req.method !== 'POST') return jsonResponse({ error: 'method_not_allowed' }, 405)
let eventId: string | null = null
const serviceClient = createServiceRoleClient()
const purchaseStore = createSupabaseGooglePlayPurchaseStore(serviceClient)
try {
await verifyGooglePubSubIdentity(req)
const notification = parseGooglePlayRtdn(await req.json())
if (notification.kind === 'test') {
return jsonResponse({ success: true, test: true })
}
let purchaseRecord: RegisteredGooglePlayPurchase | null = await purchaseStore.findPurchase(
notification.purchaseToken,
)
let preverifiedPurchase: NormalizedGooglePlayPurchase | null = null
// Google Play subscriptions-center re-subscriptions can notify the server
// before the app has registered the new token. Map those only through the
// prior token that our database already owns; never guess a user.
if (!purchaseRecord) {
const verification = await fetchGooglePlaySubscription(notification.purchaseToken)
const recognizedItems = (verification.lineItems ?? []).filter((item) => (
typeof item.productId === 'string'
&& GOOGLE_PLAY_PRODUCT_TIERS[item.productId] !== undefined
))
const expiredToken = verification.outOfAppPurchaseContext?.expiredPurchaseToken
if (
recognizedItems.length !== 1
|| typeof expiredToken !== 'string'
|| expiredToken.length < 8
|| expiredToken.length > 4096
) {
throw new GooglePubSubError('purchase_not_registered', 503)
}
const previous = await purchaseStore.findPurchase(expiredToken)
if (!previous) {
throw new GooglePubSubError('purchase_not_registered', 503)
}
const productId = recognizedItems[0].productId as string
purchaseRecord = { userId: previous.userId, productId }
preverifiedPurchase = await verifyGooglePlaySubscriptionPayload(
previous.userId,
productId,
verification,
(candidate) => Promise.resolve(candidate === expiredToken),
)
}
const { data: insertedEvent, error: insertError } = await serviceClient
.from('store_notification_events')
.insert({
platform: 'google_play',
message_id: notification.messageId,
event_type: `subscription:${notification.notificationType}`,
})
.select('id')
.single()
if (insertError) {
if (insertError.code !== '23505') throw new Error('notification_insert_failed')
const { data: existingEvent, error: existingError } = await serviceClient
.from('store_notification_events')
.select('id, processed_at')
.eq('platform', 'google_play')
.eq('message_id', notification.messageId)
.single()
if (existingError || !existingEvent) throw new Error('notification_lookup_failed')
const existingRecord = existingEvent as { id?: unknown; processed_at?: unknown }
if (existingRecord.processed_at) return jsonResponse({ success: true, duplicate: true })
if (typeof existingRecord.id !== 'string') throw new Error('notification_lookup_failed')
eventId = existingRecord.id
} else {
const insertedRecord = insertedEvent as { id?: unknown }
if (typeof insertedRecord.id !== 'string') throw new Error('notification_insert_failed')
eventId = insertedRecord.id
}
await applyGooglePlayPurchase(
{ playApi: createGooglePlayPurchaseApi(fetch), store: purchaseStore },
{
userId: purchaseRecord.userId,
productId: purchaseRecord.productId,
purchaseToken: notification.purchaseToken,
preverified: preverifiedPurchase,
},
)
if (!eventId) throw new Error('notification_lookup_failed')
const { error: completeError } = await serviceClient
.from('store_notification_events')
.update({ processed_at: new Date().toISOString(), processing_error: null })
.eq('id', eventId)
if (completeError) throw new Error('notification_completion_failed')
return jsonResponse({ success: true })
} catch (error) {
if (eventId) {
const errorCode = error instanceof GooglePlayVerificationError
|| error instanceof GooglePubSubError
? error.code
: 'notification_processing_failed'
await serviceClient
.from('store_notification_events')
.update({ processing_error: errorCode })
.eq('id', eventId)
}
if (error instanceof GooglePubSubError || error instanceof GooglePlayVerificationError) {
return jsonResponse({ error: error.code }, error.status)
}
return jsonResponse({ error: 'notification_processing_failed' }, 500)
}
})