feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
418
server/supabase/functions/_shared/google-play.ts
Normal file
418
server/supabase/functions/_shared/google-play.ts
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
export type GooglePlayTier = 'pro' | 'pro_plus'
|
||||
export type StorePurchaseState =
|
||||
| 'pending'
|
||||
| 'purchased'
|
||||
| 'cancelled'
|
||||
| 'expired'
|
||||
| 'refunded'
|
||||
| 'on_hold'
|
||||
| 'paused'
|
||||
|
||||
export const GOOGLE_PLAY_PACKAGE_NAME = 'com.d3ro.voice'
|
||||
|
||||
export const GOOGLE_PLAY_PRODUCT_TIERS: Readonly<Record<string, GooglePlayTier>> = Object.freeze({
|
||||
d3ro_voice_pro_monthly: 'pro',
|
||||
d3ro_voice_pro_plus_monthly: 'pro_plus',
|
||||
})
|
||||
|
||||
interface GoogleServiceAccount {
|
||||
client_email: string
|
||||
private_key: string
|
||||
token_uri?: string
|
||||
}
|
||||
|
||||
interface GooglePlayLineItem {
|
||||
productId?: string
|
||||
expiryTime?: string
|
||||
latestSuccessfulOrderId?: string
|
||||
autoRenewingPlan?: {
|
||||
autoRenewEnabled?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface GooglePlaySubscriptionV2 {
|
||||
kind?: string
|
||||
startTime?: string
|
||||
subscriptionState?: string
|
||||
acknowledgementState?: string
|
||||
linkedPurchaseToken?: string
|
||||
externalAccountIdentifiers?: {
|
||||
obfuscatedExternalAccountId?: string
|
||||
}
|
||||
outOfAppPurchaseContext?: {
|
||||
expiredExternalAccountIdentifiers?: {
|
||||
obfuscatedExternalAccountId?: string
|
||||
}
|
||||
expiredPurchaseToken?: string
|
||||
}
|
||||
lineItems?: GooglePlayLineItem[]
|
||||
canceledStateContext?: Record<string, unknown>
|
||||
testPurchase?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface NormalizedGooglePlayPurchase {
|
||||
platform: 'google_play'
|
||||
productId: string
|
||||
tier: GooglePlayTier
|
||||
storeTransactionId: string | null
|
||||
purchaseState: StorePurchaseState
|
||||
purchaseAt: string | null
|
||||
expiresAt: string | null
|
||||
autoRenewing: boolean
|
||||
acknowledged: boolean
|
||||
entitled: boolean
|
||||
verification: GooglePlaySubscriptionV2
|
||||
linkedPurchaseToken: string | null
|
||||
}
|
||||
|
||||
export class GooglePlayVerificationError extends Error {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
public readonly status: number,
|
||||
) {
|
||||
super(code)
|
||||
this.name = 'GooglePlayVerificationError'
|
||||
}
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
let cachedAccessToken: { token: string; expiresAtMs: number } | null = null
|
||||
const GOOGLE_REQUEST_TIMEOUT_MS = 15_000
|
||||
|
||||
async function googleFetch(
|
||||
fetchImpl: typeof fetch,
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await fetchImpl(input, {
|
||||
...init,
|
||||
signal: AbortSignal.timeout(GOOGLE_REQUEST_TIMEOUT_MS),
|
||||
})
|
||||
} catch {
|
||||
throw new GooglePlayVerificationError('google_play_timeout', 504)
|
||||
}
|
||||
}
|
||||
|
||||
function encodeBase64Url(value: Uint8Array | string): string {
|
||||
const bytes = typeof value === 'string' ? encoder.encode(value) : value
|
||||
let binary = ''
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte)
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function decodePem(pem: string): Uint8Array {
|
||||
const body = pem
|
||||
.replace(/-----BEGIN PRIVATE KEY-----/g, '')
|
||||
.replace(/-----END PRIVATE KEY-----/g, '')
|
||||
.replace(/\s/g, '')
|
||||
|
||||
if (!body) throw new GooglePlayVerificationError('google_play_credentials_invalid', 503)
|
||||
|
||||
try {
|
||||
const binary = atob(body)
|
||||
return Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
} catch {
|
||||
throw new GooglePlayVerificationError('google_play_credentials_invalid', 503)
|
||||
}
|
||||
}
|
||||
|
||||
function getServiceAccount(): GoogleServiceAccount {
|
||||
const json = Deno.env.get('GOOGLE_PLAY_SERVICE_ACCOUNT_JSON') ?? ''
|
||||
if (!json) throw new GooglePlayVerificationError('google_play_not_configured', 503)
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(json) as Partial<GoogleServiceAccount>
|
||||
if (!parsed.client_email || !parsed.private_key) {
|
||||
throw new Error('missing service-account fields')
|
||||
}
|
||||
return {
|
||||
client_email: parsed.client_email,
|
||||
private_key: parsed.private_key,
|
||||
token_uri: parsed.token_uri,
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof GooglePlayVerificationError) throw error
|
||||
throw new GooglePlayVerificationError('google_play_credentials_invalid', 503)
|
||||
}
|
||||
}
|
||||
|
||||
async function createServiceAccountAssertion(
|
||||
account: GoogleServiceAccount,
|
||||
nowSeconds: number,
|
||||
): Promise<string> {
|
||||
const tokenUri = account.token_uri ?? 'https://oauth2.googleapis.com/token'
|
||||
const header = encodeBase64Url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
|
||||
const claims = encodeBase64Url(JSON.stringify({
|
||||
iss: account.client_email,
|
||||
scope: 'https://www.googleapis.com/auth/androidpublisher',
|
||||
aud: tokenUri,
|
||||
iat: nowSeconds,
|
||||
exp: nowSeconds + 3600,
|
||||
}))
|
||||
const signingInput = `${header}.${claims}`
|
||||
|
||||
let key: CryptoKey
|
||||
try {
|
||||
const decodedKey = decodePem(account.private_key)
|
||||
const keyBuffer = new ArrayBuffer(decodedKey.byteLength)
|
||||
new Uint8Array(keyBuffer).set(decodedKey)
|
||||
key = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
keyBuffer,
|
||||
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign'],
|
||||
)
|
||||
} catch {
|
||||
throw new GooglePlayVerificationError('google_play_credentials_invalid', 503)
|
||||
}
|
||||
|
||||
const signature = await crypto.subtle.sign(
|
||||
'RSASSA-PKCS1-v1_5',
|
||||
key,
|
||||
encoder.encode(signingInput),
|
||||
)
|
||||
return `${signingInput}.${encodeBase64Url(new Uint8Array(signature))}`
|
||||
}
|
||||
|
||||
async function getGoogleAccessToken(fetchImpl: typeof fetch): Promise<string> {
|
||||
const nowMs = Date.now()
|
||||
if (cachedAccessToken && cachedAccessToken.expiresAtMs > nowMs + 60_000) {
|
||||
return cachedAccessToken.token
|
||||
}
|
||||
|
||||
const account = getServiceAccount()
|
||||
const tokenUri = account.token_uri ?? 'https://oauth2.googleapis.com/token'
|
||||
const assertion = await createServiceAccountAssertion(account, Math.floor(nowMs / 1000))
|
||||
const response = await googleFetch(fetchImpl, tokenUri, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new GooglePlayVerificationError('google_play_auth_failed', 502)
|
||||
}
|
||||
|
||||
const body = await response.json() as { access_token?: unknown; expires_in?: unknown }
|
||||
if (typeof body.access_token !== 'string' || !body.access_token) {
|
||||
throw new GooglePlayVerificationError('google_play_auth_failed', 502)
|
||||
}
|
||||
const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600
|
||||
cachedAccessToken = {
|
||||
token: body.access_token,
|
||||
expiresAtMs: nowMs + Math.max(60, expiresIn) * 1000,
|
||||
}
|
||||
return body.access_token
|
||||
}
|
||||
|
||||
export async function sha256Hex(value: string): Promise<string> {
|
||||
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(value))
|
||||
return Array.from(new Uint8Array(digest))
|
||||
.map((byte) => byte.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export async function googlePlayAccountId(userId: string): Promise<string> {
|
||||
return await sha256Hex(`d3ro-google-play:${userId}`)
|
||||
}
|
||||
|
||||
function parseTimestamp(value: string | undefined): number | null {
|
||||
if (!value) return null
|
||||
const timestamp = Date.parse(value)
|
||||
return Number.isFinite(timestamp) ? timestamp : null
|
||||
}
|
||||
|
||||
export function normalizeGooglePlaySubscription(
|
||||
verification: GooglePlaySubscriptionV2,
|
||||
expectedProductId: string,
|
||||
nowMs = Date.now(),
|
||||
): NormalizedGooglePlayPurchase {
|
||||
const tier = GOOGLE_PLAY_PRODUCT_TIERS[expectedProductId]
|
||||
if (!tier) throw new GooglePlayVerificationError('unknown_product', 400)
|
||||
|
||||
const lineItem = verification.lineItems?.find((item) => item.productId === expectedProductId)
|
||||
if (!lineItem) throw new GooglePlayVerificationError('product_mismatch', 409)
|
||||
|
||||
const expiresAtMs = parseTimestamp(lineItem.expiryTime)
|
||||
const hasFutureExpiry = expiresAtMs !== null && expiresAtMs > nowMs
|
||||
let purchaseState: StorePurchaseState
|
||||
let entitled = false
|
||||
|
||||
switch (verification.subscriptionState) {
|
||||
case 'SUBSCRIPTION_STATE_PENDING':
|
||||
purchaseState = 'pending'
|
||||
break
|
||||
case 'SUBSCRIPTION_STATE_ACTIVE':
|
||||
case 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD':
|
||||
purchaseState = 'purchased'
|
||||
entitled = hasFutureExpiry
|
||||
break
|
||||
case 'SUBSCRIPTION_STATE_CANCELED':
|
||||
purchaseState = 'cancelled'
|
||||
entitled = hasFutureExpiry
|
||||
break
|
||||
case 'SUBSCRIPTION_STATE_PAUSED':
|
||||
purchaseState = 'paused'
|
||||
break
|
||||
case 'SUBSCRIPTION_STATE_ON_HOLD':
|
||||
purchaseState = 'on_hold'
|
||||
break
|
||||
case 'SUBSCRIPTION_STATE_EXPIRED':
|
||||
purchaseState = 'expired'
|
||||
break
|
||||
case 'SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED':
|
||||
purchaseState = 'cancelled'
|
||||
break
|
||||
default:
|
||||
throw new GooglePlayVerificationError('unknown_subscription_state', 502)
|
||||
}
|
||||
|
||||
return {
|
||||
platform: 'google_play',
|
||||
productId: expectedProductId,
|
||||
tier,
|
||||
storeTransactionId: typeof lineItem.latestSuccessfulOrderId === 'string'
|
||||
&& lineItem.latestSuccessfulOrderId.length > 0
|
||||
? lineItem.latestSuccessfulOrderId
|
||||
: null,
|
||||
purchaseState,
|
||||
purchaseAt: parseTimestamp(verification.startTime) === null ? null : verification.startTime ?? null,
|
||||
expiresAt: expiresAtMs === null ? null : lineItem.expiryTime ?? null,
|
||||
autoRenewing: lineItem.autoRenewingPlan?.autoRenewEnabled === true,
|
||||
acknowledged: verification.acknowledgementState === 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED',
|
||||
entitled,
|
||||
verification,
|
||||
linkedPurchaseToken: typeof verification.linkedPurchaseToken === 'string'
|
||||
&& verification.linkedPurchaseToken.length >= 8
|
||||
? verification.linkedPurchaseToken
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
export async function acknowledgeGooglePlaySubscription(
|
||||
productId: string,
|
||||
purchaseToken: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<void> {
|
||||
if (!GOOGLE_PLAY_PRODUCT_TIERS[productId]) {
|
||||
throw new GooglePlayVerificationError('unknown_product', 400)
|
||||
}
|
||||
if (purchaseToken.length < 8 || purchaseToken.length > 4096) {
|
||||
throw new GooglePlayVerificationError('invalid_purchase_token', 400)
|
||||
}
|
||||
|
||||
const configuredPackage = Deno.env.get('GOOGLE_PLAY_PACKAGE_NAME') ?? GOOGLE_PLAY_PACKAGE_NAME
|
||||
if (configuredPackage !== GOOGLE_PLAY_PACKAGE_NAME) {
|
||||
throw new GooglePlayVerificationError('google_play_package_mismatch', 503)
|
||||
}
|
||||
const accessToken = await getGoogleAccessToken(fetchImpl)
|
||||
const endpoint = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/'
|
||||
+ `${encodeURIComponent(configuredPackage)}/purchases/subscriptions/`
|
||||
+ `${encodeURIComponent(productId)}/tokens/${encodeURIComponent(purchaseToken)}:acknowledge`
|
||||
const response = await googleFetch(fetchImpl, endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: '{}',
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new GooglePlayVerificationError('google_play_acknowledgement_failed', 502)
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyGooglePlaySubscriptionPayload(
|
||||
userId: string,
|
||||
productId: string,
|
||||
verification: GooglePlaySubscriptionV2,
|
||||
ownsExpiredPurchaseToken?: (purchaseToken: string) => Promise<boolean>,
|
||||
): Promise<NormalizedGooglePlayPurchase> {
|
||||
const expectedAccountId = await googlePlayAccountId(userId)
|
||||
const currentAccountId = verification.externalAccountIdentifiers?.obfuscatedExternalAccountId
|
||||
if (typeof currentAccountId === 'string' && currentAccountId.length > 0) {
|
||||
if (currentAccountId !== expectedAccountId) {
|
||||
throw new GooglePlayVerificationError('purchase_account_mismatch', 409)
|
||||
}
|
||||
return normalizeGooglePlaySubscription(verification, productId)
|
||||
}
|
||||
|
||||
const outOfAppContext = verification.outOfAppPurchaseContext
|
||||
const expiredAccountId = outOfAppContext?.expiredExternalAccountIdentifiers
|
||||
?.obfuscatedExternalAccountId
|
||||
if (typeof expiredAccountId === 'string' && expiredAccountId.length > 0) {
|
||||
if (expiredAccountId !== expectedAccountId) {
|
||||
throw new GooglePlayVerificationError('purchase_account_mismatch', 409)
|
||||
}
|
||||
return normalizeGooglePlaySubscription(verification, productId)
|
||||
}
|
||||
|
||||
const expiredToken = outOfAppContext?.expiredPurchaseToken
|
||||
if (
|
||||
typeof expiredToken === 'string'
|
||||
&& expiredToken.length >= 8
|
||||
&& expiredToken.length <= 4096
|
||||
&& ownsExpiredPurchaseToken
|
||||
&& await ownsExpiredPurchaseToken(expiredToken)
|
||||
) {
|
||||
return normalizeGooglePlaySubscription(verification, productId)
|
||||
}
|
||||
|
||||
throw new GooglePlayVerificationError('purchase_account_mismatch', 409)
|
||||
}
|
||||
|
||||
export async function fetchGooglePlaySubscription(
|
||||
purchaseToken: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<GooglePlaySubscriptionV2> {
|
||||
if (purchaseToken.length < 8 || purchaseToken.length > 4096) {
|
||||
throw new GooglePlayVerificationError('invalid_purchase_token', 400)
|
||||
}
|
||||
|
||||
const configuredPackage = Deno.env.get('GOOGLE_PLAY_PACKAGE_NAME') ?? GOOGLE_PLAY_PACKAGE_NAME
|
||||
if (configuredPackage !== GOOGLE_PLAY_PACKAGE_NAME) {
|
||||
throw new GooglePlayVerificationError('google_play_package_mismatch', 503)
|
||||
}
|
||||
|
||||
const accessToken = await getGoogleAccessToken(fetchImpl)
|
||||
const endpoint = 'https://androidpublisher.googleapis.com/androidpublisher/v3/applications/'
|
||||
+ `${encodeURIComponent(configuredPackage)}/purchases/subscriptionsv2/tokens/`
|
||||
+ encodeURIComponent(purchaseToken)
|
||||
const response = await googleFetch(fetchImpl, endpoint, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
})
|
||||
|
||||
if (response.status === 404) {
|
||||
throw new GooglePlayVerificationError('purchase_not_found', 409)
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new GooglePlayVerificationError('google_play_verification_failed', 502)
|
||||
}
|
||||
|
||||
return await response.json() as GooglePlaySubscriptionV2
|
||||
}
|
||||
|
||||
export async function verifyGooglePlaySubscription(
|
||||
userId: string,
|
||||
productId: string,
|
||||
purchaseToken: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
ownsExpiredPurchaseToken?: (purchaseToken: string) => Promise<boolean>,
|
||||
): Promise<NormalizedGooglePlayPurchase> {
|
||||
if (!GOOGLE_PLAY_PRODUCT_TIERS[productId]) {
|
||||
throw new GooglePlayVerificationError('unknown_product', 400)
|
||||
}
|
||||
|
||||
const verification = await fetchGooglePlaySubscription(purchaseToken, fetchImpl)
|
||||
return await verifyGooglePlaySubscriptionPayload(
|
||||
userId,
|
||||
productId,
|
||||
verification,
|
||||
ownsExpiredPurchaseToken,
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue