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 <noreply@anthropic.com>
This commit is contained in:
Eric Bailey
2026-06-22 18:21:47 -05:00
parent 4b7abf0ff2
commit e834167f81
6 changed files with 221 additions and 74 deletions
+47 -20
View File
@@ -4,7 +4,6 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context'
import {msg} from '@lingui/core/macro' import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import * as AgeRange from 'expo-age-range'
import { import {
SupportCode, SupportCode,
@@ -12,7 +11,7 @@ import {
} from '#/lib/hooks/useCreateSupportLink' } from '#/lib/hooks/useCreateSupportLink'
import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo' import {dateDiff, useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
import {useIsBirthdateUpdateAllowed} from '#/state/birthdate' import {useIsBirthdateUpdateAllowed} from '#/state/birthdate'
import {useSessionApi} from '#/state/session' import {useSession, useSessionApi} from '#/state/session'
import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog' import {DeactivateAccountDialog} from '#/screens/Settings/components/DeactivateAccountDialog'
import {DeleteAccountDialog} from '#/screens/Settings/components/DeleteAccountDialog' import {DeleteAccountDialog} from '#/screens/Settings/components/DeleteAccountDialog'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 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 {Text} from '#/components/Typography'
import {BottomSheetOutlet} from '#/../modules/bottom-sheet' import {BottomSheetOutlet} from '#/../modules/bottom-sheet'
import {useAgeAssurance} from '#/ageAssurance' 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 {useComputeAgeAssuranceRegionAccess} from '#/ageAssurance/useComputeAgeAssuranceRegionAccess'
import { import {
getAssuredAgeFromDeviceSignals,
isLegacyBirthdateBug, isLegacyBirthdateBug,
regionAllowsDeviceVerification,
useAgeAssuranceRegionConfig, useAgeAssuranceRegionConfig,
} from '#/ageAssurance/util' } from '#/ageAssurance/util'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
@@ -308,6 +314,8 @@ function AccessSection() {
const getTimeAgo = useGetTimeAgo() const getTimeAgo = useGetTimeAgo()
const {setDeviceGeolocation} = useDeviceGeolocationApi() const {setDeviceGeolocation} = useDeviceGeolocationApi()
const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess() const computeAgeAssuranceRegionAccess = useComputeAgeAssuranceRegionAccess()
const {currentAccount} = useSession()
const region = useAgeAssuranceRegionConfig()
const aa = useAgeAssurance() const aa = useAgeAssurance()
const {status, lastInitiatedAt} = aa.state const {status, lastInitiatedAt} = aa.state
@@ -320,6 +328,41 @@ function AccessSection() {
? dateDiff(lastInitiatedAt, new Date(), 'down') ? dateDiff(lastInitiatedAt, new Date(), 'down')
: null : 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 ( return (
<> <>
<AgeAssuranceInitDialog control={control} /> <AgeAssuranceInitDialog control={control} />
@@ -349,23 +392,7 @@ function AccessSection() {
label={_(msg`Verify now`)} label={_(msg`Verify now`)}
size="large" size="large"
color={hasInitiated ? 'secondary' : 'primary'} color={hasInitiated ? 'secondary' : 'primary'}
onPress={async () => { onPress={() => void onPressVerify()}>
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,
})
}}>
<ButtonIcon icon={ShieldIcon} /> <ButtonIcon icon={ShieldIcon} />
<ButtonText> <ButtonText>
{hasInitiated ? ( {hasInitiated ? (
+51 -46
View File
@@ -1,4 +1,5 @@
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react' import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
import * as AgeRange from 'expo-age-range'
import { import {
type AppBskyAgeassuranceDefs, type AppBskyAgeassuranceDefs,
type AppBskyAgeassuranceGetConfig, type AppBskyAgeassuranceGetConfig,
@@ -11,7 +12,6 @@ import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persist
import {focusManager, QueryClient, useQuery} from '@tanstack/react-query' import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
import {persistQueryClient} from '@tanstack/react-query-persist-client' import {persistQueryClient} from '@tanstack/react-query-persist-client'
import debounce from 'lodash.debounce' import debounce from 'lodash.debounce'
import * as AgeRange from 'expo-age-range';
import {networkRetry} from '#/lib/async/retry' import {networkRetry} from '#/lib/async/retry'
import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' import {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
@@ -489,15 +489,18 @@ export function useOtherRequiredDataQuery() {
export function createDeviceSignalsQueryKey({did}: {did: string}) { export function createDeviceSignalsQueryKey({did}: {did: string}) {
return ['device-signals', did] return ['device-signals', did]
} }
export async function getDeviceSignals(): Promise<AgeRange.AgeRangeResponse | undefined> { export async function getDeviceSignals(): Promise<
AgeRange.AgeRangeResponse | undefined
> {
if (debug.enabled) return debug.resolve(debug.deviceSignals) if (debug.enabled) return debug.resolve(debug.deviceSignals)
try { try {
return AgeRange.requestAgeRangeAsync({ return await AgeRange.requestAgeRangeAsync({
threshold1: 13, threshold1: 13,
threshold2: 16, threshold2: 16,
threshold3: 18, threshold3: 18,
}); })
} catch (e: any) { } catch (err) {
const e = err as Error
logger.error(`getDeviceSignals: failed to get device signals`, { logger.error(`getDeviceSignals: failed to get device signals`, {
safeMessage: e.message, safeMessage: e.message,
}) })
@@ -508,66 +511,60 @@ export function getDeviceSignalsFromCache({
did, did,
}: { }: {
did: string did: string
}): }): AgeRange.AgeRangeResponse | undefined {
| AgeRange.AgeRangeResponse
| undefined {
return qc.getQueryData<AgeRange.AgeRangeResponse>( return qc.getQueryData<AgeRange.AgeRangeResponse>(
createDeviceSignalsQueryKey({did}), createDeviceSignalsQueryKey({did}),
) )
} }
let deviceSignalsPrefetchPromise: Promise<void> | undefined /**
export async function prefetchDeviceSignals({ * Writes freshly granted device signals into the (persisted) cache. Notifies
agent, * the disabled `useDeviceSignalsQuery` observer so the AA state recomputes.
*/
export function setDeviceSignalsForDid({
did,
signals,
}: { }: {
agent: AtpAgent did: string
signals: AgeRange.AgeRangeResponse | undefined
}) { }) {
qc.setQueryData<AgeRange.AgeRangeResponse | undefined>(
createDeviceSignalsQueryKey({did}),
signals,
)
}
export async function prefetchDeviceSignals({agent}: {agent: AtpAgent}) {
const did = getDidFromAgentSession(agent) const did = getDidFromAgentSession(agent)
if (!did) return if (!did) return
/** /**
* If we don't have a cache, it's possible the user hasn't granted access. * Device signals are restored from the persisted cache only — we never call
* We don't want to do this during the prefetch phase, so just exit early, * the native age API during prefetch, since that would prompt the OS for
* the user can potentially enable it later. * 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.
*/ */
const cached = getDeviceSignalsFromCache({did})
if (!cached) return
if (deviceSignalsPrefetchPromise) {
logger.debug(`prefetchDeviceSignals: already in progress`)
return
}
deviceSignalsPrefetchPromise = new Promise(async resolve => {
await cacheHydrationPromise await cacheHydrationPromise
const cached = getDeviceSignalsFromCache({did}) const cached = getDeviceSignalsFromCache({did})
logger.debug(
if (cached) { `prefetchDeviceSignals: ${cached ? 'restored from cache' : 'no cache'}`,
logger.debug(`prefetchDeviceSignals: using cache`)
resolve()
} else {
try {
logger.debug(`prefetchDeviceSignals: resolving...`)
const res = await getDeviceSignals()
qc.setQueryData<AgeRange.AgeRangeResponse>(
createDeviceSignalsQueryKey({did}),
res,
) )
} catch (e: any) {
logger.warn(`prefetchDeviceSignals: failed`, {
safeMessage: e.message,
})
} finally {
resolve()
}
}
})
} }
export function useDeviceSignalsQuery() { export function useDeviceSignalsQuery() {
const agent = useAgent() const agent = useAgent()
const did = getDidFromAgentSession(agent) const did = getDidFromAgentSession(agent)
return useQuery( 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!}), initialData: getDeviceSignalsFromCache({did: did!}),
queryKey: createDeviceSignalsQueryKey({did: did!}), queryKey: createDeviceSignalsQueryKey({did: did!}),
async queryFn() { async queryFn() {
@@ -620,6 +617,11 @@ export type AgeAssuranceServerData = {
*/ */
state: AppBskyAgeassuranceDefs.State | undefined state: AppBskyAgeassuranceDefs.State | undefined
metadata: AgeAssuranceMetadata | 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<AgeAssuranceServerData>({ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
config: undefined, config: undefined,
@@ -629,6 +631,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
declaredAge: undefined, declaredAge: undefined,
birthdate: undefined, birthdate: undefined,
}, },
deviceSignals: undefined,
}) })
export function useAgeAssuranceServerDataContext() { export function useAgeAssuranceServerDataContext() {
return useContext(AgeAssuranceServerDataContext) return useContext(AgeAssuranceServerDataContext)
@@ -642,6 +645,7 @@ export function AgeAssuranceServerDataProvider({
const serverState = useServerStateQuery() const serverState = useServerStateQuery()
const {state, metadata} = serverState.data || {} const {state, metadata} = serverState.data || {}
const {data} = useOtherRequiredDataQuery() const {data} = useOtherRequiredDataQuery()
const {data: deviceSignals} = useDeviceSignalsQuery()
const ctx = useMemo( const ctx = useMemo(
() => ({ () => ({
config, config,
@@ -654,8 +658,9 @@ export function AgeAssuranceServerDataProvider({
: undefined, : undefined,
birthdate: data?.birthdate, birthdate: data?.birthdate,
}, },
deviceSignals,
}), }),
[config, state, data, metadata], [config, state, data, metadata, deviceSignals],
) )
return ( return (
<AgeAssuranceServerDataContext.Provider value={ctx}> <AgeAssuranceServerDataContext.Provider value={ctx}>
+33 -3
View File
@@ -1,14 +1,23 @@
import type * as AgeRange from 'expo-age-range' import type * as AgeRange from 'expo-age-range'
import { import {
ageAssuranceRuleIDs as ids, ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
type AppBskyAgeassuranceGetState, type AppBskyAgeassuranceGetState,
} from '@atproto/api' } from '@atproto/api'
import {type OtherRequiredData} from '#/ageAssurance/data' import {type OtherRequiredData} from '#/ageAssurance/data'
import {type AgeAssuranceConfigRegion} from '#/ageAssurance/types'
import {IS_DEV, IS_E2E} from '#/env' import {IS_DEV, IS_E2E} from '#/env'
import {type Geolocation} from '#/geolocation' 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 enabled = (IS_DEV && true) || IS_E2E
export const geolocation: Geolocation | undefined = enabled export const geolocation: Geolocation | undefined = enabled
@@ -46,7 +55,7 @@ export const serverState: AppBskyAgeassuranceGetState.OutputSchema | undefined =
} }
: undefined : undefined
export const config: AppBskyAgeassuranceDefs.Config = { export const config: DebugConfig = {
regions: [ regions: [
{ {
countryCode: 'AA', 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', countryCode: 'GB',
minAccessAge: 13, minAccessAge: 13,
@@ -262,7 +290,9 @@ const deviceSignalsEnabled = true
export const deviceSignals: AgeRange.AgeRangeResponse | undefined = export const deviceSignals: AgeRange.AgeRangeResponse | undefined =
deviceSignalsEnabled 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, upperBound: null,
} }
: undefined : undefined
+17 -2
View File
@@ -1,4 +1,5 @@
import {useEffect, useMemo, useState} from 'react' import {useEffect, useMemo, useState} from 'react'
import type * as AgeRange from 'expo-age-range'
import { import {
type AppBskyAgeassuranceDefs, type AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess, computeAgeAssuranceRegionAccess,
@@ -8,6 +9,7 @@ import {getAge} from '#/lib/strings/time'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import { import {
getConfigFromCache, getConfigFromCache,
getDeviceSignalsFromCache,
getOtherRequiredDataFromCache, getOtherRequiredDataFromCache,
getServerStateFromCache, getServerStateFromCache,
useAgeAssuranceServerDataContext, useAgeAssuranceServerDataContext,
@@ -24,6 +26,7 @@ import {
import { import {
computeAgeAssuranceFlags, computeAgeAssuranceFlags,
getAgeAssuranceRegionConfigWithFallback, getAgeAssuranceRegionConfigWithFallback,
getAssuredAgeFromDeviceSignals,
} from '#/ageAssurance/util' } from '#/ageAssurance/util'
import {type Geolocation, useGeolocation} from '#/geolocation' import {type Geolocation, useGeolocation} from '#/geolocation'
import {device} from '#/storage' import {device} from '#/storage'
@@ -39,12 +42,14 @@ function computeAgeAssuranceState({
config, config,
state, state,
metadata, metadata,
deviceSignals,
}: { }: {
hasSession: boolean hasSession: boolean
geolocation: Geolocation geolocation: Geolocation
config?: AppBskyAgeassuranceDefs.Config config?: AppBskyAgeassuranceDefs.Config
state?: AppBskyAgeassuranceDefs.State state?: AppBskyAgeassuranceDefs.State
metadata?: AgeAssuranceMetadata metadata?: AgeAssuranceMetadata
deviceSignals?: AgeRange.AgeRangeResponse
}) { }) {
/** /**
* This is where we control logged-out moderation prefs. It's all * 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 * Otherwise, we need to compute the access based on the latest data. For
* accounts with an accurate birthdate, our default fallback rules should * accounts with an accurate birthdate, our default fallback rules should
* ensure correct access. * 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, { const result = computeAgeAssuranceRegionAccess(region, {
accountCreatedAt: metadata?.accountCreatedAt, accountCreatedAt: metadata?.accountCreatedAt,
declaredAge: metadata?.declaredAge, declaredAge: metadata?.declaredAge,
assuredAge,
}) })
const computed = { const computed = {
lastInitiatedAt: state?.lastInitiatedAt, lastInitiatedAt: state?.lastInitiatedAt,
@@ -126,6 +137,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
const config = getConfigFromCache() const config = getConfigFromCache()
const state = getServerStateFromCache({did}) const state = getServerStateFromCache({did})
const requiredData = getOtherRequiredDataFromCache({did}) const requiredData = getOtherRequiredDataFromCache({did})
const deviceSignals = getDeviceSignalsFromCache({did})
const geolocation = device.get(['mergedGeolocation']) const geolocation = device.get(['mergedGeolocation'])
if (!geolocation || !config || !state || !requiredData) { if (!geolocation || !config || !state || !requiredData) {
@@ -151,6 +163,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
geolocation, geolocation,
state: state.state, state: state.state,
metadata, metadata,
deviceSignals,
}) })
return { return {
@@ -166,7 +179,8 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
export function useAgeAssuranceState(): AgeAssuranceState { export function useAgeAssuranceState(): AgeAssuranceState {
const {hasSession} = useSession() const {hasSession} = useSession()
const geolocation = useGeolocation() const geolocation = useGeolocation()
const {config, state, metadata} = useAgeAssuranceServerDataContext() const {config, state, metadata, deviceSignals} =
useAgeAssuranceServerDataContext()
return useMemo( return useMemo(
() => () =>
@@ -176,8 +190,9 @@ export function useAgeAssuranceState(): AgeAssuranceState {
geolocation, geolocation,
state, state,
metadata, metadata,
deviceSignals,
}), }),
[hasSession, geolocation, config, state, metadata], [hasSession, geolocation, config, state, metadata, deviceSignals],
) )
} }
+26 -1
View File
@@ -1,7 +1,32 @@
import {type computeAgeAssuranceRegionAccess} from '@atproto/api' import {
type AppBskyAgeassuranceDefs,
type computeAgeAssuranceRegionAccess,
} from '@atproto/api'
import {logger} from '#/ageAssurance/logger' 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 { export enum AgeAssuranceAccess {
Unknown = 'unknown', Unknown = 'unknown',
None = 'none', None = 'none',
+45
View File
@@ -1,4 +1,5 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import type * as AgeRange from 'expo-age-range'
import { import {
type AppBskyAgeassuranceDefs, type AppBskyAgeassuranceDefs,
getAgeAssuranceRegionConfig, getAgeAssuranceRegionConfig,
@@ -11,9 +12,11 @@ import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data' import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import { import {
AgeAssuranceAccess, AgeAssuranceAccess,
type AgeAssuranceConfigRegion,
type AgeAssuranceFlags, type AgeAssuranceFlags,
type AgeAssuranceMetadata, type AgeAssuranceMetadata,
type AgeAssuranceState, type AgeAssuranceState,
type AgeAssuranceVerificationMethod,
} from '#/ageAssurance/types' } from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation' import {type Geolocation, useGeolocation} from '#/geolocation'
@@ -36,6 +39,48 @@ export function getAgeAssuranceRegionConfigWithFallback(
return region || FALLBACK_REGION_CONFIG 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. * 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 * Does not fall-back to our app defaults. If no config is found, returns