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 {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
@@ -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}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user