Skip to content

feat: add headless CDN API client and provider methods - #126

Closed
ethanpschoen wants to merge 16 commits into
mainfrom
ethanpschoen/feat/headless-api
Closed

feat: add headless CDN API client and provider methods#126
ethanpschoen wants to merge 16 commits into
mainfrom
ethanpschoen/feat/headless-api

Conversation

@ethanpschoen

@ethanpschoen ethanpschoen commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Description of this change

Adds the pre-WebView headless CDN API and brings the React Native surface in line with ketch-android.

  • KetchHeadless / HeadlessApiClient (web/v3), surfaced through KetchServiceProvider and useKetchService().
  • trigger() for onFunction rules. Calls made before the tag has loaded its config are queued one deep and dispatched on onConfigLoaded.
  • getRegion() / getJurisdiction() — preferring a locally set value over a network lookup, with the GeoIP result cached per provider. getLocation() is no longer on the service context.
  • Privacy string accessors getSavedString / getTCFTCString / getUSPrivacyString / getGPPHDRGppString, plus the IAB key constants.
  • onWillShowExperience callback, ketchMobileSdkUrl to override the CDN base URL, and the TriggerName / WillShowExperienceType / OnHideExperienceArgument.none enum values.

Fix: a consent response containing only vendors was discarded and replaced with an empty consent. An unrecognized hide reason now maps to none instead of passing through a raw string the parameter type claimed was an enum member.

Why is this change being made?

  • Chore (non-functional changes)
  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

How was this tested? How can the reviewer verify your testing?

  • cd package && npm run typecheck && npm run lint && npm test — 59 tests, 0 failures.
  • Optional: KETCH_INTEGRATION_TESTS=1 npm run test:integration.

Note for reviewers: CI does not run the test suite — npm run all covers lint, typecheck, and build only. Tests need running locally.

Related issues

KD-17456

Checklist

  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • I have evaluated the security impact of this change, and OWASP Secure Coding Practices have been observed.
  • I have informed stakeholders of my changes.

Note

Medium Risk
Touches consent fetch/update, WebView JS injection for triggers, and new pre-WebView network paths that affect jurisdiction/region resolution; mitigated by validation, tests, and conservative consent parsing fallbacks.

Overview
Adds a fetch-based headless web/v3 layer (KetchHeadless / HeadlessApiClient) and wires it through KetchServiceProvider / useKetchService() so apps can call GeoIP, bootstrap/full config, server consent, rights, subscriptions, and preference QR URLs without relying on the WebView.

trigger() runs onFunction rules via injected ketch("trigger", …) JS, with one-deep queuing until onConfigLoaded (not merely first WebView message). Function names are validated to block injection into the quoted literal.

New service surface includes getRegion / getJurisdiction (local params beat network; GeoIP cached per provider), IAB privacy string readers from native storage, onWillShowExperience, and ketchMobileSdkUrl overriding the CDN base for both WebView init params and headless calls. Device locale is centralized via getDeviceLanguage / formatLanguageTag for config requests.

Fixes: headless getConsent now keeps vendors-only responses; onHideExperience maps unknown reasons to none instead of raw strings.

Broad unit tests plus optional test:integration against live CDN.

Reviewed by Cursor Bugbot for commit 381f86c. Configure here.

Expose pre-WebView headless endpoints via KetchHeadless, wire them through
KetchServiceProvider, and harden JSON/network error handling in the client.
When jurisdiction/environment aren't fully set, getFullConfiguration()
falls back to the short config.json path, which previously carried no
language hint. Now the short path always sends a language query param:
the explicit languageCode if set, otherwise the device locale
(formatted to match ketch-tag's formatLanguage convention), plus an
Accept-Language header. jurisdiction/region are forwarded when
explicitly set. The full static path is unchanged.

Device-locale detection is extracted from KetchServiceProvider into a
shared util so both the WebView and headless paths use it.
hasUsableConsentFields checked purposes and protocols but not vendors, so a
consent response carrying only vendors was discarded and replaced with an empty
consent. The test asserted this behaviour, so it is updated too.

Also applies pending prettier formatting in the same test file, which was failing
lint on this branch.
trigger() fires `ketch('trigger', 'custom', fn, options)` on the booted tag.
Calls made before the tag is ready are queued one deep and dispatched on the
onConfigLoaded event rather than on isServiceReady, which flips on the first
message of any kind and so can fire before the tag can accept calls. A later call
supersedes an earlier queued one, and a WebView remount drops it.

Returns false for an invalid function name or while an experience is showing.
Function names are validated against the same pattern ketch-tag uses, which is the
only guard against breaking out of the quoted JS literal.

onWillShowExperience surfaces an event that was previously handled internally only.
Adds OnHideExperienceArgument.none, WillShowExperienceType, and TriggerName; an
unrecognized hide reason now reports none instead of passing through a raw string
that the parameter type claimed was an enum member.
…add ketchMobileSdkUrl

getRegion prefers a locally set regionCode and otherwise resolves a GeoIP lookup,
cached for the lifetime of the provider. getJurisdiction prefers a locally set
jurisdictionCode and otherwise reads the resolved code from the CDN configuration.
Both mirror the Android SDK. getLocation is no longer on the service context; it
remains on KetchHeadless as the low-level client call.

ketchMobileSdkUrl overrides the CDN base URL for both the WebView and the headless
client, which previously derived it from dataCenter alone. It is applied after the
parameter loop so it wins regardless of key iteration order.
The IAB strings the tag persists could only be read by calling nativeStorage.read
with key names the package did not export. Adds the key constants and
getSavedString / getTCFTCString / getUSPrivacyString / getGPPHDRGppString to the
service context, matching the Android and Flutter SDKs.

These return promises rather than values, unlike the synchronous Android
equivalents, because native storage access is asynchronous here.
@ethanpschoen

Copy link
Copy Markdown
Contributor Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 381f86c. Configure here.

useEffect(() => {
isConfigLoadedRef.current = false;
pendingTriggerRef.current = null;
}, [webViewMountKey]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mount effect drops queued trigger

High Severity

The webViewMountKey's useEffect clears pendingTriggerRef on mount and key changes. This can drop a successfully queued trigger() call before the WebView's onConfigLoaded event can process it, preventing the trigger from firing.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 381f86c. Configure here.

propertyCode,
identities,
languageCode = deviceLanguage,
languageCode = getDeviceLanguage(),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Raw locale breaks headless language

Medium Severity

The languageCode defaults to the raw device language (e.g., en_US on Android) and is passed unformatted to getFullConfiguration by getJurisdiction. The headless client's BCP-47 formatting logic is bypassed when languageCode is explicitly provided, causing the CDN to receive an incorrect language tag.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 381f86c. Configure here.

if (request.jurisdictionCode)
query.jurisdiction = request.jurisdictionCode;
if (request.regionCode) query.region = request.regionCode;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Environment dropped without language

Medium Severity

getFullConfiguration only uses the full path when environmentCode, jurisdictionCode, and languageCode are all truthy. If environmentCode and jurisdictionCode are set but languageCode is omitted, the client takes the short path and never sends environmentCode (it is not a short-path query param), even though short path already defaults language from the device. Staging/production selection is silently ignored in that case.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 381f86c. Configure here.

The README covered install and provider setup only. Documents the experience
methods, rule triggers, the consent and privacy string getters, and the provider
props beyond the required codes.
getLocation was gone from the useKetchService() context but still
reachable via new HeadlessApiClient().getLocation(), since the client
class was exported from the package barrel. Neither symbol has shipped
in a released tag.

Stops exporting HeadlessApiClient from headless/index.ts. KetchHeadless
gains getRegion(), which performs its own cached GeoIP lookup
internally instead of exposing getLocation() at all - mirroring
Android's already-internal HeadlessApiClient.getLocation. The
provider's getRegion now delegates to it instead of managing its own
location cache.
These were optional only because ketchServiceContext.ts's default
object never declared them, even though KetchServiceProvider already
supplies all three unconditionally in its context value. Making them
required is what actually gives RN's headless surface the same
always-there availability Android/iOS have.
@ethanpschoen

Copy link
Copy Markdown
Contributor Author

Superseded by a stack of 5 PRs: #137#138#139#140#141. Converting to draft; kept open to preserve review history. Will close when the stack lands.

All 3 unresolved review threads on this PR are addressed in the stack:

@ethanpschoen
ethanpschoen marked this pull request as draft August 6, 2026 20:59
ethanpschoen added a commit that referenced this pull request Aug 6, 2026
getSavedString and the IAB helpers always read through nativeStorage.read, so they never saw values persisted via a custom preferenceStorage backend. That backend is required for Expo Android in the README, and on that setup crossPlatformRead falls back to React Native Settings, which is a no-op on Android — so these accessors returned empty strings after consent updates whenever preferenceStorage was configured.

This PR adds the capability (an optional reader parameter, defaulting to the existing nativeStorage.read). The provider wiring that actually builds a reader from the configured preferenceStorage and binds these accessors to it lands in #141, alongside the rest of the KetchService exposure.

Refs #126.
ethanpschoen added a commit that referenced this pull request Aug 6, 2026
Adds an imperative trigger() method that fires an onFunction rule trigger and queues until the tag's config has loaded, plus enum values for normalizing the hide/show experience callbacks. onHideExperience now receives a normalized enum, falling back to none, instead of the raw tag payload.

Also carries the ketch_mobilesdk_url URL-param override, which lives in the same helpers.ts region as the new trigger helpers.

Part 3/5 of the split of #126.
ethanpschoen added a commit that referenced this pull request Aug 6, 2026
The webViewMountKey reset effect nulled the pending trigger on every mount-key change, including the async ATT-driven change that always fires once on iOS before the WebView's first mount. trigger() had already returned true by then, so the call was silently swallowed instead of firing once the tag booted.

Refs #126.
ethanpschoen added a commit that referenced this pull request Aug 6, 2026
getDeviceLanguage() returns the raw platform identifier, underscore-separated on Android (en_US) and on iOS whenever AppleLocale is used. Every default-configured install therefore sent language=en_US to both the headless CDN client and the WebView, since the headless client's own BCP-47 formatting is only reached when languageCode is falsy — and the provider always supplied one.

Refs #126.
ethanpschoen added a commit that referenced this pull request Aug 6, 2026
Adds a standalone client for the config/consent/rights/subscriptions CDN endpoints, independent of the WebView-hosted tag.

Part 4/5 of the split of #126.
ethanpschoen added a commit that referenced this pull request Aug 6, 2026
…onfig requests

The long config path (which carries environment) was only taken when environment, jurisdiction, and language were all present. A caller who set environment and jurisdiction but not language fell to the short path, which carries no environment at all, so a staging-configured app with a jurisdiction set silently received the default environment's config.

Synthesize language from the device locale whenever environment and jurisdiction are both set, so the long path is always used in that case. Not adding ?environment= to the short path — nothing in ketch-tag suggests the CDN reads it.

Refs #126.
ethanpschoen added a commit that referenced this pull request Aug 6, 2026
parseConsent() left purposes undefined whenever a server response omitted it, while emptyConsent() returns { purposes: {} }. A vendors-only or protocols-only response therefore returned an inconsistent shape, and a caller doing Object.keys(consent.purposes) on it would throw.

Refs #126.
ethanpschoen added a commit that referenced this pull request Aug 6, 2026
Wires the headless CDN client into the provider: getRegion, getJurisdiction, getBootstrapConfiguration, getFullConfiguration, fetchConsent, setConsentOnServer, invokeRight, getSubscriptions, setSubscriptions, and preferenceQRUrl are now exposed via useKetchService(), alongside a ketchMobileSdkUrl override that takes precedence over the data-center-implied CDN URL for both the WebView and the headless client.

Breaking change: getRegion, getJurisdiction, and fetchConsent are now required on the KetchService interface, not optional. Any external implementer of KetchService must add them.

Part 5/5 of the split of #126.
ethanpschoen added a commit that referenced this pull request Aug 6, 2026
getSavedString and the IAB helpers accepted an optional reader (previous PR in this stack) but nothing in the provider ever passed one, so they always read via nativeStorage regardless of preferenceStorage. Writes went through preferenceStorage when configured; reads did not, so privacy strings written to a custom backend (the README's own recommendation for Expo Android) were invisible to these accessors.

Adds a read counterpart to preferenceStorage (SharedPrefencesInterface.getItemAsync, optional) and a readPreference builder mirroring the existing storePreference one, then binds the four accessors to it before exposing them via contextValue.

Refs #126.
@ethanpschoen ethanpschoen reopened this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant