diff --git a/package/__tests__/deviceLocale.test.ts b/package/__tests__/deviceLocale.test.ts new file mode 100644 index 0000000..1decb2e --- /dev/null +++ b/package/__tests__/deviceLocale.test.ts @@ -0,0 +1,19 @@ +import { formatLanguageTag } from '../src/util/deviceLocale'; + +describe('formatLanguageTag', () => { + it('underscore separator becomes hyphen with uppercase dialect', () => { + expect(formatLanguageTag('en_US')).toBe('en-US'); + }); + + it('lowercase dialect is uppercased', () => { + expect(formatLanguageTag('fr-ca')).toBe('fr-CA'); + }); + + it('root only is lowercased', () => { + expect(formatLanguageTag('EN')).toBe('en'); + }); + + it('blank falls back to english', () => { + expect(formatLanguageTag('')).toBe('en'); + }); +}); diff --git a/package/__tests__/headlessApiClient.test.ts b/package/__tests__/headlessApiClient.test.ts new file mode 100644 index 0000000..359d699 --- /dev/null +++ b/package/__tests__/headlessApiClient.test.ts @@ -0,0 +1,424 @@ +import { + consentConfigToJson, + consentUpdateToJson, + HeadlessException, + MigrationOption, + withoutProtocols, +} from '../src/headless/headlessTypes'; +import { + HeadlessApiClient, + type FetchFn, +} from '../src/headless/headlessApiClient'; +import { KetchDataCenter, MobileSdkUrlByDataCenterMap } from '../src/enums'; + +const consentConfig = { + organizationCode: 'org', + propertyCode: 'prop', + environmentCode: 'production', + jurisdictionCode: 'default', + identities: { id: '1' }, + purposes: {}, +}; + +const consentUpdate = { + organizationCode: 'org', + propertyCode: 'prop', + environmentCode: 'production', + identities: { id: '1' }, + jurisdictionCode: 'default', + migrationOption: MigrationOption.MIGRATE_DEFAULT, + purposes: { + analytics: { allowed: 'true', legalBasisCode: 'consent_optin' }, + }, +}; + +function mockFetch( + impl: () => Promise<{ + ok: boolean; + status: number; + text: () => Promise; + }> +): FetchFn { + return jest.fn().mockImplementation(impl) as unknown as FetchFn; +} + +function mockFetchResponse(options: { + ok: boolean; + status?: number; + body?: string; +}): FetchFn { + return mockFetch(async () => ({ + ok: options.ok, + status: options.status ?? (options.ok ? 200 : 500), + text: async () => options.body ?? '', + })); +} + +function mockFetchNetworkError(error: Error): FetchFn { + return mockFetch(async () => { + throw error; + }); +} + +function mockFetchCapturing(): { fetchFn: FetchFn; calls: () => [string, RequestInit | undefined][] } { + const calls: [string, RequestInit | undefined][] = []; + const fetchFn = jest.fn().mockImplementation(async (url: string, init?: RequestInit) => { + calls.push([url, init]); + return { ok: true, status: 200, text: async () => '{}' }; + }) as unknown as FetchFn; + return { fetchFn, calls: () => calls }; +} + +describe('getFullConfiguration URL building', () => { + it('all fields present: full static path, hash-only query', async () => { + const { fetchFn, calls } = mockFetchCapturing(); + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US, fetchFn, deviceLanguage: () => 'fr-CA' }); + await client.getFullConfiguration({ + organizationCode: 'org', + propertyCode: 'prop', + environmentCode: 'production', + jurisdictionCode: 'us-ca', + languageCode: 'en-US', + hash: 'abc123', + }); + expect(calls()[0]![0]).toBe( + 'https://global.ketchcdn.com/web/v3/config/org/prop/production/us-ca/en-US/config.json?hash=abc123' + ); + }); + + it('nothing set: short path defaults language from device', async () => { + const { fetchFn, calls } = mockFetchCapturing(); + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US, fetchFn, deviceLanguage: () => 'fr-CA' }); + await client.getFullConfiguration({ organizationCode: 'org', propertyCode: 'prop' }); + expect(calls()[0]![0]).toBe( + 'https://global.ketchcdn.com/web/v3/config/org/prop/config.json?language=fr-CA' + ); + }); + + it('explicit language wins over device locale', async () => { + const { fetchFn, calls } = mockFetchCapturing(); + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US, fetchFn, deviceLanguage: () => 'fr-CA' }); + await client.getFullConfiguration({ organizationCode: 'org', propertyCode: 'prop', languageCode: 'de-DE' }); + expect(calls()[0]![0]).toBe( + 'https://global.ketchcdn.com/web/v3/config/org/prop/config.json?language=de-DE' + ); + }); + + it('jurisdiction only: short path includes jurisdiction and defaulted language', async () => { + const { fetchFn, calls } = mockFetchCapturing(); + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US, fetchFn, deviceLanguage: () => 'fr-CA' }); + await client.getFullConfiguration({ organizationCode: 'org', propertyCode: 'prop', jurisdictionCode: 'us-ca' }); + expect(calls()[0]![0]).toBe( + 'https://global.ketchcdn.com/web/v3/config/org/prop/config.json?language=fr-CA&jurisdiction=us-ca' + ); + }); + + it('region only: short path includes region and defaulted language', async () => { + const { fetchFn, calls } = mockFetchCapturing(); + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US, fetchFn, deviceLanguage: () => 'fr-CA' }); + await client.getFullConfiguration({ organizationCode: 'org', propertyCode: 'prop', regionCode: 'US-CA' }); + expect(calls()[0]![0]).toBe( + 'https://global.ketchcdn.com/web/v3/config/org/prop/config.json?language=fr-CA®ion=US-CA' + ); + }); + + it('blank environment treated as absent, still includes hash', async () => { + const { fetchFn, calls } = mockFetchCapturing(); + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US, fetchFn, deviceLanguage: () => 'fr-CA' }); + await client.getFullConfiguration({ + organizationCode: 'org', + propertyCode: 'prop', + environmentCode: '', + jurisdictionCode: 'us-ca', + languageCode: 'en-US', + hash: 'abc123', + }); + expect(calls()[0]![0]).toBe( + 'https://global.ketchcdn.com/web/v3/config/org/prop/config.json?language=en-US&jurisdiction=us-ca&hash=abc123' + ); + }); + + it('short path includes Accept-Language header from device locale', async () => { + const { fetchFn, calls } = mockFetchCapturing(); + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US, fetchFn, deviceLanguage: () => 'fr-CA' }); + await client.getFullConfiguration({ organizationCode: 'org', propertyCode: 'prop' }); + expect(calls()[0]![1]?.headers).toMatchObject({ 'Accept-Language': 'fr-CA' }); + }); +}); + +describe('HeadlessApiClient URL building', () => { + it('buildUrl ip', () => { + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US }); + expect(client.buildUrl('/ip')).toBe( + 'https://global.ketchcdn.com/web/v3/ip' + ); + }); + + it('buildUrl bootstrap', () => { + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US }); + expect(client.buildUrl('/config/acme/prop/boot.json')).toBe( + 'https://global.ketchcdn.com/web/v3/config/acme/prop/boot.json' + ); + }); + + it('buildUrl fullConfigurationWithHash', () => { + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US }); + expect( + client.buildUrl('/config/acme/prop/prod/us-ca/en-US/config.json', { + hash: '8913461971881236311', + }) + ).toBe( + 'https://global.ketchcdn.com/web/v3/config/acme/prop/prod/us-ca/en-US/config.json?hash=8913461971881236311' + ); + }); + + it('buildUrl eu data center', () => { + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.EU }); + expect(client.buildUrl('/ip')).toBe('https://eu.ketchcdn.com/web/v3/ip'); + }); + + it('ketchDataCenter base URLs', () => { + expect(MobileSdkUrlByDataCenterMap[KetchDataCenter.US]).toBe( + 'https://global.ketchcdn.com/web/v3' + ); + expect(MobileSdkUrlByDataCenterMap[KetchDataCenter.EU]).toBe( + 'https://eu.ketchcdn.com/web/v3' + ); + expect(MobileSdkUrlByDataCenterMap[KetchDataCenter.UAT]).toBe( + 'https://dev.ketchcdn.com/web/v3' + ); + }); +}); + +describe('Headless consent payloads', () => { + it('setConsent payload omits protocols', () => { + const update = { + organizationCode: 'org', + propertyCode: 'prop', + environmentCode: 'production', + identities: { id: '1' }, + jurisdictionCode: 'default', + migrationOption: MigrationOption.MIGRATE_DEFAULT, + purposes: { + analytics: { allowed: 'true', legalBasisCode: 'consent_optin' }, + }, + protocols: { gpp: 'DBABLA~' }, + }; + const json = consentUpdateToJson(withoutProtocols(update)); + expect(json).not.toHaveProperty('protocols'); + expect(json.organizationCode).toBe('org'); + }); + + it('buildUrl invokeRight', () => { + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US }); + expect(client.buildUrl('/rights/switchbitcorp/invoke')).toBe( + 'https://global.ketchcdn.com/web/v3/rights/switchbitcorp/invoke' + ); + }); + + it('preferenceQRUrl matches contract fixture', () => { + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US }); + expect( + client.preferenceQRUrl({ + organizationCode: 'switchbitcorp', + propertyCode: 'switchbit', + environmentCode: 'production', + imageSize: 1024, + path: '/policy.html', + backgroundColor: 'white', + foregroundColor: 'black', + parameters: { foo: 'bar' }, + }) + ).toBe( + 'https://global.ketchcdn.com/web/v3/qr/switchbitcorp/switchbit/preferences.png?env=production&size=1024&path=%2Fpolicy.html&bgcolor=white&fgcolor=black&foo=bar' + ); + }); + + it('buildUrl subscriptions configuration', () => { + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US }); + expect( + client.buildUrl('/config/switchbitcorp/foo/en-US/bar/subscriptions.json') + ).toBe( + 'https://global.ketchcdn.com/web/v3/config/switchbitcorp/foo/en-US/bar/subscriptions.json' + ); + }); + + it('buildUrl profile and subscriptions', () => { + const client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US }); + expect(client.buildUrl('/profile/acme/get')).toBe( + 'https://global.ketchcdn.com/web/v3/profile/acme/get' + ); + expect(client.buildUrl('/subscriptions/acme/get')).toBe( + 'https://global.ketchcdn.com/web/v3/subscriptions/acme/get' + ); + expect(client.buildUrl('/subscriptions/acme/update')).toBe( + 'https://global.ketchcdn.com/web/v3/subscriptions/acme/update' + ); + }); + + it('consentConfig payload omits cachedAt', () => { + const json = consentConfigToJson({ + organizationCode: 'org', + propertyCode: 'prop', + environmentCode: 'production', + jurisdictionCode: 'default', + identities: {}, + purposes: {}, + }); + expect(json).not.toHaveProperty('cachedAt'); + }); +}); + +describe('HeadlessApiClient consent', () => { + it('getConsent propagates HTTP failure', async () => { + const client = new HeadlessApiClient({ + dataCenter: KetchDataCenter.US, + fetchFn: mockFetchResponse({ ok: false, status: 500 }), + }); + + await expect(client.getConsent(consentConfig)).rejects.toThrow( + HeadlessException + ); + await expect(client.getConsent(consentConfig)).rejects.toThrow( + 'HTTP 500' + ); + }); + + it('setConsentOnServer propagates network failure', async () => { + const client = new HeadlessApiClient({ + dataCenter: KetchDataCenter.US, + fetchFn: mockFetchNetworkError(new TypeError('Network request failed')), + }); + + await expect(client.setConsentOnServer(consentUpdate)).rejects.toThrow( + HeadlessException + ); + }); + + it('getConsent returns empty consent on malformed JSON', async () => { + const client = new HeadlessApiClient({ + dataCenter: KetchDataCenter.US, + fetchFn: mockFetchResponse({ ok: true, body: '{not-json' }), + }); + + await expect(client.getConsent(consentConfig)).resolves.toEqual({ + purposes: {}, + }); + }); + + it('getConsent returns empty consent on 200 null body', async () => { + const client = new HeadlessApiClient({ + dataCenter: KetchDataCenter.US, + fetchFn: mockFetchResponse({ ok: true, body: 'null' }), + }); + + await expect(client.getConsent(consentConfig)).resolves.toEqual({ + purposes: {}, + }); + }); + + it('getConsent returns empty consent on 200 blank body', async () => { + const client = new HeadlessApiClient({ + dataCenter: KetchDataCenter.US, + fetchFn: mockFetchResponse({ ok: true, body: '' }), + }); + + await expect(client.getConsent(consentConfig)).resolves.toEqual({ + purposes: {}, + }); + }); + + it('setConsentOnServer accepts protocols-only response', async () => { + const client = new HeadlessApiClient({ + dataCenter: KetchDataCenter.US, + fetchFn: mockFetchResponse({ + ok: true, + body: JSON.stringify({ protocols: { gpp: 'DBABLA~' } }), + }), + }); + + await expect(client.setConsentOnServer(consentUpdate)).resolves.toEqual({ + purposes: undefined, + vendors: undefined, + protocols: { gpp: 'DBABLA~' }, + }); + }); +}); + +describe('hasUsableConsentFields (via getConsent)', () => { + it('returns empty consent when purposes and protocols are empty objects', async () => { + const client = new HeadlessApiClient({ + dataCenter: KetchDataCenter.US, + fetchFn: mockFetchResponse({ + ok: true, + body: JSON.stringify({ purposes: {}, protocols: {} }), + }), + }); + + await expect(client.getConsent(consentConfig)).resolves.toEqual({ + purposes: {}, + }); + }); + + it('accepts purposes-only response', async () => { + const client = new HeadlessApiClient({ + dataCenter: KetchDataCenter.US, + fetchFn: mockFetchResponse({ + ok: true, + body: JSON.stringify({ purposes: { analytics: true } }), + }), + }); + + await expect(client.getConsent(consentConfig)).resolves.toEqual({ + purposes: { analytics: true }, + vendors: undefined, + protocols: undefined, + }); + }); + + it('accepts protocols-only response', async () => { + const client = new HeadlessApiClient({ + dataCenter: KetchDataCenter.US, + fetchFn: mockFetchResponse({ + ok: true, + body: JSON.stringify({ protocols: { gpp: 'DBABLA~' } }), + }), + }); + + await expect(client.getConsent(consentConfig)).resolves.toEqual({ + purposes: undefined, + vendors: undefined, + protocols: { gpp: 'DBABLA~' }, + }); + }); + + it('returns empty consent when neither purposes nor protocols are present', async () => { + const client = new HeadlessApiClient({ + dataCenter: KetchDataCenter.US, + fetchFn: mockFetchResponse({ + ok: true, + body: JSON.stringify({ vendors: ['1'] }), + }), + }); + + await expect(client.getConsent(consentConfig)).resolves.toEqual({ + purposes: {}, + }); + }); + + it('setConsentOnServer falls back when response has empty purposes and protocols', async () => { + const client = new HeadlessApiClient({ + dataCenter: KetchDataCenter.US, + fetchFn: mockFetchResponse({ + ok: true, + body: JSON.stringify({ purposes: {}, protocols: {} }), + }), + }); + + await expect(client.setConsentOnServer(consentUpdate)).resolves.toEqual({ + purposes: { analytics: true }, + vendors: undefined, + protocols: {}, + }); + }); +}); diff --git a/package/__tests__/headlessCdnIntegration.test.ts b/package/__tests__/headlessCdnIntegration.test.ts new file mode 100644 index 0000000..dc23da8 --- /dev/null +++ b/package/__tests__/headlessCdnIntegration.test.ts @@ -0,0 +1,90 @@ +/** + * Live CDN headless tests — require network. + * + * Run: `KETCH_INTEGRATION_TESTS=1 npm run test:integration` + */ +import { HeadlessApiClient } from '../src/headless/headlessApiClient'; +import { + MigrationOption, + withoutProtocols, + type ConsentUpdate, +} from '../src/headless/headlessTypes'; +import { KetchDataCenter } from '../src/enums'; +import { HeadlessIntegrationSupport } from './headlessIntegrationSupport'; + +const runIntegration = process.env.KETCH_INTEGRATION_TESTS === '1'; + +(runIntegration ? describe : describe.skip)('Headless CDN integration', () => { + jest.setTimeout(60_000); + + let client: HeadlessApiClient; + + beforeEach(() => { + client = new HeadlessApiClient({ dataCenter: KetchDataCenter.US }); + }); + + it('getLocation returns GeoIP from CDN', async () => { + const location = await client.getLocation(); + expect(location.location?.countryCode).toBeTruthy(); + }); + + it('getBootstrapConfiguration returns sandbox config', async () => { + const boot = await client.getBootstrapConfiguration( + HeadlessIntegrationSupport.orgCode, + HeadlessIntegrationSupport.propertyCode + ); + expect(Object.keys(boot).length).toBeGreaterThan(0); + const environments = boot.environments; + expect(Array.isArray(environments) && environments.length > 0).toBe(true); + }); + + it('headless cold start consent round-trip', async () => { + const identities = HeadlessIntegrationSupport.uniqueEmailIdentity(); + + await client.getLocation(); + + await client.getBootstrapConfiguration( + HeadlessIntegrationSupport.orgCode, + HeadlessIntegrationSupport.propertyCode + ); + + const fullConfig = await client.getFullConfiguration({ + organizationCode: HeadlessIntegrationSupport.orgCode, + propertyCode: HeadlessIntegrationSupport.propertyCode, + }); + + const consentConfig = + HeadlessIntegrationSupport.consentConfigFromConfiguration({ + configuration: fullConfig, + identities, + }); + + const consent = await client.getConsent(consentConfig); + const hasProtocols = + consent.protocols != null && Object.keys(consent.protocols).length > 0; + const hasPurposes = + consent.purposes != null && Object.keys(consent.purposes).length > 0; + expect(hasProtocols || hasPurposes).toBe(true); + + const purposeCode = Object.keys(consentConfig.purposes)[0]; + const legalBasis = consentConfig.purposes[purposeCode || '']; + + const update: ConsentUpdate = { + organizationCode: HeadlessIntegrationSupport.orgCode, + propertyCode: HeadlessIntegrationSupport.propertyCode, + environmentCode: HeadlessIntegrationSupport.environmentCode, + identities, + jurisdictionCode: consentConfig.jurisdictionCode, + migrationOption: MigrationOption.MIGRATE_DEFAULT, + purposes: { + [purposeCode || '']: { + allowed: 'true', + legalBasisCode: legalBasis?.legalBasisCode || '', + }, + }, + }; + + const updated = await client.setConsentOnServer(withoutProtocols(update)); + expect(updated.purposes?.[purposeCode || '']).toBeDefined(); + }); +}); diff --git a/package/__tests__/headlessIntegrationSupport.ts b/package/__tests__/headlessIntegrationSupport.ts new file mode 100644 index 0000000..cae5575 --- /dev/null +++ b/package/__tests__/headlessIntegrationSupport.ts @@ -0,0 +1,67 @@ +import type { ConsentConfig } from '../src/headless/headlessTypes'; + +/** Shared sandbox config for live CDN integration tests. */ +export const HeadlessIntegrationSupport = { + orgCode: 'ketch_samples', + propertyCode: 'react_native_sample_app', + environmentCode: 'production', + + uniqueEmailIdentity(): Record { + return { + email: `headless-${Date.now()}@integration.ketch.test`, + }; + }, + + consentConfigFromConfiguration(options: { + configuration: Record; + identities: Record; + organizationCode?: string; + propertyCode?: string; + environmentCode?: string; + }): ConsentConfig { + const { + configuration, + identities, + organizationCode = HeadlessIntegrationSupport.orgCode, + propertyCode = HeadlessIntegrationSupport.propertyCode, + environmentCode = HeadlessIntegrationSupport.environmentCode, + } = options; + + const jurisdictionMap = configuration.jurisdiction; + let jurisdiction = 'us'; + if (jurisdictionMap && typeof jurisdictionMap === 'object') { + const map = jurisdictionMap as Record; + jurisdiction = String( + map.code ?? map.defaultJurisdictionCode ?? jurisdiction + ); + } + + const purposesList = configuration.purposes; + const purposes: ConsentConfig['purposes'] = {}; + if (Array.isArray(purposesList)) { + for (const entry of purposesList) { + if (entry && typeof entry === 'object') { + const purpose = entry as Record; + const code = purpose.code?.toString(); + const legalBasis = purpose.legalBasisCode?.toString(); + if (code && legalBasis) { + purposes[code] = { legalBasisCode: legalBasis }; + } + } + } + } + + if (Object.keys(purposes).length === 0) { + throw new Error('Configuration returned no purposes'); + } + + return { + organizationCode, + propertyCode, + environmentCode, + jurisdictionCode: jurisdiction, + identities, + purposes, + }; + }, +}; diff --git a/package/package.json b/package/package.json index f05f1ff..933a79e 100644 --- a/package/package.json +++ b/package/package.json @@ -19,6 +19,7 @@ "scripts": { "all": "npm run clean && npm run lint && npm run typecheck && npm run prepare", "test": "jest", + "test:integration": "KETCH_INTEGRATION_TESTS=1 jest headlessCdnIntegration", "typecheck": "tsc --noEmit", "lint": "eslint \"**/*.{js,ts,tsx}\"", "lint-fix": "eslint \"**/*.{js,ts,tsx}\" --fix", @@ -92,6 +93,9 @@ "modulePathIgnorePatterns": [ "/example/node_modules", "/lib/" + ], + "testPathIgnorePatterns": [ + "/__tests__/headlessIntegrationSupport.ts" ] }, "commitlint": { diff --git a/package/src/KetchServiceProvider/KetchServiceProvider.tsx b/package/src/KetchServiceProvider/KetchServiceProvider.tsx index 26b264a..2af0517 100644 --- a/package/src/KetchServiceProvider/KetchServiceProvider.tsx +++ b/package/src/KetchServiceProvider/KetchServiceProvider.tsx @@ -9,14 +9,7 @@ import React, { type ReactElement, } from 'react'; -import { - Platform, - NativeModules, - View, - Linking, - StatusBar, - Dimensions, -} from 'react-native'; +import { Platform, View, Linking, StatusBar, Dimensions } from 'react-native'; import WebView, { type WebViewMessageEvent, type WebViewNavigation, @@ -43,6 +36,7 @@ import { createOptionsString, getWebViewConfigKey, savePrivacyToStorage, + getDeviceLanguage, } from '../util'; import { getIndexHtml, @@ -53,6 +47,15 @@ import { import styles from './styles'; import nativeStorage from '../util/nativeStorage'; import wrapSharedPrefences from '../util/wrapSharedPrefences'; +import { KetchHeadless } from '../headless'; +import type { + ConsentConfig, + ConsentUpdate, + FullConfigurationRequest, + InvokeRightRequest, + PreferenceQRRequest, + SubscriptionsRequest, +} from '../headless/headlessTypes'; import { trackingAuthorizationStatusString, ATT_LAST_STORAGE_KEY, @@ -73,18 +76,11 @@ const isWithin1kb = (css: string): boolean => ? new TextEncoder().encode(css).length <= 1024 : css.length <= 1024; -const deviceLanguage: string = - Platform.OS === 'ios' - ? NativeModules.SettingsManager?.settings?.AppleLocale || - NativeModules.SettingsManager?.settings?.AppleLanguages?.[0] || - 'en' - : NativeModules.I18nManager?.localeIdentifier || 'en'; - export const KetchServiceProvider: React.FC = ({ organizationCode, propertyCode, identities, - languageCode = deviceLanguage, + languageCode = getDeviceLanguage(), regionCode, jurisdictionCode, environmentName, @@ -190,6 +186,61 @@ export const KetchServiceProvider: React.FC = ({ onError, }); + const headlessApi = useMemo( + () => new KetchHeadless({ dataCenter: parameters.dataCenter }), + [parameters.dataCenter] + ); + + const getLocation = useCallback( + () => headlessApi.getLocation(), + [headlessApi] + ); + + const getBootstrapConfiguration = useCallback( + () => + headlessApi.getBootstrapConfiguration( + parameters.organizationCode, + parameters.propertyCode + ), + [headlessApi, parameters.organizationCode, parameters.propertyCode] + ); + + const getFullConfiguration = useCallback( + (request: FullConfigurationRequest) => + headlessApi.getFullConfiguration(request), + [headlessApi] + ); + + const fetchConsent = useCallback( + (config: ConsentConfig) => headlessApi.getConsent(config), + [headlessApi] + ); + + const setConsentOnServer = useCallback( + (update: ConsentUpdate) => headlessApi.setConsentOnServer(update), + [headlessApi] + ); + + const invokeRight = useCallback( + (request: InvokeRightRequest) => headlessApi.invokeRight(request), + [headlessApi] + ); + + const getSubscriptions = useCallback( + (request: SubscriptionsRequest) => headlessApi.getSubscriptions(request), + [headlessApi] + ); + + const setSubscriptions = useCallback( + (request: SubscriptionsRequest) => headlessApi.setSubscriptions(request), + [headlessApi] + ); + + const preferenceQRUrl = useCallback( + (request: PreferenceQRRequest) => headlessApi.preferenceQRUrl(request), + [headlessApi] + ); + const webViewParameters = useMemo(() => { const att = parameters.ketchAtt ?? resolvedKetchAtt; const attPrev = parameters.ketchAttPrev ?? resolvedKetchAttPrev; @@ -554,18 +605,47 @@ export const KetchServiceProvider: React.FC = ({ `; // Simply render children if no identities passed as SDK cannot be used + const contextValue = useMemo( + () => ({ + showConsentExperience, + showPreferenceExperience, + dismissExperience, + getConsent, + updateParameters, + load, + setCssOverride, + getLocation, + getBootstrapConfiguration, + getFullConfiguration, + fetchConsent, + setConsentOnServer, + invokeRight, + getSubscriptions, + setSubscriptions, + preferenceQRUrl, + }), + [ + showConsentExperience, + showPreferenceExperience, + dismissExperience, + getConsent, + updateParameters, + load, + setCssOverride, + getLocation, + getBootstrapConfiguration, + getFullConfiguration, + fetchConsent, + setConsentOnServer, + invokeRight, + getSubscriptions, + setSubscriptions, + preferenceQRUrl, + ] + ); + return ( - + {children} {shouldLoadWebView && isAttReady && ( ): string { + return this.client.buildUrl(path, query); + } + + getLocation(): Promise { + return this.client.getLocation(); + } + + getBootstrapConfiguration( + organization: string, + property: string + ): Promise> { + return this.client.getBootstrapConfiguration(organization, property); + } + + getFullConfiguration( + request: FullConfigurationRequest + ): Promise> { + return this.client.getFullConfiguration(request); + } + + getConsent(config: ConsentConfig): Promise { + return this.client.getConsent(config); + } + + setConsentOnServer(update: ConsentUpdate): Promise { + return this.client.setConsentOnServer(update); + } + + invokeRight(request: InvokeRightRequest): Promise { + return this.client.invokeRight(request); + } + + getSubscriptions( + request: SubscriptionsRequest + ): Promise { + return this.client.getSubscriptions(request); + } + + setSubscriptions(request: SubscriptionsRequest): Promise { + return this.client.setSubscriptions(request); + } + + preferenceQRUrl(request: PreferenceQRRequest): string { + return this.client.preferenceQRUrl(request); + } +} diff --git a/package/src/headless/headlessApiClient.ts b/package/src/headless/headlessApiClient.ts new file mode 100644 index 0000000..7231e60 --- /dev/null +++ b/package/src/headless/headlessApiClient.ts @@ -0,0 +1,333 @@ +import { KetchDataCenter, MobileSdkUrlByDataCenterMap } from '../enums'; +import { getDeviceLanguageTag } from '../util/deviceLocale'; +import type { Consent } from '../types'; +import { + consentConfigToJson, + consentUpdateToJson, + HeadlessException, + withoutProtocols, + type ConsentConfig, + type ConsentUpdate, + type FullConfigurationRequest, + type InvokeRightRequest, + type LocationResponse, + type PreferenceQRRequest, + type SubscriptionsRequest, + type SubscriptionsResponse, +} from './headlessTypes'; + +export type FetchFn = typeof fetch; + +/** Native HTTP client mirroring ketch-tag KetchWebAPI (web/v3). */ +export class HeadlessApiClient { + private readonly baseUrl: string; + private readonly fetchFn: FetchFn; + private readonly deviceLanguage: () => string; + + constructor( + options: { + dataCenter?: KetchDataCenter; + baseUrl?: string; + fetchFn?: FetchFn; + deviceLanguage?: () => string; + } = {} + ) { + const dataCenter = options.dataCenter ?? KetchDataCenter.US; + this.baseUrl = options.baseUrl ?? MobileSdkUrlByDataCenterMap[dataCenter]; + this.fetchFn = options.fetchFn ?? fetch; + this.deviceLanguage = options.deviceLanguage ?? getDeviceLanguageTag; + } + + /** Builds an absolute CDN URL for unit tests and debugging. */ + buildUrl(path: string, query?: Record): string { + const normalized = path.startsWith('/') ? path : `/${path}`; + const url = new URL(`${this.baseUrl.replace(/\/+$/, '')}${normalized}`); + if (query) { + Object.entries(query).forEach(([key, value]) => { + url.searchParams.set(key, value); + }); + } + return url.toString(); + } + + /** GeoIP / jurisdiction hint (`GET /ip`). */ + async getLocation(): Promise { + const response = await this.get(this.buildUrl('/ip')); + return this.parseJsonResponse(response, '/ip'); + } + + /** Minimal config (`GET .../boot.json`). */ + async getBootstrapConfiguration( + organization: string, + property: string + ): Promise> { + const response = await this.get( + this.buildUrl(`/config/${organization}/${property}/boot.json`) + ); + return this.parseJsonResponse>( + response, + 'boot.json' + ); + } + + /** Full config with optional env / jurisdiction / language and hash query param. */ + async getFullConfiguration( + request: FullConfigurationRequest + ): Promise> { + let path = `/config/${request.organizationCode}/${request.propertyCode}`; + const isShortPath = !( + request.environmentCode && + request.jurisdictionCode && + request.languageCode + ); + if (!isShortPath) { + path += `/${request.environmentCode}/${request.jurisdictionCode}/${request.languageCode}`; + } + path += '/config.json'; + + const query: Record = {}; + if (isShortPath) { + query.language = request.languageCode || this.deviceLanguage(); + if (request.jurisdictionCode) + query.jurisdiction = request.jurisdictionCode; + if (request.regionCode) query.region = request.regionCode; + } + if (request.hash) query.hash = request.hash; + + // Belt-and-suspenders: the `language` query param is what the server actually reads. + const headers = isShortPath + ? { 'Accept-Language': this.deviceLanguage() } + : undefined; + + const response = await this.get(this.buildUrl(path, query), headers); + return this.parseJsonResponse>(response, path); + } + + /** Server consent including `protocols` (`POST .../consent/{org}/get`). */ + async getConsent(config: ConsentConfig): Promise { + const path = `/consent/${config.organizationCode}/get`; + const response = await this.post(path, consentConfigToJson(config)); + if (!response || response === 'null') { + return emptyConsent(); + } + const json = safeParseConsentJson(response); + if (!json) { + return emptyConsent(); + } + const consent = parseConsent(json); + return hasUsableConsentFields(consent) ? consent : emptyConsent(); + } + + /** Invokes a data subject right (`POST .../rights/{org}/invoke`). */ + async invokeRight(request: InvokeRightRequest): Promise { + const path = `/rights/${request.organizationCode}/invoke`; + await this.postVoid(path, request as unknown as Record); + } + + /** Gets subscription topics/controls (`POST .../subscriptions/{org}/get`). */ + async getSubscriptions( + request: SubscriptionsRequest + ): Promise { + const path = `/subscriptions/${request.organizationCode}/get`; + const response = await this.post( + path, + request as unknown as Record + ); + return this.parseJsonResponse(response, path); + } + + /** Updates subscription topics/controls (`POST .../subscriptions/{org}/update`). */ + async setSubscriptions(request: SubscriptionsRequest): Promise { + const path = `/subscriptions/${request.organizationCode}/update`; + await this.postVoid(path, request as unknown as Record); + } + + /** Builds preferences QR image URL (no HTTP). */ + preferenceQRUrl(request: PreferenceQRRequest): string { + const query: Record = {}; + if (request.environmentCode) { + query.env = request.environmentCode; + } + if (request.imageSize != null) { + query.size = String(request.imageSize); + } + if (request.path) { + query.path = request.path; + } + if (request.backgroundColor) { + query.bgcolor = request.backgroundColor; + } + if (request.foregroundColor) { + query.fgcolor = request.foregroundColor; + } + if (request.parameters) { + Object.assign(query, request.parameters); + } + return this.buildUrl( + `/qr/${request.organizationCode}/${request.propertyCode}/preferences.png`, + Object.keys(query).length > 0 ? query : undefined + ); + } + + /** Updates consent; returns server response with computed `protocols`. */ + async setConsentOnServer(update: ConsentUpdate): Promise { + const path = `/consent/${update.organizationCode}/update`; + const response = await this.post( + path, + consentUpdateToJson(withoutProtocols(update)) + ); + if (!response || response === 'null') { + return consentFromUpdate(update); + } + const json = safeParseConsentJson(response); + if (!json) { + return consentFromUpdate(update); + } + const consent = parseConsent(json); + return hasUsableConsentFields(consent) + ? consent + : consentFromUpdate(update); + } + + private parseJsonResponse(body: string, context: string): T { + const trimmed = body?.trim(); + if (!trimmed || trimmed === 'null') { + throw new HeadlessException(`Empty response for ${context}`); + } + try { + return JSON.parse(trimmed) as T; + } catch (error) { + throw new HeadlessException(`Invalid JSON for ${context}`, error); + } + } + + private async get( + url: string, + headers?: Record + ): Promise { + try { + const response = await this.fetchFn(url, { + method: 'GET', + headers: { Accept: 'application/json', ...headers }, + }); + if (!response.ok) { + throw new HeadlessException(`HTTP ${response.status} for ${url}`); + } + return response.text(); + } catch (error) { + if (error instanceof HeadlessException) { + throw error; + } + throw new HeadlessException(`Request failed for ${url}`, error); + } + } + + private async postVoid( + path: string, + body: Record + ): Promise { + const url = this.buildUrl(path); + try { + const response = await this.fetchFn(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new HeadlessException(`HTTP ${response.status} for ${url}`); + } + } catch (error) { + if (error instanceof HeadlessException) { + throw error; + } + throw new HeadlessException(`Request failed for ${url}`, error); + } + } + + private async post( + path: string, + body: Record + ): Promise { + const url = this.buildUrl(path); + try { + const response = await this.fetchFn(url, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new HeadlessException(`HTTP ${response.status} for ${url}`); + } + return response.text(); + } catch (error) { + if (error instanceof HeadlessException) { + throw error; + } + throw new HeadlessException(`Request failed for ${url}`, error); + } + } +} + +function safeParseConsentJson( + response: string +): Record | null { + const trimmed = response?.trim(); + if (!trimmed || trimmed === 'null') { + return null; + } + try { + const json = JSON.parse(trimmed); + return typeof json === 'object' && json !== null + ? (json as Record) + : null; + } catch { + return null; + } +} + +function emptyConsent(): Consent { + return { purposes: {} }; +} + +function hasUsableConsentFields(consent: Consent): boolean { + const hasPurposes = + consent.purposes != null && Object.keys(consent.purposes).length > 0; + const hasProtocols = + consent.protocols != null && Object.keys(consent.protocols).length > 0; + return hasPurposes || hasProtocols; +} + +function parseConsent(json: Record): Consent { + const purposes = + json.purposes && typeof json.purposes === 'object' + ? (json.purposes as Record) + : undefined; + const vendors = Array.isArray(json.vendors) + ? (json.vendors as string[]) + : undefined; + const protocols = + json.protocols && typeof json.protocols === 'object' + ? (json.protocols as Record) + : undefined; + return { purposes, vendors, protocols }; +} + +function consentFromUpdate(update: ConsentUpdate): Consent { + const purposes = Object.fromEntries( + Object.entries(update.purposes).map(([key, value]) => [ + key, + value.allowed.toLowerCase() === 'true', + ]) + ); + return { + purposes, + vendors: update.vendors, + protocols: {}, + }; +} diff --git a/package/src/headless/headlessTypes.ts b/package/src/headless/headlessTypes.ts new file mode 100644 index 0000000..cf75c76 --- /dev/null +++ b/package/src/headless/headlessTypes.ts @@ -0,0 +1,172 @@ +/** GeoIP details from `GET /ip` (ketch-types `IPInfo`). */ +export interface IPInfo { + ip?: string; + hostname?: string; + continentCode?: string; + continentName?: string; + countryCode?: string; + countryName?: string; + regionCode?: string; + regionName?: string; + city?: string; + postalCode?: string; + timezone?: string; +} + +/** Response from headless `getLocation()`. */ +export interface LocationResponse { + location?: IPInfo; +} + +/** Parameters for v3 `getFullConfiguration`. */ +export interface FullConfigurationRequest { + organizationCode: string; + propertyCode: string; + environmentCode?: string; + jurisdictionCode?: string; + languageCode?: string; + hash?: string; + regionCode?: string; +} + +export interface PurposeLegalBasis { + legalBasisCode: string; +} + +/** Request body for `POST /consent/{org}/get`. */ +export interface ConsentConfig { + organizationCode: string; + propertyCode: string; + environmentCode: string; + jurisdictionCode: string; + identities: Record; + purposes: Record; +} + +export enum MigrationOption { + MIGRATE_DEFAULT = 'MIGRATE_DEFAULT', + MIGRATE_NEVER = 'MIGRATE_NEVER', + MIGRATE_FROM_ALLOW = 'MIGRATE_FROM_ALLOW', + MIGRATE_FROM_DENY = 'MIGRATE_FROM_DENY', + MIGRATE_ALWAYS = 'MIGRATE_ALWAYS', +} + +export interface PurposeAllowedLegalBasis { + allowed: string; + legalBasisCode: string; +} + +/** Request body for `POST /consent/{org}/update`. */ +export interface ConsentUpdate { + organizationCode: string; + propertyCode: string; + environmentCode: string; + identities: Record; + jurisdictionCode: string; + migrationOption: MigrationOption; + purposes: Record; + vendors?: string[]; + protocols?: Record; +} + +export class HeadlessException extends Error { + constructor( + message: string, + readonly cause?: unknown + ) { + super(message); + this.name = 'HeadlessException'; + } +} + +export function consentConfigToJson( + config: ConsentConfig +): Record { + return { + organizationCode: config.organizationCode, + propertyCode: config.propertyCode, + environmentCode: config.environmentCode, + jurisdictionCode: config.jurisdictionCode, + identities: config.identities, + purposes: config.purposes, + }; +} + +export function consentUpdateToJson( + update: ConsentUpdate +): Record { + return { + organizationCode: update.organizationCode, + propertyCode: update.propertyCode, + environmentCode: update.environmentCode, + identities: update.identities, + jurisdictionCode: update.jurisdictionCode, + migrationOption: update.migrationOption, + purposes: update.purposes, + ...(update.vendors != null ? { vendors: update.vendors } : {}), + }; +} + +export function withoutProtocols(update: ConsentUpdate): ConsentUpdate { + const rest = { ...update }; + delete rest.protocols; + return rest; +} + +/** ketch-types `DataSubject` */ +export interface DataSubject { + email: string; + firstName: string; + lastName: string; + country?: string; + stateRegion?: string; + city?: string; + description?: string; + phone?: string; + postalCode?: string; + addressLine1?: string; + addressLine2?: string; +} + +/** ketch-types `InvokeRightRequest` */ +export interface InvokeRightRequest { + organizationCode: string; + propertyCode: string; + environmentCode: string; + identities: Record; + jurisdictionCode: string; + rightCode: string; + user: DataSubject; + controllerCode?: string; + invokedAt?: number; + recaptchaToken?: string; + regionCode?: string; + isAuthenticated?: boolean; +} + +/** ketch-types `GetSubscriptionsRequest` / `SetSubscriptionsRequest` */ +export interface SubscriptionsRequest { + organizationCode: string; + controllerCode?: string; + propertyCode?: string; + environmentCode?: string; + identities?: Record; + topics?: Record>; + controls?: Record>; + collectedAt?: number; + jurisdictionCode?: string; + regionCode?: string; +} + +export type SubscriptionsResponse = SubscriptionsRequest; + +export interface PreferenceQRRequest { + organizationCode: string; + propertyCode: string; + environmentCode?: string; + imageSize?: number; + path?: string; + backgroundColor?: string; + foregroundColor?: string; + parameters?: Record; +} diff --git a/package/src/headless/index.ts b/package/src/headless/index.ts new file mode 100644 index 0000000..e4568da --- /dev/null +++ b/package/src/headless/index.ts @@ -0,0 +1,21 @@ +export { KetchHeadless, type KetchHeadlessOptions } from './KetchHeadless'; +export { HeadlessApiClient, type FetchFn } from './headlessApiClient'; +export { + HeadlessException, + MigrationOption, + consentConfigToJson, + consentUpdateToJson, + withoutProtocols, + type ConsentConfig, + type ConsentUpdate, + type DataSubject, + type FullConfigurationRequest, + type InvokeRightRequest, + type IPInfo, + type LocationResponse, + type PreferenceQRRequest, + type PurposeAllowedLegalBasis, + type PurposeLegalBasis, + type SubscriptionsRequest, + type SubscriptionsResponse, +} from './headlessTypes'; diff --git a/package/src/index.ts b/package/src/index.ts index cbd2a2b..6ca0037 100644 --- a/package/src/index.ts +++ b/package/src/index.ts @@ -1,5 +1,6 @@ export * from './context'; export * from './enums'; +export * from './headless'; export * from './KetchServiceProvider'; export * from './types'; export * from './util'; diff --git a/package/src/types/index.ts b/package/src/types/index.ts index 25377bb..5cd8da4 100644 --- a/package/src/types/index.ts +++ b/package/src/types/index.ts @@ -6,6 +6,16 @@ import { type PreferenceTab, type PrivacyProtocol, } from '../enums'; +import type { + ConsentConfig, + ConsentUpdate, + FullConfigurationRequest, + InvokeRightRequest, + LocationResponse, + PreferenceQRRequest, + SubscriptionsRequest, + SubscriptionsResponse, +} from '../headless/headlessTypes'; /** * Consent object @@ -281,4 +291,35 @@ export interface KetchService { * Will ignore if string contains any HTML tags or exceeds 1kb. */ setCssOverride?: (css: string) => void; + + /** GeoIP / jurisdiction hint (`GET /ip`). Pre-WebView headless API. */ + getLocation?: () => Promise; + + /** Minimal config (`GET .../boot.json`). */ + getBootstrapConfiguration?: () => Promise>; + + /** Full config with optional env / jurisdiction / language and hash query param. */ + getFullConfiguration?: ( + request: FullConfigurationRequest + ) => Promise>; + + /** Server consent including `protocols`. Does not read WebView cache — use [getConsent]. */ + fetchConsent?: (config: ConsentConfig) => Promise; + + /** Updates consent on the CDN; returns server-computed `protocols`. */ + setConsentOnServer?: (update: ConsentUpdate) => Promise; + + /** Invokes a data subject right (`POST .../rights/{org}/invoke`). */ + invokeRight?: (request: InvokeRightRequest) => Promise; + + /** Gets subscription topics/controls (`POST .../subscriptions/{org}/get`). */ + getSubscriptions?: ( + request: SubscriptionsRequest + ) => Promise; + + /** Updates subscription topics/controls (`POST .../subscriptions/{org}/update`). */ + setSubscriptions?: (request: SubscriptionsRequest) => Promise; + + /** Builds preferences QR image URL (no HTTP). */ + preferenceQRUrl?: (request: PreferenceQRRequest) => string; } diff --git a/package/src/util/deviceLocale.ts b/package/src/util/deviceLocale.ts new file mode 100644 index 0000000..a6e5657 --- /dev/null +++ b/package/src/util/deviceLocale.ts @@ -0,0 +1,23 @@ +import { Platform, NativeModules } from 'react-native'; + +/** Raw device locale identifier (e.g. "en_US" on Android, "en-US"/"en_US" on iOS). */ +export function getDeviceLanguage(): string { + return Platform.OS === 'ios' + ? NativeModules.SettingsManager?.settings?.AppleLocale || + NativeModules.SettingsManager?.settings?.AppleLanguages?.[0] || + 'en' + : NativeModules.I18nManager?.localeIdentifier || 'en'; +} + +/** Matches ketch-tag's `formatLanguage` ("fr-CA"), tolerant of the "fr_CA" form. */ +export function formatLanguageTag(raw: string): string { + if (!raw) return 'en'; + const [root, dialect] = raw.split(/[-_]/); + return dialect + ? `${root!.toLowerCase()}-${dialect.toUpperCase()}` + : root!.toLowerCase(); +} + +export function getDeviceLanguageTag(): string { + return formatLanguageTag(getDeviceLanguage()); +} diff --git a/package/src/util/index.ts b/package/src/util/index.ts index abfa42c..f31fe91 100644 --- a/package/src/util/index.ts +++ b/package/src/util/index.ts @@ -1,5 +1,6 @@ export * from './helpers'; export * from './services'; export * from './hooks'; +export * from './deviceLocale'; export { default as wrapSharedPreferences } from './wrapSharedPrefences'; export { default as nativeStorage } from './nativeStorage';