198 lines
7.7 KiB
TypeScript
198 lines
7.7 KiB
TypeScript
import { createServiceRoleClient } from '../_shared/quota.ts'
|
|
import {
|
|
acknowledgeGooglePlaySubscription,
|
|
fetchGooglePlaySubscription,
|
|
GOOGLE_PLAY_PRODUCT_TIERS,
|
|
GooglePlayVerificationError,
|
|
sha256Hex,
|
|
verifyGooglePlaySubscription,
|
|
verifyGooglePlaySubscriptionPayload,
|
|
type NormalizedGooglePlayPurchase,
|
|
} from '../_shared/google-play.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()
|
|
try {
|
|
await verifyGooglePubSubIdentity(req)
|
|
const notification = parseGooglePlayRtdn(await req.json())
|
|
if (notification.kind === 'test') {
|
|
return jsonResponse({ success: true, test: true })
|
|
}
|
|
|
|
const currentTokenHash = await sha256Hex(notification.purchaseToken)
|
|
const { data: knownPurchase, error: purchaseError } = await serviceClient
|
|
.from('iap_purchases')
|
|
.select('user_id, product_id')
|
|
.eq('platform', 'google_play')
|
|
.eq('token_hash', currentTokenHash)
|
|
.maybeSingle()
|
|
if (purchaseError) throw new Error('purchase_lookup_failed')
|
|
|
|
let purchaseRecord = knownPurchase as { user_id?: unknown; product_id?: unknown } | null
|
|
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 { data: previousPurchase, error: previousError } = await serviceClient
|
|
.from('iap_purchases')
|
|
.select('user_id')
|
|
.eq('platform', 'google_play')
|
|
.eq('token_hash', await sha256Hex(expiredToken))
|
|
.maybeSingle()
|
|
if (previousError) throw new Error('previous_purchase_lookup_failed')
|
|
const previous = previousPurchase as { user_id?: unknown } | null
|
|
if (typeof previous?.user_id !== 'string') {
|
|
throw new GooglePubSubError('purchase_not_registered', 503)
|
|
}
|
|
|
|
purchaseRecord = {
|
|
user_id: previous.user_id,
|
|
product_id: recognizedItems[0].productId,
|
|
}
|
|
preverifiedPurchase = await verifyGooglePlaySubscriptionPayload(
|
|
previous.user_id,
|
|
recognizedItems[0].productId as string,
|
|
verification,
|
|
(candidate) => Promise.resolve(candidate === expiredToken),
|
|
)
|
|
}
|
|
|
|
if (typeof purchaseRecord.user_id !== 'string' || typeof purchaseRecord.product_id !== 'string') {
|
|
throw new Error('purchase_lookup_failed')
|
|
}
|
|
|
|
const ownsExpiredPurchaseToken = async (expiredToken: string): Promise<boolean> => {
|
|
const { data: prior, error: priorError } = await serviceClient
|
|
.from('iap_purchases')
|
|
.select('id')
|
|
.eq('platform', 'google_play')
|
|
.eq('user_id', purchaseRecord.user_id as string)
|
|
.eq('token_hash', await sha256Hex(expiredToken))
|
|
.maybeSingle()
|
|
if (priorError) throw new Error('previous_purchase_lookup_failed')
|
|
return prior !== null
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
let purchase = preverifiedPurchase ?? await verifyGooglePlaySubscription(
|
|
purchaseRecord.user_id,
|
|
purchaseRecord.product_id,
|
|
notification.purchaseToken,
|
|
fetch,
|
|
ownsExpiredPurchaseToken,
|
|
)
|
|
if (purchase.entitled && !purchase.acknowledged) {
|
|
await acknowledgeGooglePlaySubscription(purchase.productId, notification.purchaseToken)
|
|
purchase = {
|
|
...purchase,
|
|
acknowledged: true,
|
|
verification: {
|
|
...purchase.verification,
|
|
acknowledgementState: 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED',
|
|
},
|
|
}
|
|
}
|
|
const { error: applyError } = await serviceClient.rpc('apply_verified_google_play_purchase', {
|
|
p_user_id: purchaseRecord.user_id,
|
|
p_platform: purchase.platform,
|
|
p_product_id: purchase.productId,
|
|
p_store_transaction_id: purchase.storeTransactionId,
|
|
p_token_hash: currentTokenHash,
|
|
p_linked_token_hash: purchase.linkedPurchaseToken
|
|
? await sha256Hex(purchase.linkedPurchaseToken)
|
|
: null,
|
|
p_purchase_token: notification.purchaseToken,
|
|
p_purchase_state: purchase.purchaseState,
|
|
p_purchase_at: purchase.purchaseAt,
|
|
p_expires_at: purchase.expiresAt,
|
|
p_auto_renewing: purchase.autoRenewing,
|
|
p_acknowledged: purchase.acknowledged,
|
|
p_tier: purchase.tier,
|
|
p_entitled: purchase.entitled,
|
|
p_verification: purchase.verification,
|
|
})
|
|
if (applyError) throw new Error('purchase_persistence_failed')
|
|
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)
|
|
}
|
|
})
|