diff --git a/ENVIRONMENT.md b/ENVIRONMENT.md index fb81e2ed83..5b49a4b194 100644 --- a/ENVIRONMENT.md +++ b/ENVIRONMENT.md @@ -165,6 +165,8 @@ Manage shared web env var additions and rotations with `pnpm web:env set ({ + args, + type: 'google-auth', +})); + +const mockSubscriptionsV2Get = jest.fn().mockImplementation(() => ({ + data: { subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE' }, +})); + +const mockAndroidPublisher = jest.fn().mockImplementation((...args: unknown[]) => ({ + args, + purchases: { + subscriptionsv2: { + get: mockSubscriptionsV2Get, + }, + }, +})); + +jest.mock('google-auth-library', () => ({ + GoogleAuth: mockGoogleAuth, +})); + +jest.mock('@googleapis/androidpublisher', () => ({ + androidpublisher: mockAndroidPublisher, +})); + +function loadGooglePlaySdk(): typeof GooglePlaySdk { + return jest.requireActual('./google-play-sdk'); +} + +describe('google-play-sdk', () => { + beforeEach(() => { + jest.resetModules(); + process.env.GOOGLE_PLAY_PUBLISHER_SERVICE_ACCOUNT_JSON = JSON.stringify({ + client_email: 'publisher@example.com', + private_key: 'private-key', + }); + jest.clearAllMocks(); + }); + + it('reuses the Android Publisher client for unchanged JSON', () => { + const { createGooglePlayAndroidPublisherClient } = loadGooglePlaySdk(); + + const first = createGooglePlayAndroidPublisherClient(); + const second = createGooglePlayAndroidPublisherClient(); + + expect(second).toBe(first); + expect(mockAndroidPublisher).toHaveBeenCalledTimes(1); + }); + + it('constructs GoogleAuth with the androidpublisher scope', () => { + const { createGooglePlayAndroidPublisherClient } = loadGooglePlaySdk(); + + createGooglePlayAndroidPublisherClient(); + + expect(mockGoogleAuth).toHaveBeenCalledWith({ + credentials: expect.objectContaining({ + client_email: 'publisher@example.com', + private_key: 'private-key', + }), + scopes: ['https://www.googleapis.com/auth/androidpublisher'], + }); + }); + + it('calls subscriptionsv2.get with the package name and token', async () => { + const { getGooglePlaySubscriptionPurchase } = loadGooglePlaySdk(); + + const data = await getGooglePlaySubscriptionPurchase('purchase-token'); + + expect(mockSubscriptionsV2Get).toHaveBeenCalledWith({ + packageName: 'com.kilocode.kiloapp', + token: 'purchase-token', + }); + expect(data).toEqual({ subscriptionState: 'SUBSCRIPTION_STATE_ACTIVE' }); + }); + + it('throws when the service account JSON is not set', () => { + delete process.env.GOOGLE_PLAY_PUBLISHER_SERVICE_ACCOUNT_JSON; + + const { createGooglePlayAndroidPublisherClient } = loadGooglePlaySdk(); + + expect(() => createGooglePlayAndroidPublisherClient()).toThrow( + 'GOOGLE_PLAY_PUBLISHER_SERVICE_ACCOUNT_JSON is not set' + ); + }); + + it('throws when the service account JSON is invalid', () => { + process.env.GOOGLE_PLAY_PUBLISHER_SERVICE_ACCOUNT_JSON = JSON.stringify({ + client_email: 'publisher@example.com', + }); + + const { createGooglePlayAndroidPublisherClient } = loadGooglePlaySdk(); + + expect(() => createGooglePlayAndroidPublisherClient()).toThrow( + 'GOOGLE_PLAY_PUBLISHER_SERVICE_ACCOUNT_JSON is invalid' + ); + }); +}); diff --git a/apps/web/src/lib/kilo-pass/google-play-sdk.ts b/apps/web/src/lib/kilo-pass/google-play-sdk.ts new file mode 100644 index 0000000000..2b6ac2cc4f --- /dev/null +++ b/apps/web/src/lib/kilo-pass/google-play-sdk.ts @@ -0,0 +1,58 @@ +import { androidpublisher } from '@googleapis/androidpublisher'; +import type { androidpublisher_v3 } from '@googleapis/androidpublisher'; +import { GoogleAuth } from 'google-auth-library'; +import type { JWTInput } from 'google-auth-library'; + +import { getEnvVariable } from '@/lib/dotenvx'; + +export const GOOGLE_PLAY_PACKAGE_NAME = 'com.kilocode.kiloapp'; + +const ANDROID_PUBLISHER_SCOPE = 'https://www.googleapis.com/auth/androidpublisher'; + +type CachedValue = { + key: string; + value: T; +}; + +let cachedPublisherClient: CachedValue | null = null; + +function requiredEnv(name: string): string { + const value = getEnvVariable(name); + if (!value) throw new Error(`${name} is not set`); + return value; +} + +function parseGooglePlayServiceAccountCredentials(json: string): JWTInput { + const parsed = JSON.parse(json) as JWTInput; + if (!parsed.client_email || !parsed.private_key) { + throw new Error('GOOGLE_PLAY_PUBLISHER_SERVICE_ACCOUNT_JSON is invalid'); + } + return parsed; +} + +export function createGooglePlayAndroidPublisherClient(): androidpublisher_v3.Androidpublisher { + const serviceAccountJson = requiredEnv('GOOGLE_PLAY_PUBLISHER_SERVICE_ACCOUNT_JSON'); + if (cachedPublisherClient?.key === serviceAccountJson) { + return cachedPublisherClient.value; + } + + const credentials = parseGooglePlayServiceAccountCredentials(serviceAccountJson); + const auth = new GoogleAuth({ + credentials, + scopes: [ANDROID_PUBLISHER_SCOPE], + }); + const client = androidpublisher({ version: 'v3', auth }); + cachedPublisherClient = { key: serviceAccountJson, value: client }; + return client; +} + +export async function getGooglePlaySubscriptionPurchase( + purchaseToken: string +): Promise { + const client = createGooglePlayAndroidPublisherClient(); + const response = await client.purchases.subscriptionsv2.get({ + packageName: GOOGLE_PLAY_PACKAGE_NAME, + token: purchaseToken, + }); + return response.data; +} diff --git a/apps/web/src/lib/kilo-pass/mobile-store-products.test.ts b/apps/web/src/lib/kilo-pass/mobile-store-products.test.ts new file mode 100644 index 0000000000..b3f506986b --- /dev/null +++ b/apps/web/src/lib/kilo-pass/mobile-store-products.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from '@jest/globals'; + +import { KiloPassCadence, KiloPassTier } from './enums'; +import { + getMobileStoreKiloPassProductByAppleProductId, + getMobileStoreKiloPassProductByGoogleProductId, +} from './mobile-store-products'; + +describe('mobile-store-products', () => { + it('looks up a product by Google product id', () => { + const product = getMobileStoreKiloPassProductByGoogleProductId('kilopass_tier19'); + + expect(product?.tier).toBe(KiloPassTier.Tier19); + expect(product?.cadence).toBe(KiloPassCadence.Monthly); + }); + + it('returns null for an unknown Google product id', () => { + expect(getMobileStoreKiloPassProductByGoogleProductId('unknown_id')).toBeNull(); + }); + + it('still looks up a product by Apple product id', () => { + const product = getMobileStoreKiloPassProductByAppleProductId('kilopass.tier19.monthly.v1'); + + expect(product?.tier).toBe(KiloPassTier.Tier19); + expect(product?.cadence).toBe(KiloPassCadence.Monthly); + }); +}); diff --git a/apps/web/src/lib/kilo-pass/mobile-store-products.ts b/apps/web/src/lib/kilo-pass/mobile-store-products.ts index fce53267ba..5725da9f0f 100644 --- a/apps/web/src/lib/kilo-pass/mobile-store-products.ts +++ b/apps/web/src/lib/kilo-pass/mobile-store-products.ts @@ -75,3 +75,13 @@ export function getMobileStoreKiloPassProductByAppleProductId( ) ?? null ); } + +export function getMobileStoreKiloPassProductByGoogleProductId( + googleProductId: string +): MobileStoreKiloPassProduct | null { + return ( + getAllMobileStoreKiloPassProducts().find( + product => product.googleProductId === googleProductId + ) ?? null + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6e5376bf8..383b79d444 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,7 @@ overrides: next: 16.2.11 preact: 10.28.4 glob: '>=13.0.1' + google-auth-library: 10.6.2 esbuild: '>=0.28.1' lodash: '>=4.18.1' diff: '>=8.0.3' @@ -798,6 +799,9 @@ importers: '@emoji-mart/react': specifier: 1.1.1 version: 1.1.1(emoji-mart@5.6.0)(react@19.2.6) + '@googleapis/androidpublisher': + specifier: 37.0.0 + version: 37.0.0 '@kilocode/app-shared': specifier: workspace:* version: link:../../packages/app-shared @@ -5288,6 +5292,10 @@ packages: '@modelcontextprotocol/sdk': optional: true + '@googleapis/androidpublisher@37.0.0': + resolution: {integrity: sha512-54v9hNzvq8qoZs/P2wMcfslrL1aXBUcCektbyNemWulORgX4PNDx8TTzEoOEzEiyTHKLNnmMhiTGG5NIdrndHA==} + engines: {node: '>=12.0.0'} + '@grammyjs/runner@2.0.3': resolution: {integrity: sha512-nckmTs1dPWfVQteK9cxqxzE+0m1VRvluLWB8UgFzsjg62w3qthPJt0TYtJBEdG7OedvfQq4vnFAyE6iaMkR42A==} engines: {node: '>=12.20.0 || >=14.13.1'} @@ -13259,6 +13267,10 @@ packages: resolution: {integrity: sha512-EstfdRQu04tM+SXR3pBmc0+GYKZj9FMHAvA8XAbKPzTvUeHP0v1/F3aQ10bG7EluR6phvFqsvn5Z7SvYJsEgNw==} hasBin: true + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + gaxios@7.1.4: resolution: {integrity: sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==} engines: {node: '>=18'} @@ -13390,6 +13402,10 @@ packages: resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} engines: {node: '>=14'} + googleapis-common@8.0.3: + resolution: {integrity: sha512-7g1yzQKx0mmNTjiK0H9dJ8eqKqDBveES9vLHeg5neb3BMQy/d1oQefIMhIpOVT8a+f+LOcixMEdRbFIW/cQUJw==} + engines: {node: '>=18'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -16898,6 +16914,10 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + rimraf@6.1.3: resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==} engines: {node: 20 || >=22} @@ -18132,6 +18152,9 @@ packages: url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + url-template@2.0.8: + resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==} + url@0.11.4: resolution: {integrity: sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg==} engines: {node: '>= 0.4'} @@ -22008,6 +22031,12 @@ snapshots: - supports-color - utf-8-validate + '@googleapis/androidpublisher@37.0.0': + dependencies: + googleapis-common: 8.0.3 + transitivePeerDependencies: + - supports-color + '@grammyjs/runner@2.0.3(grammy@1.44.0)': dependencies: abort-controller: 3.0.0 @@ -30979,6 +31008,15 @@ snapshots: which: 4.0.0 winreg: 0.0.12 + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + gaxios@7.1.4: dependencies: extend: 3.0.2 @@ -31120,6 +31158,17 @@ snapshots: google-logging-utils@1.1.3: {} + googleapis-common@8.0.3: + dependencies: + extend: 3.0.2 + gaxios: 7.1.3 + google-auth-library: 10.6.2 + google-logging-utils: 1.1.3 + qs: 6.15.2 + url-template: 2.0.8 + transitivePeerDependencies: + - supports-color + gopd@1.2.0: {} graceful-fs@4.2.10: {} @@ -36092,6 +36141,10 @@ snapshots: dependencies: glob: 13.0.6 + rimraf@5.0.10: + dependencies: + glob: 13.0.6 + rimraf@6.1.3: dependencies: glob: 13.0.6 @@ -37526,6 +37579,8 @@ snapshots: url-join@4.0.1: {} + url-template@2.0.8: {} + url@0.11.4: dependencies: punycode: 1.4.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9abae0bd2e..32b7dda688 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -85,6 +85,7 @@ overrides: next: 16.2.11 preact: 10.28.4 glob: '>=13.0.1' + google-auth-library: 10.6.2 esbuild: '>=0.28.1' lodash: '>=4.18.1' diff: '>=8.0.3'