/** * MotorSignal — Play Purchase Verification & Server Acknowledgement Module * Communicates with Google Play Developer API v3 (Android Publisher API) * to authoritatively verify and acknowledge subscriptions and one-time purchases. */ export interface PlayVerificationEnv { GOOGLE_SERVICE_ACCOUNT_EMAIL?: string; GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY?: string; GOOGLE_PLAY_PACKAGE_NAME?: string; } export const AUTHORITATIVE_PACKAGE_NAME = 'app.motorsignal.motorcycle'; export const ALLOWED_PRODUCTS = { PRO_MONTHLY: 'motorsignal_pro_monthly', PRO_LIFETIME: 'motorsignal_pro_lifetime', } as const; export interface VerifyPurchaseRequest { productId: string; purchaseToken: string; } export type AcknowledgementState = 'acknowledged' | 'pendingRetry' | 'notApplicable'; export interface VerificationResponse { schemaVersion: number; productId: string; plan: 'proMonthly' | 'proLifetime' | 'free'; status: | 'proMonthlyActive' | 'proMonthlyGrace' | 'proMonthlyAccountHold' | 'proMonthlyPaused' | 'proMonthlyExpired' | 'proLifetime' | 'unknown' | 'syncPending'; verificationStatus: 'verified' | 'unverified' | 'expired' | 'revoked'; acknowledgementState: AcknowledgementState; verifiedAt: string; expiresAt: string | null; tokenHash: string; error?: string; } // In-memory token cache for short-lived Google OAuth access token let cachedAccessToken: { token: string; expiresAtMs: number } | null = null; /** * Derives SHA-256 fingerprint of purchase token for replay tracking / cache key. */ export async function computeTokenHash(token: string): Promise { const enc = new TextEncoder().encode(token); const digest = await crypto.subtle.digest('SHA-256', enc); return Array.from(new Uint8Array(digest)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } /** * Generates an OAuth2 access token for Google APIs via a service account private key. */ export async function getGoogleAccessToken( clientEmail: string, privateKeyPem: string ): Promise { const nowMs = Date.now(); if (cachedAccessToken && cachedAccessToken.expiresAtMs > nowMs + 60000) { return cachedAccessToken.token; } const nowSec = Math.floor(nowMs / 1000); const expSec = nowSec + 3600; const header = { alg: 'RS256', typ: 'JWT', }; const claimSet = { iss: clientEmail, scope: 'https://www.googleapis.com/auth/androidpublisher', aud: 'https://oauth2.googleapis.com/token', exp: expSec, iat: nowSec, }; const encodedHeader = base64UrlEncode(JSON.stringify(header)); const encodedClaimSet = base64UrlEncode(JSON.stringify(claimSet)); const signingInput = `${encodedHeader}.${encodedClaimSet}`; const binaryKey = pemToBinary(privateKeyPem); const cryptoKey = await crypto.subtle.importKey( 'pkcs8', binaryKey, { name: 'RSASSA-PKCS1-v1_5', hash: { name: 'SHA-256' }, }, false, ['sign'] ); const signature = await crypto.subtle.sign( 'RSASSA-PKCS1-v1_5', cryptoKey, new TextEncoder().encode(signingInput) ); const jwt = `${signingInput}.${base64UrlEncode(signature)}`; const tokenResp = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt, }), }); if (!tokenResp.ok) { const errText = await tokenResp.text().catch(() => ''); throw new Error(`Google OAuth exchange failed (${tokenResp.status}): ${errText}`); } const tokenData = (await tokenResp.json()) as { access_token: string; expires_in: number; }; cachedAccessToken = { token: tokenData.access_token, expiresAtMs: nowMs + (tokenData.expires_in || 3600) * 1000, }; return cachedAccessToken.token; } /** * Authoritatively verifies and acknowledges a Google Play purchase token. */ export async function verifyPlayPurchase( req: VerifyPurchaseRequest, env: PlayVerificationEnv ): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> { const { productId, purchaseToken } = req; if (!productId || typeof productId !== 'string') { return { status: 400, body: { error: 'Invalid or missing productId', code: 'INVALID_PRODUCT' } }; } if (!purchaseToken || typeof purchaseToken !== 'string' || purchaseToken.length < 10) { return { status: 400, body: { error: 'Invalid or missing purchaseToken', code: 'INVALID_PURCHASE' } }; } if ( productId !== ALLOWED_PRODUCTS.PRO_MONTHLY && productId !== ALLOWED_PRODUCTS.PRO_LIFETIME ) { return { status: 400, body: { error: `Product '${productId}' is not recognized`, code: 'INVALID_PRODUCT' }, }; } const tokenHash = await computeTokenHash(purchaseToken); const packageName = env.GOOGLE_PLAY_PACKAGE_NAME || AUTHORITATIVE_PACKAGE_NAME; const clientEmail = env.GOOGLE_SERVICE_ACCOUNT_EMAIL; const privateKey = env.GOOGLE_SERVICE_ACCOUNT_PRIVATE_KEY; if (!clientEmail || !privateKey) { return { status: 503, body: { error: 'Google Play verification credentials not configured on server', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE', }, }; } let accessToken: string; try { accessToken = await getGoogleAccessToken(clientEmail, privateKey); } catch (err: unknown) { const tokenPrefix = tokenHash.substring(0, 8); console.error(`[PlayVerification] [hash=${tokenPrefix}] OAuth token acquisition failed`, err); return { status: 502, body: { error: 'Failed to authenticate with Google Developer API', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE', }, }; } if (productId === ALLOWED_PRODUCTS.PRO_MONTHLY) { return await verifySubscription(packageName, purchaseToken, tokenHash, accessToken); } else { return await verifyOneTimeProduct(packageName, productId, purchaseToken, tokenHash, accessToken); } } /** * Extracts latest valid expiration timestamp across all line items matching the target product. */ export function extractLatestExpiry( lineItems: Array<{ productId?: string; expiryTime?: string }> | undefined, targetProductId: string ): { expiryTimeIso: string | null; expiryTimeMs: number } { if (!lineItems || lineItems.length === 0) { return { expiryTimeIso: null, expiryTimeMs: NaN }; } let latestMs = NaN; let latestIso: string | null = null; for (const item of lineItems) { if (item.productId && item.productId !== targetProductId) { continue; } if (item.expiryTime) { const ms = Date.parse(item.expiryTime); if (!isNaN(ms) && (isNaN(latestMs) || ms > latestMs)) { latestMs = ms; latestIso = new Date(ms).toISOString(); } } } return { expiryTimeIso: latestIso, expiryTimeMs: latestMs }; } /** * Acknowledges a subscription token server-side via purchases.subscriptions.acknowledge. */ export async function acknowledgeSubscription( packageName: string, subscriptionId: string, token: string, accessToken: string, tokenHash: string ): Promise<{ success: boolean; isRetryable: boolean }> { const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent( packageName )}/purchases/subscriptions/${encodeURIComponent(subscriptionId)}/tokens/${encodeURIComponent( token )}:acknowledge`; try { const res = await fetch(endpoint, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({}), }); if (res.ok || res.status === 204) { return { success: true, isRetryable: false }; } const tokenPrefix = tokenHash.substring(0, 8); const text = await res.text().catch(() => ''); // Idempotency: If already acknowledged, Google may respond with 400 'Purchase already acknowledged' or similar if (res.status === 400 && text.toLowerCase().includes('already acknowledged')) { return { success: true, isRetryable: false }; } console.error( `[PlayVerification] [hash=${tokenPrefix}] Subscription acknowledge failed: status=${res.status} body=${text}` ); const isRetryable = res.status === 429 || res.status >= 500; return { success: false, isRetryable }; } catch (e) { const tokenPrefix = tokenHash.substring(0, 8); console.error(`[PlayVerification] [hash=${tokenPrefix}] Subscription acknowledge network exception:`, e); return { success: false, isRetryable: true }; } } /** * Acknowledges a one-time product token server-side via purchases.products.acknowledge. */ export async function acknowledgeOneTimeProduct( packageName: string, productId: string, token: string, accessToken: string, tokenHash: string ): Promise<{ success: boolean; isRetryable: boolean }> { const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent( packageName )}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent( token )}:acknowledge`; try { const res = await fetch(endpoint, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify({}), }); if (res.ok || res.status === 204) { return { success: true, isRetryable: false }; } const tokenPrefix = tokenHash.substring(0, 8); const text = await res.text().catch(() => ''); // Idempotency check for product acknowledgement if (res.status === 400 && text.toLowerCase().includes('already acknowledged')) { return { success: true, isRetryable: false }; } console.error( `[PlayVerification] [hash=${tokenPrefix}] Product acknowledge failed: status=${res.status} body=${text}` ); const isRetryable = res.status === 429 || res.status >= 500; return { success: false, isRetryable }; } catch (e) { const tokenPrefix = tokenHash.substring(0, 8); console.error(`[PlayVerification] [hash=${tokenPrefix}] Product acknowledge network exception:`, e); return { success: false, isRetryable: true }; } } /** * Verifies subscription using purchases.subscriptionsv2.get */ async function verifySubscription( packageName: string, token: string, tokenHash: string, accessToken: string ): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> { const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent( packageName )}/purchases/subscriptionsv2/tokens/${encodeURIComponent(token)}`; const res = await fetch(endpoint, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, }); if (res.status === 404) { return { status: 404, body: { error: 'Purchase token not found in Google Play', code: 'INVALID_PURCHASE' }, }; } if (res.status === 401 || res.status === 403) { const tokenPrefix = tokenHash.substring(0, 8); console.error(`[PlayVerification] [hash=${tokenPrefix}] Google returned status ${res.status}`); return { status: 502, body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' }, }; } if (res.status === 429) { return { status: 429, body: { error: 'Google Developer API rate limited', code: 'RATE_LIMITED' }, }; } if (!res.ok) { return { status: 502, body: { error: `Google API returned error status ${res.status}`, code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' }, }; } const sub = (await res.json()) as { subscriptionState?: string; acknowledgementState?: string; lineItems?: Array<{ productId?: string; expiryTime?: string; }>; }; const { expiryTimeIso, expiryTimeMs } = extractLatestExpiry( sub.lineItems, ALLOWED_PRODUCTS.PRO_MONTHLY ); const nowMs = Date.now(); const state = sub.subscriptionState || 'SUBSCRIPTION_STATE_UNSPECIFIED'; let plan: 'proMonthly' | 'free' = 'proMonthly'; let status: VerificationResponse['status'] = 'proMonthlyActive'; let verificationStatus: VerificationResponse['verificationStatus'] = 'verified'; switch (state) { case 'SUBSCRIPTION_STATE_ACTIVE': status = 'proMonthlyActive'; verificationStatus = 'verified'; break; case 'SUBSCRIPTION_STATE_IN_GRACE_PERIOD': status = 'proMonthlyGrace'; verificationStatus = 'verified'; break; case 'SUBSCRIPTION_STATE_ON_HOLD': status = 'proMonthlyAccountHold'; verificationStatus = 'verified'; // Hold is confirmed verified, but entitlement state will deny pro break; case 'SUBSCRIPTION_STATE_PAUSED': status = 'proMonthlyPaused'; verificationStatus = 'verified'; // Explicit Paused state; denies Pro access break; case 'SUBSCRIPTION_STATE_CANCELED': // Canceled: user keeps entitlement until paid period expires if (!isNaN(expiryTimeMs) && expiryTimeMs > nowMs) { status = 'proMonthlyActive'; verificationStatus = 'verified'; } else { status = 'proMonthlyExpired'; verificationStatus = 'expired'; } break; case 'SUBSCRIPTION_STATE_EXPIRED': status = 'proMonthlyExpired'; verificationStatus = 'expired'; break; case 'SUBSCRIPTION_STATE_PENDING': status = 'syncPending'; verificationStatus = 'unverified'; break; default: status = 'unknown'; verificationStatus = 'unverified'; break; } // Server-Side Acknowledgement // Only acknowledge if purchase is in active/entitled state and not already acknowledged. let ackState: AcknowledgementState = 'notApplicable'; const isAlreadyAcknowledged = sub.acknowledgementState === 'ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED'; const shouldAcknowledge = (status === 'proMonthlyActive' || status === 'proMonthlyGrace' || status === 'proMonthlyAccountHold') && verificationStatus === 'verified'; if (shouldAcknowledge) { if (isAlreadyAcknowledged) { ackState = 'acknowledged'; } else { const ackResult = await acknowledgeSubscription( packageName, ALLOWED_PRODUCTS.PRO_MONTHLY, token, accessToken, tokenHash ); if (ackResult.success) { ackState = 'acknowledged'; } else { // Verification succeeded, but acknowledgement requires retry ackState = 'pendingRetry'; } } } return { status: 200, body: { schemaVersion: 1, productId: ALLOWED_PRODUCTS.PRO_MONTHLY, plan, status, verificationStatus, acknowledgementState: ackState, verifiedAt: new Date().toISOString(), expiresAt: expiryTimeIso, tokenHash, }, }; } /** * Verifies one-time lifetime purchase using purchases.products.get */ async function verifyOneTimeProduct( packageName: string, productId: string, token: string, tokenHash: string, accessToken: string ): Promise<{ status: number; body: VerificationResponse | { error: string; code: string } }> { const endpoint = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${encodeURIComponent( packageName )}/purchases/products/${encodeURIComponent(productId)}/tokens/${encodeURIComponent(token)}`; const res = await fetch(endpoint, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', }, }); if (res.status === 404) { return { status: 404, body: { error: 'Purchase token not found in Google Play', code: 'INVALID_PURCHASE' }, }; } if (res.status === 401 || res.status === 403) { const tokenPrefix = tokenHash.substring(0, 8); console.error(`[PlayVerification] [hash=${tokenPrefix}] Google returned status ${res.status}`); return { status: 502, body: { error: 'Google API authorization failure', code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' }, }; } if (res.status === 429) { return { status: 429, body: { error: 'Google Developer API rate limited', code: 'RATE_LIMITED' }, }; } if (!res.ok) { return { status: 502, body: { error: `Google API returned error status ${res.status}`, code: 'GOOGLE_TEMPORARILY_UNAVAILABLE' }, }; } const prod = (await res.json()) as { purchaseState?: number; // 0 = Purchased, 1 = Canceled/Refunded, 2 = Pending consumptionState?: number; acknowledgementState?: number; // 0 = Yet to be acknowledged, 1 = Acknowledged }; const purchaseState = prod.purchaseState ?? 0; let status: VerificationResponse['status'] = 'proLifetime'; let verificationStatus: VerificationResponse['verificationStatus'] = 'verified'; if (purchaseState === 0) { // Purchased & valid status = 'proLifetime'; verificationStatus = 'verified'; } else if (purchaseState === 1) { // Canceled / refunded status = 'unknown'; verificationStatus = 'revoked'; } else if (purchaseState === 2) { // Pending status = 'syncPending'; verificationStatus = 'unverified'; } // Server-Side Acknowledgement for Lifetime let ackState: AcknowledgementState = 'notApplicable'; const isAlreadyAcknowledged = prod.acknowledgementState === 1; if (purchaseState === 0 && verificationStatus === 'verified') { if (isAlreadyAcknowledged) { ackState = 'acknowledged'; } else { const ackResult = await acknowledgeOneTimeProduct( packageName, productId, token, accessToken, tokenHash ); if (ackResult.success) { ackState = 'acknowledged'; } else { ackState = 'pendingRetry'; } } } return { status: 200, body: { schemaVersion: 1, productId: ALLOWED_PRODUCTS.PRO_LIFETIME, plan: 'proLifetime', status, verificationStatus, acknowledgementState: ackState, verifiedAt: new Date().toISOString(), expiresAt: null, tokenHash, }, }; } // Helpers for PKCS#8 PEM decoding and Base64URL function pemToBinary(pem: string): ArrayBuffer { const cleanPem = pem .replace(/-----BEGIN[ A-Z0-9_-]+-----/g, '') .replace(/-----END[ A-Z0-9_-]+-----/g, '') .replace(/\s+/g, ''); const raw = atob(cleanPem); const bytes = new Uint8Array(raw.length); for (let i = 0; i < raw.length; i++) { bytes[i] = raw.charCodeAt(i); } return bytes.buffer; } function base64UrlEncode(input: string | ArrayBuffer): string { let base64: string; if (typeof input === 'string') { base64 = btoa(input); } else { const bytes = new Uint8Array(input); let binary = ''; for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } base64 = btoa(binary); } return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }