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:
@@ -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 (
|
||||
<>
|
||||
<AgeAssuranceInitDialog control={control} />
|
||||
@@ -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()}>
|
||||
<ButtonIcon icon={ShieldIcon} />
|
||||
<ButtonText>
|
||||
{hasInitiated ? (
|
||||
|
||||
+53
-48
@@ -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<AgeRange.AgeRangeResponse | undefined> {
|
||||
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<AgeRange.AgeRangeResponse>(
|
||||
createDeviceSignalsQueryKey({did}),
|
||||
)
|
||||
}
|
||||
let deviceSignalsPrefetchPromise: Promise<void> | 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<AgeRange.AgeRangeResponse | undefined>(
|
||||
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<AgeRange.AgeRangeResponse>(
|
||||
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<AgeAssuranceServerData>({
|
||||
config: undefined,
|
||||
@@ -629,6 +631,7 @@ const AgeAssuranceServerDataContext = createContext<AgeAssuranceServerData>({
|
||||
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 (
|
||||
<AgeAssuranceServerDataContext.Provider value={ctx}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user