Files
bsky-social-app/src/ageAssurance/util.ts
T
Eric Bailey 85f6031708 Bind device age assurance to its origin region, add KWS fallback
Device assurance can't be verified server-side (the OS gives only age
bounds, no signed attestation), so it's persisted client-side only. Bind
each cached grant to the region it was captured in so a TX grant can't
unlock another region.

- Add AgeAssuranceDeviceSignals (signals + originRegion); store the
  region-tagged record in the persisted cache instead of the raw response
- getAssuredAgeFromDeviceSignals now requires the current region to match
  the capture region
- Gate the native age request to native platforms (web returns a
  misleading default); web/new-device/declined falls back to KWS
- TX allows ['device', 'kws'] so the fallback path is real

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:38:51 -05:00

205 lines
6.8 KiB
TypeScript

import {useMemo} from 'react'
import {
type AppBskyAgeassuranceDefs,
getAgeAssuranceRegionConfig,
type ModerationPrefs,
} from '@atproto/api'
import {getAge} from '#/lib/strings/time'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
import {FALLBACK_REGION_CONFIG, MIN_ACCESS_AGE} from '#/ageAssurance/const'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {
AgeAssuranceAccess,
type AgeAssuranceConfigRegion,
type AgeAssuranceDeviceSignals,
type AgeAssuranceFlags,
type AgeAssuranceMetadata,
type AgeAssuranceState,
type AgeAssuranceVerificationMethod,
} from '#/ageAssurance/types'
import {type Geolocation, useGeolocation} from '#/geolocation'
/**
* Get age assurance region config based on geolocation, with fallback to
* app defaults if no region config is found.
*
* See {@link getAgeAssuranceRegionConfig} for the generic option, which can
* return undefined if the geolocation does not match any AA region.
*/
export function getAgeAssuranceRegionConfigWithFallback(
config: AppBskyAgeassuranceDefs.Config,
geolocation: Geolocation,
): AppBskyAgeassuranceDefs.ConfigRegion {
const region = getAgeAssuranceRegionConfig(config, {
countryCode: geolocation.countryCode ?? '',
regionCode: geolocation.regionCode,
})
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')
}
/**
* Whether two regions refer to the same country + region. Used to ensure device
* signals are only applied within the region they were captured in.
*/
function isSameRegion(
a: {countryCode: string; regionCode?: string},
b: {countryCode: string; regionCode?: string},
): boolean {
return a.countryCode === b.countryCode && a.regionCode === b.regionCode
}
/**
* Derives an assured age from native device signals, but only when:
*
* 1. the current region permits device verification, and
* 2. the signals were captured in this same region.
*
* Device assurance is region-bound (see {@link AgeAssuranceDeviceSignals}): a
* grant captured in TX must not unlock another region. 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 device verification doesn't apply or the OS didn't
* provide a usable lower bound.
*/
export function getAssuredAgeFromDeviceSignals(
region: AppBskyAgeassuranceDefs.ConfigRegion,
deviceSignals: AgeAssuranceDeviceSignals | undefined,
): number | undefined {
if (!regionAllowsDeviceVerification(region)) return undefined
if (!deviceSignals) return undefined
if (
!isSameRegion(deviceSignals.originRegion, {
countryCode: region.countryCode,
regionCode: region.regionCode,
})
) {
return undefined
}
const lowerBound = deviceSignals.signals.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
* undefined, which indicates no regional age assurance rules apply.
*/
export function useAgeAssuranceRegionConfig() {
const geolocation = useGeolocation()
const {config} = useAgeAssuranceServerDataContext()
return useMemo(() => {
if (!config) return
// use generic helper, we want to potentially return undefined
return getAgeAssuranceRegionConfig(config, {
countryCode: geolocation.countryCode ?? '',
regionCode: geolocation.regionCode,
})
}, [config, geolocation])
}
/**
* Hook to get the age assurance region config based on current geolocation.
* Falls back to our app defaults if no region config is found.
*/
export function useAgeAssuranceRegionConfigWithFallback() {
return useAgeAssuranceRegionConfig() || FALLBACK_REGION_CONFIG
}
/**
* Some users may have erroneously set their birth date to the current date
* if one wasn't set on their account. We previously didn't do validation on
* the bday dialog, and it defaulted to the current date. This bug _has_ been
* seen in production, so we need to check for it where possible.
*/
export function isLegacyBirthdateBug(birthDate: string) {
return ['2025', '2024', '2023'].includes((birthDate || '').slice(0, 4))
}
/**
* Returns whether the date (converted to an age as a whole integer) is under
* the provided minimum age.
*/
export function isUnderAge(birthDate: string, age: number) {
return getAge(new Date(birthDate)) < age
}
export function getBirthdateStringFromAge(age: number) {
const today = new Date()
return new Date(
today.getFullYear() - age,
today.getMonth(),
today.getDate() - 1, // set to day before to ensure age is reached
).toISOString()
}
export const makeAgeRestrictedModerationPrefs = (
prefs: ModerationPrefs,
): ModerationPrefs => ({
...prefs,
adultContentEnabled: false,
labels: DEFAULT_LOGGED_OUT_LABEL_PREFERENCES,
})
export function computeAgeAssuranceFlags({
state,
regionConfig,
metadata,
}: {
state: AgeAssuranceState
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
metadata?: AgeAssuranceMetadata
}): AgeAssuranceFlags {
const isAgeRestricted = state.access !== AgeAssuranceAccess.Full
const chatDisabled = isAgeRestricted
const isDeclaredUnderAdultAge = metadata?.declaredAge
? metadata.declaredAge < 18
: true
const groupChatDisabled = chatDisabled || isDeclaredUnderAdultAge
const isOverRegionMinAccessAge = metadata?.declaredAge
? metadata.declaredAge >= regionConfig.minAccessAge
: false
const isOverAppMinAccessAge = metadata?.declaredAge
? metadata.declaredAge >= MIN_ACCESS_AGE
: false
const adultContentDisabled =
state.access !== AgeAssuranceAccess.Full || isDeclaredUnderAdultAge
return {
isAgeRestricted,
adultContentDisabled,
chatDisabled,
groupChatDisabled,
isDeclaredUnderAdultAge,
isOverRegionMinAccessAge,
isOverAppMinAccessAge,
}
}