@privacysuraksha/react-native-consent
v0.2.0
Published
The bare React Native / Expo shell for the Compliant DPDP consent notice. It hosts the same embedded web core (`packages/banner`) that `mobile/ios` and `mobile/android` host natively, inside a real `react-native-webview`.
Readme
@privacysuraksha/react-native-consent
The bare React Native / Expo shell for the Compliant DPDP consent notice.
It hosts the same embedded web core (packages/banner) that mobile/ios
and mobile/android host natively, inside a real react-native-webview.
Every snippet below is copied verbatim from
example/App.tsx, which is a real RN app this package
is wired into with "@privacysuraksha/react-native-consent": "file:.." — nothing
here is untested prose.
Install
npm install @privacysuraksha/react-native-consent react-native-webview
# and ONE of:
npm install react-native-keychain @react-native-async-storage/async-storage # bare RN
npm install expo-secure-store @react-native-async-storage/async-storage # Exporeact-native-webview, react-native-keychain, expo-secure-store and
@react-native-async-storage/async-storage are peer dependencies — this
package installs no native code of its own, so there is nothing to link and
nothing to conflict with a version a customer already has in their tree.
Bundle size: the published dist/index.js is ~581 KB. That is not a
mistake — scripts/build.mjs bundles both the embedded web core (spec §4.2)
and all 23 locale catalogs (spec §4.6) into this one file at publish time
(spec §4.8), rather than shipping them as separate assets. The catalogs still
parse lazily at runtime, only the requested locale ever gets JSON-parsed
(assets.test.ts asserts this), so this size does not translate one-to-one
into launch-time cost — but it is the real number a bundle-size budget should
account for, not the web core's own ~46 KB.
Integration
import { CompliantConsentProvider } from '@privacysuraksha/react-native-consent';
import { keychainStorage } from '@privacysuraksha/react-native-consent/adapters/keychain';
function App() {
return (
<CompliantConsentProvider
config={{
siteKey: 'site_demo_0000000000000000',
apiBase: 'https://api.privacysuraksha.com',
// Optional. Present here only so the example exercises the
// header this SDK attaches to every httpRequest for an
// app-sourced consent record (EMBEDDED.md §5 gap 2, ledger row
// 384). Omit entirely for a site with no registered mobile app.
appKey: 'app_00000000000000000000000000000000',
storage: keychainStorage(),
}}
>
<AppContent />
</CompliantConsentProvider>
);
}That is the whole integration: mount CompliantConsentProvider once, near
the app's root, and supply a storage adapter. It shows the notice itself
when one is due; there is nothing else to call to make that happen. Call
useCompliantConsent().show('prefs') from anywhere inside the
provider to open the preferences view on demand (a "Manage consent" menu
item, for example).
Pass a stable config object. config is read once, at mount — a
changed config prop is intentionally ignored afterward (spec §9 risk 5),
so the notice does not re-fetch or re-render mid-decision because a parent
re-rendered. Pass a useMemo-wrapped object or a module-level constant, not
an inline object literal — an inline literal is a new reference on every
render, which is harmless here only because the effect that reads it never
re-runs, but it invites the same trap the next time this file is edited.
Where the keys come from
siteKey— dashboard → your site. It is public; it is already in your website's page source.appKey— dashboard → your site → Mobile apps → register this app by its bundle identifier (iOS build) or application id (Android build). You are shown the key once.
If your site has registered no apps, omit appKey and everything works.
The moment you register one, consent requests from apps must carry a valid
key — including from a build you shipped before registering, so
register before you release. See "The app key is extractable from the JS
bundle" below for what this key does and does not prove.
Storage adapters — one is mandatory
CompliantConsentProvider throws (through config.onError, not a render
crash) if config.storage is missing. There is no unencrypted fallback.
The visitor id this package generates is the only thing standing between a
consent record and an anonymous one, and it must survive an app restart in
storage a customer's own device backup does not silently exfiltrate.
Two adapters ship, and the split between them is deliberate:
| Export | Backing store | Use for |
|---|---|---|
| keychainStorage() from /adapters/keychain | react-native-keychain (iOS Keychain / Android Keystore) + @react-native-async-storage/async-storage | bare React Native apps |
| expoStorage() from /adapters/expo | expo-secure-store + @react-native-async-storage/async-storage | Expo apps |
Both adapters expose two surfaces under the hood, matching mobile/ios
exactly:
secureGet/secureSet— the Keychain/Keystore/SecureStore. Survives app uninstall. Holds the visitor id only.get/set/delete— plainAsyncStorage. Cleared on uninstall. Holds prefs, language, and the cached config.
That split is why a reinstall shows the notice again (see below) — Keychain survival across reinstall would resurrect a decision the visitor believed they had removed, so this package never puts the decision there, only the id.
Neither adapter raises a biometric prompt. Both pin
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY accessibility (readable at launch
after the first unlock, never migrated to a new device via backup) and, on
the bare-RN adapter, STORAGE_TYPE.AES_GCM_NO_AUTH specifically because
react-native-keychain's default cipher selection on Android can gate on
biometrics — a consent banner that raises a fingerprint prompt at launch
would be an absurd failure mode. mobile/react-native/maestro/transport.yaml
relaunches the app after accepting and asserts the notice does not
reappear, which is the one signal that would actually catch a regression
here; no unit test running against in-memory storage ever would.
Writing your own adapter
Any object implementing CompliantStorage from
@privacysuraksha/react-native-consent works — the two shipped adapters are a
convenience, not a requirement:
export interface CompliantStorage {
secureGet(key: string): Promise<string | null>;
secureSet(key: string, value: string): Promise<void>;
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
delete(key: string): Promise<void>;
}Consent Mode v2 (signalWriter)
This SDK ships no Firebase dependency of its own — if a customer's app
already uses @react-native-firebase/analytics for Google Consent Mode v2,
this SDK's dependency would only fight it. Instead, supply a callback:
<CompliantConsentProvider
config={{
// ...
signalWriter: (signals) => {
// Swap for `analytics().setConsent(signals)` once
// @react-native-firebase/analytics is installed — the shape matches
// that call's argument exactly: ad_storage, analytics_storage,
// ad_user_data, ad_personalization, security_storage,
// functionality_storage and personalization_storage, each
// 'granted' | 'denied'.
console.log('[compliant] consent-mode signals', signals);
},
}}
>A signalWriter that throws never costs the consent record — the record is
already persisted before this callback runs, and CompliantConsentProvider
swallows and reports the throw through onError rather than propagating it.
The provider must never be conditionally unmounted
Do not write {showConsent && <CompliantConsentProvider>...}. Mount it once
and leave it mounted for the life of the app.
React Native's idiomatic reflex for "hide this" is to stop rendering it, and
that reflex is wrong here. A successful consent POST fires a second
persist/decision pair carrying the server's recordId and atServer, and it
can arrive well after the notice has already visually hidden. A provider
that unmounts on hide passes an eleven-item conformance suite and silently
drops those two fields — they never reach the API a second time, and the
record it produced is missing the fields renewal detection depends on.
Internally the provider hides the WebView behind an always-mounted overlay
View (opacity/pointerEvents), never a real RN Modal (which unmounts its
children on hide), for exactly this reason.
The app key is extractable from the JS bundle
config.appKey, if supplied, is sent as X-Compliant-App on every
httpRequest this SDK makes, and is issued once per registered app from
the dashboard (Site → Mobile apps). It authenticates a POST as coming from a
registered app surface for that site — not from a verified genuine
install of that app. The key lives in the app binary (this SDK's own
bundle, or the app's) and can be extracted from it by anyone who
decompiles the APK/IPA or dumps the JS bundle; it is not a secret in the
cryptographic sense. State that weaker claim in customer-facing material,
never the stronger one. Device attestation (Apple App Attest, Google Play
Integrity) would close that gap and is tracked separately
(docs/superpowers/backlog/dpdp-ledger.md row 399) — not built by this
package.
Reinstalling the app shows the notice again
The visitor id this SDK generates is a fresh v4 UUID created on first launch
after install and stored only in the Keychain/Keystore/SecureStore surface
(secureGet/secureSet), never in a backed-up location and never derived
from IDFA/AAID. An uninstall clears that store, so a reinstall gets a new
id and no prior decision to read — the notice shows again. This is
correct behaviour, not a bug: it mirrors what a browser does when a
visitor clears cookies, and avoiding it would mean either using an
advertising identifier (which would make the consent record itself a
tracking identifier) or storing the decision somewhere that survives an
uninstall the visitor initiated on purpose.
No figure here was measured on a physical device
Every latency number this package's tests or this README could quote —
timeToReady, timeToPaint, cold-WebView-boot cost — comes from a
Chromium harness (tier 1) or from maestro/transport.yaml running against
an iOS Simulator or an Android Emulator (tier 2), both of which run
on the host machine's own CPU and are optimistic by construction. Neither
this package nor mobile/ios/mobile/android has ever reported a number
produced on real hardware. Re-running on physical devices — including one
low-end Android phone, since Android's per-process WebView boot cost varies
with hardware in a way a simulator or an emulator does not — is tracked
separately (docs/superpowers/backlog/dpdp-ledger.md row 396) and blocks
customer ship, not this package landing. Do not quote anything from this
package as a device SLA.
Testing strategy
- Tier 1 (
npx vitest run --project mobile-react-native) — unit and component tests against a real Chromium instance (Playwright), covering everything except what only a realreact-native-webviewcan prove. - Tier 2 (
maestro test mobile/react-native/maestro/transport.yaml, run againstexample/) — four assertions on a real device/simulator/ emulator: the inlined HTML paints in a real WebView,injectJavaScriptdeliveredinit,onMessagecarried the decision back, and the Keychain adapter round-tripped a visitor id with no biometric prompt. This is the only layer that exercises a genuinereact-native-webviewrather than Chromium — the Android shell's own plan found two product bugs invisible to all 117 of its unit/instrumented tests, because nothing in that module consumed it the way a customer does.example/exists so this package has the same guarantee.
Publishing
This is the first package in this monorepo published as an npm tarball
(docs/superpowers/backlog/dpdp-ledger.md row 397) rather than consumed by
git tag or Gradle composite build. npm pack -w @privacysuraksha/react-native-consent
produces a tarball whose dist/ is bundled with esbuild
(scripts/build.mjs) so that @compliant/banner's private, unpublished
TypeScript sources are inlined rather than left as an unresolvable
import — CI's packed-tarball job extracts the packed tarball and
greps dist/ to enforce this on every change.
