Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ENVIRONMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,8 @@ Manage shared web env var additions and rotations with `pnpm web:env set <VARIAB
- `APPLE_IAP_ISSUER_ID` - Apple IAP issuer (team) ID. `[SECRET]`
- `APPLE_IAP_PRIVATE_KEY` - Apple IAP private key (PEM/ES256) for receipt validation. `[SECRET]`
- `APPLE_ROOT_CERTIFICATES_PEM` - Apple root CA certs (PEM) for validating IAP receipts. [SERVER]
- `GOOGLE_PLAY_PUBLISHER_SERVICE_ACCOUNT_JSON` - Service account JSON for the Android Publisher API (subscriptions v2 get). `[SECRET]`
- `GOOGLE_PLAY_RTDN_PUSH_AUDIENCE` - Expected OIDC audience for Play Real-time Developer Notification Pub/Sub push (the HTTPS URL of POST /api/kilo-pass/play/notifications). `[SERVER]`
- `APPLE_APP_BUNDLE_ID` - iOS app bundle ID for Apple App Attest and Sign In verification. [SERVER]
- `NATIVE_ADMISSION_MODE` - Native admission enforcement mode: `off` (default), `report`, or `enforce`. [SERVER]
- `NATIVE_ADMISSION_SIMULATOR_BYPASS` - When `true` in non-production, bypasses Play Integrity API verification. [SERVER]
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"@chat-adapter/state-redis": "4.36.0",
"@emoji-mart/data": "1.2.1",
"@emoji-mart/react": "1.1.1",
"@googleapis/androidpublisher": "37.0.0",
"@kilocode/app-shared": "workspace:*",
"@kilocode/auto-routing-contracts": "workspace:*",
"@kilocode/cloud-agent-profile": "workspace:*",
Expand Down
101 changes: 101 additions & 0 deletions apps/web/src/lib/kilo-pass/google-play-sdk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, jest } from '@jest/globals';
import type * as GooglePlaySdk from './google-play-sdk';

const mockGoogleAuth = jest.fn().mockImplementation((...args: unknown[]) => ({
args,
type: 'google-auth',
}));

const mockSubscriptionsV2Get = jest.fn().mockImplementation((...args: unknown[]) => ({
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<typeof GooglePlaySdk>('./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'
);
});
});
58 changes: 58 additions & 0 deletions apps/web/src/lib/kilo-pass/google-play-sdk.ts
Original file line number Diff line number Diff line change
@@ -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<T> = {
key: string;
value: T;
};

let cachedPublisherClient: CachedValue<androidpublisher_v3.Androidpublisher> | 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<androidpublisher_v3.Schema$SubscriptionPurchaseV2> {
const client = createGooglePlayAndroidPublisherClient();
const response = await client.purchases.subscriptionsv2.get({
packageName: GOOGLE_PLAY_PACKAGE_NAME,
token: purchaseToken,
});
return response.data;
}
27 changes: 27 additions & 0 deletions apps/web/src/lib/kilo-pass/mobile-store-products.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions apps/web/src/lib/kilo-pass/mobile-store-products.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,13 @@ export function getMobileStoreKiloPassProductByAppleProductId(
) ?? null
);
}

export function getMobileStoreKiloPassProductByGoogleProductId(
googleProductId: string
): MobileStoreKiloPassProduct | null {
return (
getAllMobileStoreKiloPassProducts().find(
product => product.googleProductId === googleProductId
) ?? null
);
}
Loading
Loading