From e834167f816c2954cd952df41370539fda4a0ca4 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Mon, 22 Jun 2026 18:21:47 -0500 Subject: [PATCH] Integrate on-device age verification into access decision Wire the expo-age-range device signals (already plumbed) into the age assurance access decision, gated per-region. - Model verificationMethods on region config (AgeAssuranceConfigRegion) and add a debug US/TX region permitting the 'device' method - Add region accessors + getAssuredAgeFromDeviceSignals in util - Surface deviceSignals through the AA context and feed lowerBound in as assuredAge, matching the existing IfAssuredOverAge rules - NoAccessScreen "Verify now" now prompts the OS in device regions and falls back to the KWS dialog when unavailable/insufficient - Keep useDeviceSignalsQuery disabled so we never prompt on load; restore from the persisted cache only Co-Authored-By: Claude Opus 4.8 --- .../components/NoAccessScreen.tsx | 67 ++++++++---- src/ageAssurance/data.tsx | 101 +++++++++--------- src/ageAssurance/debug.ts | 36 ++++++- src/ageAssurance/state.ts | 19 +++- src/ageAssurance/types.ts | 27 ++++- src/ageAssurance/util.ts | 45 ++++++++ 6 files changed, 221 insertions(+), 74 deletions(-) diff --git a/src/ageAssurance/components/NoAccessScreen.tsx b/src/ageAssurance/components/NoAccessScreen.tsx index b2c220104b..f5b050d408 100644 --- a/src/ageAssurance/components/NoAccessScreen.tsx +++ b/src/ageAssurance/components/NoAccessScreen.tsx @@ -4,7 +4,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' -import * as AgeRange from 'expo-age-range' import { SupportCode, @@ -12,7 +11,7 @@ import { } from '#/lib/hooks/useCreateSupportLink' import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' -import {useSessionApi} from '#/state/session' +import {useSession, useSessionApi} from '#/state/session' import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog' import {DeleteAccountDialog} from '#/screens/Settings/components/DeleteAccountDialog' import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' @@ -33,10 +32,17 @@ import * as Toast from '#/components/Toast' import {Text} from '#/components/Typography' import {BottomSheetOutlet} from '#/../modules/bottom-sheet' import {useAgeAssurance} from '#/ageAssurance' -import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data' +import { + getDeviceSignals, + setDeviceSignalsForDid, + useAgeAssuranceServerDataContext, +} from '#/ageAssurance/data' +import {logger} from '#/ageAssurance/logger' import {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess' import { + getAssuredAgeFromDeviceSignals, isLegacyBirthdateBug, + regionAllowsDeviceVerification, useAgeAssuranceRegionConfig, } from '#/ageAssurance/util' import {useAnalytics} from '#/analytics' @@ -308,6 +314,8 @@ function AccessSection() { const getTimeAgo = useGetTimeAgo() const {setDeviceGeolocation} = useDeviceGeolocationApi() const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess() + const {currentAccount} = useSession() + const region = useAgeAssuranceRegionConfig() const aa = useAgeAssurance() const {status, lastInitiatedAt} = aa.state @@ -320,6 +328,41 @@ function AccessSection() { ? dateDiff(lastInitiatedAt, new Date(), 'down') : null + const openKwsDialog = useCallback(() => { + control.open() + ax.metric('ageAssurance:initDialogOpen', { + hasInitiatedPreviously: hasInitiated, + }) + }, [control, ax, hasInitiated]) + + const onPressVerify = useCallback(async () => { + /* + * In regions that permit on-device verification, try the native age API + * first. If it returns a sufficient age, the cached signals flow into the + * AA state recompute and lift the gate. Otherwise we fall back to the KWS + * flow below. `getDeviceSignals` handles its own errors and returns + * undefined on failure, which also routes us to the fallback. + */ + if (region && regionAllowsDeviceVerification(region)) { + const did = currentAccount?.did + const signals = await getDeviceSignals() + if (did) { + setDeviceSignalsForDid({did, signals}) + } + const assuredAge = getAssuredAgeFromDeviceSignals(region, signals) + if (assuredAge !== undefined) { + // Sufficient device signals: AA state recomputes from the cache + // write above and unlocks access. Nothing else to do here. + return + } + logger.debug( + `onPressVerify: device signals insufficient, falling back to KWS`, + ) + } + + openKwsDialog() + }, [region, currentAccount?.did, openKwsDialog]) + return ( <> @@ -349,23 +392,7 @@ function AccessSection() { label={_(msg`Verify now`)} size="large" color={hasInitiated ? 'secondary' : 'primary'} - onPress={async () => { - try { - const ageRange = await AgeRange.requestAgeRangeAsync({ - threshold1: 10, - threshold2: 13, - threshold3: 18, - }); - console.log(ageRange) - } catch (e) { - console.error(e) - } - return - control.open() - ax.metric('ageAssurance:initDialogOpen', { - hasInitiatedPreviously: hasInitiated, - }) - }}> + onPress={() => void onPressVerify()}> {hasInitiated ? ( diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 91e2f9f2d6..5167552fc7 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -1,4 +1,5 @@ import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' +import * as AgeRange from 'expo-age-range' import { type AppBskyAgeassuranceDefs, type AppBskyAgeassuranceGetConfig, @@ -11,7 +12,6 @@ import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persist import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' import {persistQueryClient} from '@tanstack/react-query-persist-client' import debounce from 'lodash.debounce' -import * as AgeRange from 'expo-age-range'; import {networkRetry} from '#/lib/async/retry' import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' @@ -489,15 +489,18 @@ export function useOtherRequiredDataQuery() { export function createDeviceSignalsQueryKey({did}: {did: string}) { return ['device-signals', did] } -export async function getDeviceSignals(): Promise { +export async function getDeviceSignals(): Promise< + AgeRange.AgeRangeResponse | undefined +> { if (debug.enabled) return debug.resolve(debug.deviceSignals) try { - return AgeRange.requestAgeRangeAsync({ + return await AgeRange.requestAgeRangeAsync({ threshold1: 13, threshold2: 16, threshold3: 18, - }); - } catch (e: any) { + }) + } catch (err) { + const e = err as Error logger.error(`getDeviceSignals: failed to get device signals`, { safeMessage: e.message, }) @@ -508,66 +511,60 @@ export function getDeviceSignalsFromCache({ did, }: { did: string -}): - | AgeRange.AgeRangeResponse - | undefined { +}): AgeRange.AgeRangeResponse | undefined { return qc.getQueryData( createDeviceSignalsQueryKey({did}), ) } -let deviceSignalsPrefetchPromise: Promise | undefined -export async function prefetchDeviceSignals({ - agent, +/** + * Writes freshly granted device signals into the (persisted) cache. Notifies + * the disabled `useDeviceSignalsQuery` observer so the AA state recomputes. + */ +export function setDeviceSignalsForDid({ + did, + signals, }: { - agent: AtpAgent + did: string + signals: AgeRange.AgeRangeResponse | undefined }) { + qc.setQueryData( + createDeviceSignalsQueryKey({did}), + signals, + ) +} +export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) { const did = getDidFromAgentSession(agent) if (!did) return /** - * If we don't have a cache, it's possible the user hasn't granted access. - * We don't want to do this during the prefetch phase, so just exit early, - * the user can potentially enable it later. + * Device signals are restored from the persisted cache only — we never call + * the native age API during prefetch, since that would prompt the OS for + * users who haven't opted in. Awaiting cache hydration ensures any previously + * granted signals are available before the AA state is first computed. The + * user can (re)grant access later via the NoAccessScreen verify flow. */ + await cacheHydrationPromise const cached = getDeviceSignalsFromCache({did}) - if (!cached) return - - if (deviceSignalsPrefetchPromise) { - logger.debug(`prefetchDeviceSignals: already in progress`) - return - } - - deviceSignalsPrefetchPromise = new Promise(async resolve => { - await cacheHydrationPromise - const cached = getDeviceSignalsFromCache({did}) - - if (cached) { - logger.debug(`prefetchDeviceSignals: using cache`) - resolve() - } else { - try { - logger.debug(`prefetchDeviceSignals: resolving...`) - const res = await getDeviceSignals() - qc.setQueryData( - createDeviceSignalsQueryKey({did}), - res, - ) - } catch (e: any) { - logger.warn(`prefetchDeviceSignals: failed`, { - safeMessage: e.message, - }) - } finally { - resolve() - } - } - }) + logger.debug( + `prefetchDeviceSignals: ${cached ? 'restored from cache' : 'no cache'}`, + ) } export function useDeviceSignalsQuery() { const agent = useAgent() const did = getDidFromAgentSession(agent) return useQuery( { - enabled: !!did, + /** + * Disabled so we never auto-call the native age API on load — that would + * prompt the OS for every logged-in user. We restore from the persisted + * cache (via `initialData`) and otherwise only update reactively when the + * user explicitly verifies (see `getDeviceSignals` + `setQueryData` in + * the NoAccessScreen verify flow). + * + * A future enhancement could silently refresh here when already cached, + * since the OS returns the granted result without re-prompting. + */ + enabled: false, initialData: getDeviceSignalsFromCache({did: did!}), queryKey: createDeviceSignalsQueryKey({did: did!}), async queryFn() { @@ -620,6 +617,11 @@ export type AgeAssuranceServerData = { */ state: AppBskyAgeassuranceDefs.State | undefined metadata: AgeAssuranceMetadata | undefined + /** + * The native on-device age signals, if the user has granted access. Only + * consumed for regions that permit device verification. + */ + deviceSignals: AgeRange.AgeRangeResponse | undefined } const AgeAssuranceServerDataContext = createContext({ config: undefined, @@ -629,6 +631,7 @@ const AgeAssuranceServerDataContext = createContext({ declaredAge: undefined, birthdate: undefined, }, + deviceSignals: undefined, }) export function useAgeAssuranceServerDataContext() { return useContext(AgeAssuranceServerDataContext) @@ -642,6 +645,7 @@ export function AgeAssuranceServerDataProvider({ const serverState = useServerStateQuery() const {state, metadata} = serverState.data || {} const {data} = useOtherRequiredDataQuery() + const {data: deviceSignals} = useDeviceSignalsQuery() const ctx = useMemo( () => ({ config, @@ -654,8 +658,9 @@ export function AgeAssuranceServerDataProvider({ : undefined, birthdate: data?.birthdate, }, + deviceSignals, }), - [config, state, data, metadata], + [config, state, data, metadata, deviceSignals], ) return ( diff --git a/src/ageAssurance/debug.ts b/src/ageAssurance/debug.ts index 1fdbd53d40..986d875f0b 100644 --- a/src/ageAssurance/debug.ts +++ b/src/ageAssurance/debug.ts @@ -1,14 +1,23 @@ import type * as AgeRange from 'expo-age-range' import { ageAssuranceRuleIDs as ids, - type AppBskyAgeassuranceDefs, type AppBskyAgeassuranceGetState, } from '@atproto/api' import {type OtherRequiredData} from '#/ageAssurance/data' +import {type AgeAssuranceConfigRegion} from '#/ageAssurance/types' import {IS_DEV, IS_E2E} from '#/env' import {type Geolocation} from '#/geolocation' +/** + * Debug-only config shape. Mirrors {@link AppBskyAgeassuranceDefs.Config} but + * uses {@link AgeAssuranceConfigRegion}, which carries the not-yet-in-lexicon + * `verificationMethods` field so we can prototype on-device verification. + */ +export type DebugConfig = { + regions: AgeAssuranceConfigRegion[] +} + export const enabled = (IS_DEV && true) || IS_E2E export const geolocation: Geolocation | undefined = enabled @@ -46,7 +55,7 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined = } : undefined -export const config: AppBskyAgeassuranceDefs.Config = { +export const config: DebugConfig = { regions: [ { countryCode: 'AA', @@ -59,6 +68,25 @@ export const config: AppBskyAgeassuranceDefs.Config = { }, ], }, + { + // On-device verification region (e.g. Texas). Set debug.geolocation to + // {countryCode: 'US', regionCode: 'TX'} to exercise the device flow. + countryCode: 'US', + regionCode: 'TX', + minAccessAge: 18, + verificationMethods: ['device'], + rules: [ + { + age: 18, + access: 'full', + $type: ids.IfAssuredOverAge, + }, + { + access: 'none', + $type: ids.Default, + }, + ], + }, { countryCode: 'GB', minAccessAge: 13, @@ -262,7 +290,9 @@ const deviceSignalsEnabled = true export const deviceSignals: AgeRange.AgeRangeResponse | undefined = deviceSignalsEnabled ? { - lowerBound: null, + // Simulates the OS reporting the user is at least 18. Lower this below + // a region's IfAssuredOverAge threshold to exercise the KWS fallback. + lowerBound: 18, upperBound: null, } : undefined diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts index ff80bca725..69276c9737 100644 --- a/src/ageAssurance/state.ts +++ b/src/ageAssurance/state.ts @@ -1,4 +1,5 @@ import {useEffect, useMemo, useState} from 'react' +import type * as AgeRange from 'expo-age-range' import { type AppBskyAgeassuranceDefs, computeAgeAssuranceRegionAccess, @@ -8,6 +9,7 @@ import {getAge} from '#/lib/strings/time' import {useSession} from '#/state/session' import { getConfigFromCache, + getDeviceSignalsFromCache, getOtherRequiredDataFromCache, getServerStateFromCache, useAgeAssuranceServerDataContext, @@ -24,6 +26,7 @@ import { import { computeAgeAssuranceFlags, getAgeAssuranceRegionConfigWithFallback, + getAssuredAgeFromDeviceSignals, } from '#/ageAssurance/util' import {type Geolocation, useGeolocation} from '#/geolocation' import {device} from '#/storage' @@ -39,12 +42,14 @@ function computeAgeAssuranceState({ config, state, metadata, + deviceSignals, }: { hasSession: boolean geolocation: Geolocation config?: AppBskyAgeassuranceDefs.Config state?: AppBskyAgeassuranceDefs.State metadata?: AgeAssuranceMetadata + deviceSignals?: AgeRange.AgeRangeResponse }) { /** * This is where we control logged-out moderation prefs. It's all @@ -93,10 +98,16 @@ function computeAgeAssuranceState({ * Otherwise, we need to compute the access based on the latest data. For * accounts with an accurate birthdate, our default fallback rules should * ensure correct access. + * + * In regions that permit on-device verification, the OS-provided age range + * is treated as an assured age and fed into the rule engine, where it + * matches `IfAssuredOverAge`/`IfAssuredUnderAge` rules. */ + const assuredAge = getAssuredAgeFromDeviceSignals(region, deviceSignals) const result = computeAgeAssuranceRegionAccess(region, { accountCreatedAt: metadata?.accountCreatedAt, declaredAge: metadata?.declaredAge, + assuredAge, }) const computed = { lastInitiatedAt: state?.lastInitiatedAt, @@ -126,6 +137,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { const config = getConfigFromCache() const state = getServerStateFromCache({did}) const requiredData = getOtherRequiredDataFromCache({did}) + const deviceSignals = getDeviceSignalsFromCache({did}) const geolocation = device.get(['mergedGeolocation']) if (!geolocation || !config || !state || !requiredData) { @@ -151,6 +163,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { geolocation, state: state.state, metadata, + deviceSignals, }) return { @@ -166,7 +179,8 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { export function useAgeAssuranceState(): AgeAssuranceState { const {hasSession} = useSession() const geolocation = useGeolocation() - const {config, state, metadata} = useAgeAssuranceServerDataContext() + const {config, state, metadata, deviceSignals} = + useAgeAssuranceServerDataContext() return useMemo( () => @@ -176,8 +190,9 @@ export function useAgeAssuranceState(): AgeAssuranceState { geolocation, state, metadata, + deviceSignals, }), - [hasSession, geolocation, config, state, metadata], + [hasSession, geolocation, config, state, metadata, deviceSignals], ) } diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts index 21abff89f0..1215e888f2 100644 --- a/src/ageAssurance/types.ts +++ b/src/ageAssurance/types.ts @@ -1,7 +1,32 @@ -import {type computeAgeAssuranceRegionAccess} from '@atproto/api' +import { + type AppBskyAgeassuranceDefs, + type computeAgeAssuranceRegionAccess, +} from '@atproto/api' import {logger} from '#/ageAssurance/logger' +/** + * The ways a user can satisfy age assurance within a given region. + * + * - `kws`: the third-party (KWS) verification flow. + * - `device`: native on-device age APIs (Apple Declared Age Range / Google + * Play Age Signals), surfaced via `expo-age-range`. + * + * NOTE: this is not yet part of the `app.bsky.ageassurance` lexicon. It's + * modeled client-side (see {@link AgeAssuranceConfigRegion}) while we prototype + * the shape. Once the lexicon adds `verificationMethods`, this can be removed in + * favor of the generated type. + */ +export type AgeAssuranceVerificationMethod = 'device' | 'kws' + +/** + * A region config extended with the (not-yet-in-lexicon) `verificationMethods` + * field. Regions without the field are treated as KWS-only. + */ +export type AgeAssuranceConfigRegion = AppBskyAgeassuranceDefs.ConfigRegion & { + verificationMethods?: AgeAssuranceVerificationMethod[] +} + export enum AgeAssuranceAccess { Unknown = 'unknown', None = 'none', diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts index 0d493f1130..6c5b6478f1 100644 --- a/src/ageAssurance/util.ts +++ b/src/ageAssurance/util.ts @@ -1,4 +1,5 @@ import {useMemo} from 'react' +import type * as AgeRange from 'expo-age-range' import { type AppBskyAgeassuranceDefs, getAgeAssuranceRegionConfig, @@ -11,9 +12,11 @@ import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const' import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data' import { AgeAssuranceAccess, + type AgeAssuranceConfigRegion, type AgeAssuranceFlags, type AgeAssuranceMetadata, type AgeAssuranceState, + type AgeAssuranceVerificationMethod, } from '#/ageAssurance/types' import {type Geolocation, useGeolocation} from '#/geolocation' @@ -36,6 +39,48 @@ export function getAgeAssuranceRegionConfigWithFallback( return region || FALLBACK_REGION_CONFIG } +/** + * Returns the verification methods permitted for a region, defaulting to + * `['kws']` when the region doesn't specify any (the historical behavior). + * + * NOTE: `verificationMethods` is not yet part of the lexicon, so we read it via + * {@link AgeAssuranceConfigRegion}. See that type for the migration note. + */ +export function getRegionVerificationMethods( + region: AppBskyAgeassuranceDefs.ConfigRegion, +): AgeAssuranceVerificationMethod[] { + const methods = (region as AgeAssuranceConfigRegion).verificationMethods + return methods && methods.length > 0 ? methods : ['kws'] +} + +/** + * Whether a region permits satisfying age assurance via the native on-device + * age APIs (Apple Declared Age Range / Google Play Age Signals). + */ +export function regionAllowsDeviceVerification( + region: AppBskyAgeassuranceDefs.ConfigRegion, +): boolean { + return getRegionVerificationMethods(region).includes('device') +} + +/** + * Derives an assured age from native device signals, but only for regions that + * permit device verification. The OS-provided `lowerBound` is the minimum age + * the platform will attest to, which maps directly onto the `assuredAge` input + * of the rule engine (i.e. `IfAssuredOverAge`/`IfAssuredUnderAge` rules). + * + * Returns undefined when the region doesn't allow device verification or when + * the OS didn't provide a usable lower bound. + */ +export function getAssuredAgeFromDeviceSignals( + region: AppBskyAgeassuranceDefs.ConfigRegion, + deviceSignals: AgeRange.AgeRangeResponse | undefined, +): number | undefined { + if (!regionAllowsDeviceVerification(region)) return undefined + const lowerBound = deviceSignals?.lowerBound + return typeof lowerBound === 'number' ? lowerBound : undefined +} + /** * Hook to get the age assurance region config based on current geolocation. * Does not fall-back to our app defaults. If no config is found, returns