Files
bsky-social-app/src/ageAssurance/state.ts
T
2026-09-02 17:57:39 +03:00

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])
}