259 lines
7.3 KiB
TypeScript
259 lines
7.3 KiB
TypeScript
import {useEffect, useMemo, useState} from 'react'
|
|
import type * as AgeRange from 'expo-age-range'
|
|
import {computeAgeAssuranceRegionAccess} from '@bsky/sdk/utils'
|
|
|
|
import {getAge} from '#/lib/strings/time'
|
|
import {useSession} from '#/state/session'
|
|
import {
|
|
getConfigFromCache,
|
|
getDeviceSignalsFromCacheForRegion,
|
|
getOtherRequiredDataFromCache,
|
|
getServerStateFromCache,
|
|
type OtherRequiredDataStatus,
|
|
useAgeAssuranceServerDataContext,
|
|
} from '#/ageAssurance/data'
|
|
import {logger} from '#/ageAssurance/logger'
|
|
import {
|
|
AgeAssuranceAccess,
|
|
type AgeAssuranceMetadata,
|
|
type AgeAssuranceState,
|
|
AgeAssuranceStatus,
|
|
parseAccessFromString,
|
|
parseStatusFromString,
|
|
} from '#/ageAssurance/types'
|
|
import {
|
|
computeAgeAssuranceFlags,
|
|
getAgeAssuranceDataFromDeviceSignals,
|
|
getAgeAssuranceRegionConfigForGeolocation,
|
|
getAgeAssuranceRegionConfigWithFallback,
|
|
} from '#/ageAssurance/util'
|
|
import {type Geolocation, useGeolocation} from '#/geolocation'
|
|
import {type app} from '#/lexicons'
|
|
import {device} from '#/storage'
|
|
|
|
/**
|
|
* Get final evaluated age assurance state. Handles fallbacks and defers to
|
|
* server state before computing access based on AA config from the server +
|
|
* geolocation and other data.
|
|
*/
|
|
export function computeAgeAssuranceState({
|
|
hasSession,
|
|
geolocation,
|
|
config,
|
|
state,
|
|
metadata,
|
|
otherRequiredDataStatus,
|
|
deviceSignals,
|
|
}: {
|
|
hasSession: boolean
|
|
geolocation: Geolocation
|
|
config?: app.bsky.ageassurance.defs.Config
|
|
state?: app.bsky.ageassurance.defs.State
|
|
metadata?: AgeAssuranceMetadata
|
|
otherRequiredDataStatus: OtherRequiredDataStatus
|
|
deviceSignals?: AgeRange.AgeRangeResponse
|
|
}) {
|
|
/**
|
|
* This is where we control logged-out moderation prefs. It's all
|
|
* downstream of AA now.
|
|
*/
|
|
if (!hasSession)
|
|
return {
|
|
status: AgeAssuranceStatus.Unknown,
|
|
access: AgeAssuranceAccess.Safe,
|
|
}
|
|
|
|
/**
|
|
* This can happen if the prefetch fails (such as due to network issues).
|
|
* The query handler will try it again, but if it continues to fail, of
|
|
* course we won't have config.
|
|
*
|
|
* In this case, fail open to avoid blocking users.
|
|
*/
|
|
if (!config) {
|
|
logger.warn('useAgeAssuranceState: missing config')
|
|
return {
|
|
status: AgeAssuranceStatus.Unknown,
|
|
access: AgeAssuranceAccess.Safe,
|
|
error: 'config' as const,
|
|
}
|
|
}
|
|
|
|
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
|
|
const isAARequired = region.countryCode !== '*'
|
|
const isTerminalState =
|
|
state?.status === 'assured' || state?.status === 'blocked'
|
|
|
|
/*
|
|
* If we are in a terminal state and AA is required for this region,
|
|
* we can trust the server state completely and avoid recomputing.
|
|
*/
|
|
if (isTerminalState && isAARequired) {
|
|
return {
|
|
lastInitiatedAt: state.lastInitiatedAt,
|
|
status: parseStatusFromString(state.status),
|
|
access: parseAccessFromString(state.access),
|
|
}
|
|
}
|
|
|
|
if (otherRequiredDataStatus === 'pending') {
|
|
return {
|
|
status: AgeAssuranceStatus.Unknown,
|
|
access: AgeAssuranceAccess.Safe,
|
|
isLoading: true,
|
|
}
|
|
}
|
|
|
|
if (otherRequiredDataStatus === 'error') {
|
|
return {
|
|
status: AgeAssuranceStatus.Unknown,
|
|
access: AgeAssuranceAccess.Safe,
|
|
error: 'account-data' as const,
|
|
}
|
|
}
|
|
|
|
/*
|
|
* 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} = getAgeAssuranceDataFromDeviceSignals(
|
|
region,
|
|
deviceSignals,
|
|
)
|
|
const result = computeAgeAssuranceRegionAccess(region, {
|
|
accountCreatedAt: metadata?.accountCreatedAt,
|
|
declaredAge: metadata?.declaredAge,
|
|
assuredAge,
|
|
})
|
|
const computed = {
|
|
lastInitiatedAt: state?.lastInitiatedAt,
|
|
// prefer server state
|
|
status: state?.status
|
|
? parseStatusFromString(state?.status)
|
|
: AgeAssuranceStatus.Unknown,
|
|
// prefer server state
|
|
access: result
|
|
? parseAccessFromString(result.access)
|
|
: AgeAssuranceAccess.Full,
|
|
}
|
|
logger.debug('computeAgeAssuranceState', {
|
|
region,
|
|
state,
|
|
metadata,
|
|
computed,
|
|
})
|
|
return computed
|
|
}
|
|
|
|
/**
|
|
* This is a last-ditch helper for out-of-band reads of the AA state, such as
|
|
* during account creation. Don't use it for anything else.
|
|
*/
|
|
export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) {
|
|
const config = getConfigFromCache()
|
|
const state = getServerStateFromCache({did})
|
|
const requiredData = getOtherRequiredDataFromCache({did})
|
|
const geolocation = device.get(['mergedGeolocation'])
|
|
|
|
if (!geolocation || !config || !state || !requiredData) {
|
|
return {
|
|
state: {
|
|
status: AgeAssuranceStatus.Unknown,
|
|
access: AgeAssuranceAccess.Safe,
|
|
},
|
|
}
|
|
}
|
|
|
|
const region = getAgeAssuranceRegionConfigWithFallback(config, geolocation)
|
|
/*
|
|
* Device signals are keyed off the matched config region (no fallback): if
|
|
* geolocation matches no AA region there's no device grant to read, so we
|
|
* skip the lookup rather than keying off FALLBACK_REGION_CONFIG. This keeps
|
|
* the read key symmetric with the write (see `setDeviceSignalsForRegion`).
|
|
*/
|
|
const deviceRegion = getAgeAssuranceRegionConfigForGeolocation(
|
|
config,
|
|
geolocation,
|
|
)
|
|
const deviceSignals = deviceRegion
|
|
? getDeviceSignalsFromCacheForRegion({did, region: deviceRegion})
|
|
: undefined
|
|
const metadata: AgeAssuranceMetadata = {
|
|
accountCreatedAt: state.metadata?.accountCreatedAt,
|
|
declaredAge: requiredData?.birthdate
|
|
? getAge(new Date(requiredData.birthdate))
|
|
: undefined,
|
|
birthdate: requiredData?.birthdate,
|
|
}
|
|
const computed = computeAgeAssuranceState({
|
|
hasSession: true,
|
|
config,
|
|
geolocation,
|
|
state: state.state,
|
|
metadata,
|
|
otherRequiredDataStatus: 'success',
|
|
deviceSignals,
|
|
})
|
|
|
|
return {
|
|
state: computed,
|
|
flags: computeAgeAssuranceFlags({
|
|
state: computed,
|
|
regionConfig: region,
|
|
metadata,
|
|
deviceSignals,
|
|
}),
|
|
}
|
|
}
|
|
|
|
export function useAgeAssuranceState(): AgeAssuranceState {
|
|
const {hasSession} = useSession()
|
|
const geolocation = useGeolocation()
|
|
const {config, state, metadata, otherRequiredDataStatus, deviceSignals} =
|
|
useAgeAssuranceServerDataContext()
|
|
|
|
return useMemo(
|
|
() =>
|
|
computeAgeAssuranceState({
|
|
hasSession,
|
|
config,
|
|
geolocation,
|
|
state,
|
|
metadata,
|
|
otherRequiredDataStatus,
|
|
deviceSignals,
|
|
}),
|
|
[
|
|
hasSession,
|
|
geolocation,
|
|
config,
|
|
state,
|
|
metadata,
|
|
otherRequiredDataStatus,
|
|
deviceSignals,
|
|
],
|
|
)
|
|
}
|
|
|
|
export function useOnAgeAssuranceAccessUpdate(
|
|
cb: (state: AgeAssuranceState) => void,
|
|
) {
|
|
const state = useAgeAssuranceState()
|
|
// start with null to ensure callback is called on first render
|
|
const [prevAccess, setPrevAccess] = useState<AgeAssuranceAccess | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (prevAccess !== state.access) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
setPrevAccess(state.access)
|
|
cb(state)
|
|
logger.debug(`useOnAgeAssuranceAccessUpdate`, {state})
|
|
}
|
|
}, [cb, state, prevAccess])
|
|
}
|