mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 05:25:40 +09:00
feat(premium): add App Store and Google Play purchases (#3052)
This commit is contained in:
1 parent
0b3418dcbe
commit
e8cb167dbf
202 files changed
+17095
-49
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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']},
|
||||
|
||||
Reference in new issue
Block a user