feat(premium): add App Store and Google Play purchases (#3052)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-30 01:55:19 +02:00
1 parent 0b3418dcbe
commit e8cb167dbf
202 files changed
+17095 -49

No files matched your search

+61 -1
View File
@@ -11,7 +11,7 @@ import {
parsePublicOrigin,
parseWebOrigin,
} from '@fluxer/config/src/EndpointDerivation';
import {CACHE_PURGE_ADAPTER_NAMES, type MasterConfig} from '@fluxer/config/src/MasterConfig';
import {CACHE_PURGE_ADAPTER_NAMES, type MasterConfig, STORE_PRODUCT_SLOT_NAMES} from '@fluxer/config/src/MasterConfig';
let cachedConfig: MasterConfig | null = null;
@@ -224,6 +224,21 @@ function defaultConfig(): MasterConfig {
apps: [],
},
},
app_store: {
enabled: false,
apps: [],
products: {},
},
google_play: {
enabled: false,
packages: [],
token_uri: 'https://oauth2.googleapis.com/token',
products: {},
},
store_billing: {
sandbox_user_ids: [],
sandbox_entitles_all: false,
},
},
instance: {
self_hosted: false,
@@ -449,6 +464,50 @@ function validateCachePurgeConfig(config: MasterConfig): void {
assertIntegerInRange(cachePurge.http.timeout_ms, 'FLUXER_CACHE_PURGE_HTTP_TIMEOUT_MS', 1_000, 10_000);
}
function validateStoreProductSlots(
products: Record<string, string> | undefined,
envName: string,
isValidKey: (productKey: string, slot: string) => boolean,
): void {
for (const [productKey, slot] of Object.entries(products ?? {})) {
assertOneOf(slot, STORE_PRODUCT_SLOT_NAMES, `${envName} slot for ${productKey}`);
if (!isValidKey(productKey, slot)) {
throw new Error(`${envName} key ${productKey} does not match its ${slot} slot`);
}
}
}
function validateStoreBillingConfig(config: MasterConfig): void {
const appStore = config.integrations.app_store;
for (const [index, app] of (appStore.apps ?? []).entries()) {
if (!isConfigObject(app) || typeof app.bundle_id !== 'string' || app.bundle_id.trim().length === 0) {
throw new Error(`FLUXER_APP_STORE_APPS entry ${index + 1} must have a bundle_id`);
}
if (typeof app.app_apple_id !== 'number' || !Number.isSafeInteger(app.app_apple_id) || app.app_apple_id <= 0) {
throw new Error(`FLUXER_APP_STORE_APPS entry ${index + 1} must have a numeric app_apple_id`);
}
}
validateStoreProductSlots(appStore.products, 'FLUXER_APP_STORE_PRODUCTS', (productKey) => productKey.length > 0);
validateStoreProductSlots(
config.integrations.google_play.products,
'FLUXER_GOOGLE_PLAY_PRODUCTS',
(productKey, slot) => {
const parts = productKey.split(':');
const isSubscription = slot === 'monthly' || slot === 'yearly';
if (isSubscription) {
return parts.length === 2 && parts.every((part) => part.length > 0);
}
return parts.length === 1 && productKey.length > 0;
},
);
for (const userId of config.integrations.store_billing.sandbox_user_ids ?? []) {
if (!/^\d+$/.test(userId)) {
throw new Error('FLUXER_STORE_BILLING_SANDBOX_USER_IDS must be a comma separated list of user ids');
}
}
assertBoolean(config.integrations.store_billing.sandbox_entitles_all, 'FLUXER_STORE_BILLING_SANDBOX_ENTITLES_ALL');
}
function validateDomain(value: string, envName: string): void {
if (value === '') return;
const parsed = URL.parse(`http://${value}/`);
@@ -509,6 +568,7 @@ function normalizeConfig(config: MasterConfig): MasterConfig {
validateApiWorkerConfig(config);
validateStorageChangeFeedConfig(config);
validateCachePurgeConfig(config);
validateStoreBillingConfig(config);
normalizeAppOriginAliases(config);
assertIntegerInRange(config.services.api.max_inflight_requests, 'FLUXER_API_MAX_INFLIGHT_REQUESTS', 1, 100_000);
assertIntegerInRange(config.services.api.headers_timeout_ms, 'FLUXER_API_HEADERS_TIMEOUT_MS', 1_000, 3_600_000);
+30
View File
@@ -7,6 +7,8 @@ export type DatabaseBackend = 'postgres' | 'cassandra';
export type PublicScheme = 'http' | 'https';
export const CACHE_PURGE_ADAPTER_NAMES = ['none', 'http'] as const;
export type CachePurgeAdapterName = (typeof CACHE_PURGE_ADAPTER_NAMES)[number];
export const STORE_PRODUCT_SLOT_NAMES = ['monthly', 'yearly', 'gift_1_month', 'gift_1_year'] as const;
export type StoreProductSlotName = (typeof STORE_PRODUCT_SLOT_NAMES)[number];
export interface InstanceBrandingConfig {
product_name: string;
@@ -255,6 +257,34 @@ export interface MasterConfig {
}>;
};
};
app_store: {
enabled: boolean;
issuer_id?: string;
key_id?: string;
private_key?: string;
private_key_path?: string;
apps?: Array<{
bundle_id: string;
app_apple_id: number;
}>;
products?: Record<string, StoreProductSlotName>;
};
google_play: {
enabled: boolean;
packages?: Array<string>;
client_email?: string;
private_key?: string;
private_key_path?: string;
service_account_json_path?: string;
token_uri?: string;
products?: Record<string, StoreProductSlotName>;
push_audience?: string;
push_service_account_email?: string;
};
store_billing: {
sandbox_user_ids?: Array<string>;
sandbox_entitles_all: boolean;
};
};
instance: {
self_hosted: boolean;
@@ -524,6 +524,86 @@ describe('ConfigLoader', () => {
expect(config.instance.setup.configured).toBe(true);
});
test('leaves both stores off with no apps, packages or products by default', async () => {
stubMinimalEnv();
const config = await loadConfig();
expect(config.integrations.app_store).toEqual({enabled: false, apps: [], products: {}});
expect(config.integrations.google_play).toEqual({
enabled: false,
packages: [],
token_uri: 'https://oauth2.googleapis.com/token',
products: {},
});
expect(config.integrations.store_billing).toEqual({sandbox_user_ids: [], sandbox_entitles_all: false});
});
test('loads a valid store catalogue', async () => {
stubMinimalEnv({
FLUXER_APP_STORE_ENABLED: 'true',
FLUXER_APP_STORE_APPS: '[{"bundle_id":"com.fluxer","app_apple_id":1234567890}]',
FLUXER_APP_STORE_PRODUCTS: '{"com.fluxer.plutonium.monthly":"monthly","com.fluxer.gift.1month":"gift_1_month"}',
FLUXER_GOOGLE_PLAY_ENABLED: 'true',
FLUXER_GOOGLE_PLAY_PACKAGES: 'com.fluxer',
FLUXER_GOOGLE_PLAY_PRODUCTS: '{"plutonium:yearly":"yearly","gift_1_year":"gift_1_year"}',
FLUXER_STORE_BILLING_SANDBOX_USER_IDS: '1234567890123456',
});
const config = await loadConfig();
expect(config.integrations.app_store.apps).toEqual([{bundle_id: 'com.fluxer', app_apple_id: 1234567890}]);
expect(config.integrations.google_play.products).toEqual({
'plutonium:yearly': 'yearly',
gift_1_year: 'gift_1_year',
});
expect(config.integrations.store_billing.sandbox_user_ids).toEqual(['1234567890123456']);
});
test('rejects an App Store app without a numeric app_apple_id', async () => {
for (const apps of [
'[{"bundle_id":"com.fluxer"}]',
'[{"bundle_id":"com.fluxer","app_apple_id":"1234567890"}]',
'[{"bundle_id":"com.fluxer","app_apple_id":0}]',
]) {
resetConfig();
stubMinimalEnv({FLUXER_APP_STORE_APPS: apps});
await expect(loadConfig()).rejects.toThrow('FLUXER_APP_STORE_APPS entry 1 must have a numeric app_apple_id');
}
});
test('rejects an App Store app without a bundle id', async () => {
stubMinimalEnv({FLUXER_APP_STORE_APPS: '[{"app_apple_id":1234567890}]'});
await expect(loadConfig()).rejects.toThrow('FLUXER_APP_STORE_APPS entry 1 must have a bundle_id');
});
test('rejects an App Store product mapped to an unknown slot', async () => {
stubMinimalEnv({FLUXER_APP_STORE_PRODUCTS: '{"com.fluxer.visionary":"visionary"}'});
await expect(loadConfig()).rejects.toThrow(
'Invalid FLUXER_APP_STORE_PRODUCTS slot for com.fluxer.visionary: visionary',
);
});
test('rejects a Google Play product mapped to an unknown slot', async () => {
stubMinimalEnv({FLUXER_GOOGLE_PLAY_PRODUCTS: '{"plutonium:weekly":"weekly"}'});
await expect(loadConfig()).rejects.toThrow('Invalid FLUXER_GOOGLE_PLAY_PRODUCTS slot for plutonium:weekly: weekly');
});
test('rejects a Google Play product key that does not fit its slot kind', async () => {
stubMinimalEnv({FLUXER_GOOGLE_PLAY_PRODUCTS: '{"plutonium":"monthly"}'});
await expect(loadConfig()).rejects.toThrow(
'FLUXER_GOOGLE_PLAY_PRODUCTS key plutonium does not match its monthly slot',
);
resetConfig();
stubMinimalEnv({FLUXER_GOOGLE_PLAY_PRODUCTS: '{"gift:one":"gift_1_month"}'});
await expect(loadConfig()).rejects.toThrow(
'FLUXER_GOOGLE_PLAY_PRODUCTS key gift:one does not match its gift_1_month slot',
);
});
test('rejects a sandbox user id that is not a snowflake', async () => {
stubMinimalEnv({FLUXER_STORE_BILLING_SANDBOX_USER_IDS: '123,abc'});
await expect(loadConfig()).rejects.toThrow(
'FLUXER_STORE_BILLING_SANDBOX_USER_IDS must be a comma separated list of user ids',
);
});
test('defaults the cache purge adapter to none', async () => {
stubMinimalEnv();
expect((await loadConfig()).integrations.cache_purge).toEqual({
@@ -223,4 +223,94 @@ describe('buildNamedFluxerEnvOverrides', () => {
yearly_brl: 'price_blob_yearly_brl',
});
});
test('maps the App Store variables onto integrations.app_store', () => {
const overrides = buildNamedFluxerEnvOverrides({
FLUXER_APP_STORE_ENABLED: 'true',
FLUXER_APP_STORE_ISSUER_ID: 'issuer-1',
FLUXER_APP_STORE_KEY_ID: 'KEY123',
FLUXER_APP_STORE_PRIVATE_KEY: '-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----',
FLUXER_APP_STORE_PRIVATE_KEY_PATH: '/etc/fluxer/keys/app-store.p8',
FLUXER_APP_STORE_APPS: '[{"bundle_id":"com.fluxer","app_apple_id":1234567890}]',
FLUXER_APP_STORE_PRODUCTS: '{"com.fluxer.plutonium.monthly":"monthly","com.fluxer.gift.1year":"gift_1_year"}',
});
expect(overrides).toEqual({
integrations: {
app_store: {
enabled: true,
issuer_id: 'issuer-1',
key_id: 'KEY123',
private_key: '-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----',
private_key_path: '/etc/fluxer/keys/app-store.p8',
apps: [{bundle_id: 'com.fluxer', app_apple_id: 1234567890}],
products: {'com.fluxer.plutonium.monthly': 'monthly', 'com.fluxer.gift.1year': 'gift_1_year'},
},
},
});
});
test('maps the Google Play variables onto integrations.google_play', () => {
const overrides = buildNamedFluxerEnvOverrides({
FLUXER_GOOGLE_PLAY_ENABLED: 'true',
FLUXER_GOOGLE_PLAY_PACKAGES: 'com.fluxer, com.fluxer.canary',
FLUXER_GOOGLE_PLAY_CLIENT_EMAIL: '[email protected]',
FLUXER_GOOGLE_PLAY_PRIVATE_KEY: 'pem',
FLUXER_GOOGLE_PLAY_PRIVATE_KEY_PATH: '/etc/fluxer/keys/play.pem',
FLUXER_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH: '/etc/fluxer/keys/play.json',
FLUXER_GOOGLE_PLAY_TOKEN_URI: 'https://oauth2.example/token',
FLUXER_GOOGLE_PLAY_PRODUCTS: '{"plutonium:monthly":"monthly","gift_1_month":"gift_1_month"}',
FLUXER_GOOGLE_PLAY_PUSH_AUDIENCE: 'https://api.fluxer.app/webhooks/google-play',
FLUXER_GOOGLE_PLAY_PUSH_SERVICE_ACCOUNT_EMAIL: '[email protected]',
});
expect(overrides).toEqual({
integrations: {
google_play: {
enabled: true,
packages: ['com.fluxer', 'com.fluxer.canary'],
client_email: '[email protected]',
private_key: 'pem',
private_key_path: '/etc/fluxer/keys/play.pem',
service_account_json_path: '/etc/fluxer/keys/play.json',
token_uri: 'https://oauth2.example/token',
products: {'plutonium:monthly': 'monthly', gift_1_month: 'gift_1_month'},
push_audience: 'https://api.fluxer.app/webhooks/google-play',
push_service_account_email: '[email protected]',
},
},
});
});
test('maps the store billing sandbox variables onto integrations.store_billing', () => {
expect(
buildNamedFluxerEnvOverrides({
FLUXER_STORE_BILLING_SANDBOX_USER_IDS: '1234, 5678',
FLUXER_STORE_BILLING_SANDBOX_ENTITLES_ALL: 'true',
}),
).toEqual({
integrations: {store_billing: {sandbox_user_ids: ['1234', '5678'], sandbox_entitles_all: true}},
});
});
test('rejects a store enabled flag that is not a boolean', () => {
expect(() => buildNamedFluxerEnvOverrides({FLUXER_APP_STORE_ENABLED: 'yes'})).toThrow(
'FLUXER_APP_STORE_ENABLED must be true or false',
);
expect(() => buildNamedFluxerEnvOverrides({FLUXER_GOOGLE_PLAY_ENABLED: '1'})).toThrow(
'FLUXER_GOOGLE_PLAY_ENABLED must be true or false',
);
});
test('rejects a store app list or product map with the wrong JSON shape', () => {
expect(() => buildNamedFluxerEnvOverrides({FLUXER_APP_STORE_APPS: '{"bundle_id":"com.fluxer"}'})).toThrow(
'FLUXER_APP_STORE_APPS must be a JSON array',
);
expect(() => buildNamedFluxerEnvOverrides({FLUXER_APP_STORE_PRODUCTS: '["monthly"]'})).toThrow(
'FLUXER_APP_STORE_PRODUCTS must be a JSON object',
);
expect(() => buildNamedFluxerEnvOverrides({FLUXER_GOOGLE_PLAY_PRODUCTS: '{bad'})).toThrow(
'FLUXER_GOOGLE_PLAY_PRODUCTS must be valid JSON',
);
});
});
@@ -28,6 +28,11 @@ const API_SETTINGS_NOT_FORWARDED: Record<string, string> = {
]),
),
),
...Object.fromEntries(
NAMED_FLUXER_ENV_NAMES.filter((name) =>
['FLUXER_APP_STORE_', 'FLUXER_GOOGLE_PLAY_', 'FLUXER_STORE_BILLING_'].some((prefix) => name.startsWith(prefix)),
).map((name) => [name, 'in-app purchases are sold only by the hosted service']),
),
};
const INPUT_NAMES: Record<string, string> = {
@@ -241,6 +241,30 @@ const NAMED_FLUXER_ENV_OVERRIDES: Record<string, NamedEnvOverride> = {
FLUXER_PUSH_APNS_PRIVATE_KEY: {path: ['integrations', 'push', 'apns', 'private_key']},
FLUXER_PUSH_APNS_PRIVATE_KEY_PATH: {path: ['integrations', 'push', 'apns', 'private_key_path']},
FLUXER_PUSH_APNS_APPS: {path: ['integrations', 'push', 'apns', 'apps'], parse: parseJsonArray},
FLUXER_APP_STORE_ENABLED: {path: ['integrations', 'app_store', 'enabled'], parse: parseBoolean},
FLUXER_APP_STORE_ISSUER_ID: {path: ['integrations', 'app_store', 'issuer_id']},
FLUXER_APP_STORE_KEY_ID: {path: ['integrations', 'app_store', 'key_id']},
FLUXER_APP_STORE_PRIVATE_KEY: {path: ['integrations', 'app_store', 'private_key']},
FLUXER_APP_STORE_PRIVATE_KEY_PATH: {path: ['integrations', 'app_store', 'private_key_path']},
FLUXER_APP_STORE_APPS: {path: ['integrations', 'app_store', 'apps'], parse: parseJsonArray},
FLUXER_APP_STORE_PRODUCTS: {path: ['integrations', 'app_store', 'products'], parse: parseJsonObject},
FLUXER_GOOGLE_PLAY_ENABLED: {path: ['integrations', 'google_play', 'enabled'], parse: parseBoolean},
FLUXER_GOOGLE_PLAY_PACKAGES: {path: ['integrations', 'google_play', 'packages'], parse: parseCsv},
FLUXER_GOOGLE_PLAY_CLIENT_EMAIL: {path: ['integrations', 'google_play', 'client_email']},
FLUXER_GOOGLE_PLAY_PRIVATE_KEY: {path: ['integrations', 'google_play', 'private_key']},
FLUXER_GOOGLE_PLAY_PRIVATE_KEY_PATH: {path: ['integrations', 'google_play', 'private_key_path']},
FLUXER_GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH: {path: ['integrations', 'google_play', 'service_account_json_path']},
FLUXER_GOOGLE_PLAY_TOKEN_URI: {path: ['integrations', 'google_play', 'token_uri']},
FLUXER_GOOGLE_PLAY_PRODUCTS: {path: ['integrations', 'google_play', 'products'], parse: parseJsonObject},
FLUXER_GOOGLE_PLAY_PUSH_AUDIENCE: {path: ['integrations', 'google_play', 'push_audience']},
FLUXER_GOOGLE_PLAY_PUSH_SERVICE_ACCOUNT_EMAIL: {
path: ['integrations', 'google_play', 'push_service_account_email'],
},
FLUXER_STORE_BILLING_SANDBOX_USER_IDS: {path: ['integrations', 'store_billing', 'sandbox_user_ids'], parse: parseCsv},
FLUXER_STORE_BILLING_SANDBOX_ENTITLES_ALL: {
path: ['integrations', 'store_billing', 'sandbox_entitles_all'],
parse: parseBoolean,
},
FLUXER_SELF_HOSTED: {path: ['instance', 'self_hosted'], parse: parseBoolean},
FLUXER_AUTO_JOIN_INVITE_CODE: {path: ['instance', 'auto_join_invite_code']},
FLUXER_VISIONARIES_GUILD_ID: {path: ['instance', 'visionaries_guild_id']},