move age assurance onto the sdk utils and scope regions by platform locally

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Samuel Newman
2026-08-04 04:20:51 +03:00
parent 8c228c2e4f
commit 3cb305d001
7 changed files with 105 additions and 82 deletions
+6 -12
View File
@@ -1,8 +1,3 @@
import {
ageAssuranceRuleIDs as ids,
type AppBskyAgeassuranceDefs,
} from '@atproto/api'
import {AgeAssuranceAccess} from '#/ageAssurance/types' import {AgeAssuranceAccess} from '#/ageAssurance/types'
import { import {
ANDROID_API_LEVEL, ANDROID_API_LEVEL,
@@ -11,6 +6,7 @@ import {
IS_IOS, IS_IOS,
IS_WEB, IS_WEB,
} from '#/env' } from '#/env'
import {app} from '#/lexicons'
/** /**
* Minimum age required to access the app at all. * Minimum age required to access the app at all.
@@ -44,19 +40,17 @@ export const AGE_ASSURANCE_PLATFORM: 'web' | 'ios' | 'android' = IS_WEB
export const DEVICE_SIGNALS_SUPPORTED: boolean = export const DEVICE_SIGNALS_SUPPORTED: boolean =
(IS_IOS && IOS_MAJOR_VERSION >= 26) || (IS_ANDROID && ANDROID_API_LEVEL >= 23) (IS_IOS && IOS_MAJOR_VERSION >= 26) || (IS_ANDROID && ANDROID_API_LEVEL >= 23)
export const FALLBACK_REGION_CONFIG: AppBskyAgeassuranceDefs.ConfigRegion = { export const FALLBACK_REGION_CONFIG: app.bsky.ageassurance.defs.ConfigRegion = {
countryCode: '*', countryCode: '*',
regionCode: undefined, regionCode: undefined,
minAccessAge: MIN_ACCESS_AGE, minAccessAge: MIN_ACCESS_AGE,
rules: [ rules: [
{ app.bsky.ageassurance.defs.configRegionRuleIfDeclaredOverAge.build({
$type: ids.IfDeclaredOverAge,
age: MIN_ACCESS_AGE, age: MIN_ACCESS_AGE,
access: AgeAssuranceAccess.Full, access: AgeAssuranceAccess.Full,
}, }),
{ app.bsky.ageassurance.defs.configRegionRuleDefault.build({
$type: ids.Default,
access: AgeAssuranceAccess.None, access: AgeAssuranceAccess.None,
}, }),
], ],
} }
+18 -24
View File
@@ -1,11 +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 * as AgeRange from 'expo-age-range'
import {
type AppBskyAgeassuranceDefs,
type AppBskyAgeassuranceGetConfig,
type AppBskyAgeassuranceGetState,
type ChatBskyActorDeclaration,
} from '@atproto/api'
import {type Client} from '@atproto/lex' import {type Client} from '@atproto/lex'
import {getPreferences} from '@bsky.app/sdk' import {getPreferences} from '@bsky.app/sdk'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister' import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
@@ -38,7 +32,7 @@ import {
} from '#/ageAssurance/util' } from '#/ageAssurance/util'
import {IS_DEV} from '#/env' import {IS_DEV} from '#/env'
import {useGeolocation} from '#/geolocation' import {useGeolocation} from '#/geolocation'
import {app} from '#/lexicons' import {app, chat} from '#/lexicons'
import {device} from '#/storage' import {device} from '#/storage'
/** /**
@@ -104,12 +98,12 @@ export async function getConfig() {
*/ */
return (await getPublicAppviewClient().call( return (await getPublicAppviewClient().call(
app.bsky.ageassurance.getConfig, app.bsky.ageassurance.getConfig,
)) as AppBskyAgeassuranceGetConfig.OutputSchema )) as app.bsky.ageassurance.getConfig.$OutputBody
} }
export function getConfigFromCache(): export function getConfigFromCache():
| AppBskyAgeassuranceGetConfig.OutputSchema | app.bsky.ageassurance.getConfig.$OutputBody
| undefined { | undefined {
return qc.getQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>( return qc.getQueryData<app.bsky.ageassurance.getConfig.$OutputBody>(
configQueryKey, configQueryKey,
) )
} }
@@ -130,7 +124,7 @@ export function prefetchConfig() {
try { try {
logger.debug(`prefetchAgeAssuranceConfig: resolving...`) logger.debug(`prefetchAgeAssuranceConfig: resolving...`)
const res = await networkRetry(3, () => getConfig()) const res = await networkRetry(3, () => getConfig())
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>( qc.setQueryData<app.bsky.ageassurance.getConfig.$OutputBody>(
configQueryKey, configQueryKey,
res, res,
) )
@@ -146,7 +140,7 @@ export function prefetchConfig() {
export async function refetchConfig() { export async function refetchConfig() {
logger.debug(`refetchConfig: fetching...`) logger.debug(`refetchConfig: fetching...`)
const res = await getConfig() const res = await getConfig()
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>( qc.setQueryData<app.bsky.ageassurance.getConfig.$OutputBody>(
configQueryKey, configQueryKey,
res, res,
) )
@@ -216,8 +210,8 @@ export function getServerStateFromCache({
did, did,
}: { }: {
did: string did: string
}): AppBskyAgeassuranceGetState.OutputSchema | undefined { }): app.bsky.ageassurance.getState.$OutputBody | undefined {
return qc.getQueryData<AppBskyAgeassuranceGetState.OutputSchema>( return qc.getQueryData<app.bsky.ageassurance.getState.$OutputBody>(
createServerStateQueryKey({did}), createServerStateQueryKey({did}),
) )
} }
@@ -243,7 +237,7 @@ export async function prefetchServerState({
logger.debug(`prefetchServerState: resolving...`) logger.debug(`prefetchServerState: resolving...`)
const res = await networkRetry(3, () => getServerState({appviewClient})) const res = await networkRetry(3, () => getServerState({appviewClient}))
if (res) { if (res) {
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res) qc.setQueryData<app.bsky.ageassurance.getState.$OutputBody>(qk, res)
} }
} catch (err) { } catch (err) {
const e = err as Error const e = err as Error
@@ -262,7 +256,7 @@ export async function refetchServerState({
logger.debug(`refetchServerState: fetching...`) logger.debug(`refetchServerState: fetching...`)
const res = await networkRetry(3, () => getServerState({appviewClient})) const res = await networkRetry(3, () => getServerState({appviewClient}))
if (res) { if (res) {
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>( qc.setQueryData<app.bsky.ageassurance.getState.$OutputBody>(
createServerStateQueryKey({did}), createServerStateQueryKey({did}),
res, res,
) )
@@ -272,16 +266,16 @@ export async function refetchServerState({
export function usePatchServerState() { export function usePatchServerState() {
const {currentAccount} = useSession() const {currentAccount} = useSession()
return useCallback( return useCallback(
(next: AppBskyAgeassuranceDefs.State) => { (next: app.bsky.ageassurance.defs.State) => {
if (!currentAccount) return if (!currentAccount) return
const did = currentAccount.did const did = currentAccount.did
const prev = getServerStateFromCache({did}) const prev = getServerStateFromCache({did})
const merged: AppBskyAgeassuranceGetState.OutputSchema = { const merged: app.bsky.ageassurance.getState.$OutputBody = {
metadata: {}, metadata: {},
...(prev || {}), ...(prev || {}),
state: next, state: next,
} }
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>( qc.setQueryData<app.bsky.ageassurance.getState.$OutputBody>(
createServerStateQueryKey({did}), createServerStateQueryKey({did}),
merged, merged,
) )
@@ -347,7 +341,7 @@ export function useServerStateQuery() {
export type OtherRequiredData = { export type OtherRequiredData = {
birthdate: string | undefined birthdate: string | undefined
actorDeclaration?: ChatBskyActorDeclaration.Main actorDeclaration?: chat.bsky.actor.declaration.Main
} }
export function createOtherRequiredDataQueryKey({did}: {did: string}) { export function createOtherRequiredDataQueryKey({did}: {did: string}) {
return ['otherRequiredData', did] return ['otherRequiredData', did]
@@ -422,7 +416,7 @@ export function setOtherRequiredDataActorDeclarationCache({
actorDeclaration, actorDeclaration,
}: { }: {
did: string did: string
actorDeclaration: ChatBskyActorDeclaration.Main actorDeclaration: chat.bsky.actor.declaration.Main
}) { }) {
const prev = getOtherRequiredDataFromCache({did}) const prev = getOtherRequiredDataFromCache({did})
const next: OtherRequiredData = { const next: OtherRequiredData = {
@@ -564,7 +558,7 @@ export function getDeviceSignalsFromCacheForRegion({
region, region,
}: { }: {
did: string did: string
region: AppBskyAgeassuranceDefs.ConfigRegion region: app.bsky.ageassurance.defs.ConfigRegion
}): AgeRange.AgeRangeResponse | undefined { }): AgeRange.AgeRangeResponse | undefined {
const regionKey = createRegionKey(region) const regionKey = createRegionKey(region)
return getDeviceSignalsMapFromCache({did})?.[regionKey] return getDeviceSignalsMapFromCache({did})?.[regionKey]
@@ -718,11 +712,11 @@ export type AgeAssuranceServerData = {
/** /**
* The raw config from the appview. * The raw config from the appview.
*/ */
config: AppBskyAgeassuranceDefs.Config | undefined config: app.bsky.ageassurance.defs.Config | undefined
/** /**
* The raw state from the appview. Must be further processed before being useful. * The raw state from the appview. Must be further processed before being useful.
*/ */
state: AppBskyAgeassuranceDefs.State | undefined state: app.bsky.ageassurance.defs.State | undefined
metadata: AgeAssuranceMetadata | undefined metadata: AgeAssuranceMetadata | undefined
/** /**
* The native on-device age signals for the region the user is currently in, * The native on-device age signals for the region the user is currently in,
+4 -6
View File
@@ -1,10 +1,8 @@
import {useEffect, useMemo, useState} from 'react' import {useEffect, useMemo, useState} from 'react'
import type * as AgeRange from 'expo-age-range' import type * as AgeRange from 'expo-age-range'
import { import {computeAgeAssuranceRegionAccess} from '@bsky.app/sdk/utils'
type AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess,
} from '@atproto/api'
import {type app} from '#/lexicons'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import { import {
@@ -47,8 +45,8 @@ function computeAgeAssuranceState({
}: { }: {
hasSession: boolean hasSession: boolean
geolocation: Geolocation geolocation: Geolocation
config?: AppBskyAgeassuranceDefs.Config config?: app.bsky.ageassurance.defs.Config
state?: AppBskyAgeassuranceDefs.State state?: app.bsky.ageassurance.defs.State
metadata?: AgeAssuranceMetadata metadata?: AgeAssuranceMetadata
deviceSignals?: AgeRange.AgeRangeResponse deviceSignals?: AgeRange.AgeRangeResponse
}) { }) {
+1 -1
View File
@@ -1,5 +1,5 @@
import type * as AgeRange from 'expo-age-range' import type * as AgeRange from 'expo-age-range'
import {type computeAgeAssuranceRegionAccess} from '@atproto/api' import {type computeAgeAssuranceRegionAccess} from '@bsky.app/sdk/utils'
import {logger} from '#/ageAssurance/logger' import {logger} from '#/ageAssurance/logger'
@@ -1,5 +1,5 @@
import {useCallback} from 'react' import {useCallback} from 'react'
import {computeAgeAssuranceRegionAccess} from '@atproto/api' import {computeAgeAssuranceRegionAccess} from '@bsky.app/sdk/utils'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data' import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger' import {logger} from '#/ageAssurance/logger'
+39 -22
View File
@@ -1,31 +1,48 @@
import {getAgeAssuranceRegionConfig} from '@atproto/api'
import {getAgeAssuranceRegionConfigForGeolocation} from '#/ageAssurance/util' import {getAgeAssuranceRegionConfigForGeolocation} from '#/ageAssurance/util'
import {type app} from '#/lexicons'
jest.mock('#/ageAssurance/data') jest.mock('#/ageAssurance/data')
jest.mock('@atproto/api', () => ({
...jest.requireActual('@atproto/api'),
getAgeAssuranceRegionConfig: jest.fn(),
}))
/* /*
* Platform-based region filtering itself is implemented and tested in * Platform scoping is applied locally in `util.ts` (the SDK region matcher
* `@atproto/api` (see `getAgeAssuranceRegionConfig`). What we own - and test * takes no platform filter). The jest preset is `jest-expo/ios`, so
* here - is that region resolution passes the current platform through. The * `AGE_ASSURANCE_PLATFORM` resolves to `ios` in these tests.
* jest preset is `jest-expo/ios`, so `AGE_ASSURANCE_PLATFORM` resolves to
* `ios` in these tests.
*/ */
describe('getAgeAssuranceRegionConfigForGeolocation', () => { describe('getAgeAssuranceRegionConfigForGeolocation', () => {
it('passes the current platform to the SDK region matcher', () => { const region = (
const config = {regions: []} countryCode: string,
getAgeAssuranceRegionConfigForGeolocation(config, { regionCode?: string,
countryCode: 'US', platforms?: string[],
regionCode: 'TX', ) =>
}) ({
expect(getAgeAssuranceRegionConfig).toHaveBeenCalledWith(config, { countryCode,
countryCode: 'US', regionCode,
regionCode: 'TX', platforms,
platform: 'ios', minAccessAge: 13,
}) rules: [],
}) as app.bsky.ageassurance.defs.ConfigRegion
it('skips regions for other platforms and continues matching', () => {
const web = region('US', undefined, ['web'])
const ios = region('US', undefined, ['ios'])
expect(
getAgeAssuranceRegionConfigForGeolocation(
{regions: [web, ios]},
{countryCode: 'US', regionCode: undefined},
),
).toBe(ios)
})
it('matches a region-specific config before a later country config', () => {
const texas = region('US', 'TX')
const us = region('US')
expect(
getAgeAssuranceRegionConfigForGeolocation(
{regions: [texas, us]},
{countryCode: 'US', regionCode: 'TX'},
),
).toBe(texas)
}) })
}) })
+36 -16
View File
@@ -1,12 +1,13 @@
import {useMemo} from 'react' import {useMemo} from 'react'
import type * as AgeRange from 'expo-age-range' import type * as AgeRange from 'expo-age-range'
import { import {
AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess, computeAgeAssuranceRegionAccess,
getAgeAssuranceRegionConfig, getAgeAssuranceRegionConfig,
} from '@atproto/api' } from '@bsky.app/sdk/utils'
import {type ModerationPrefs} from '@bsky.app/sdk/moderation' import {type ModerationPrefs} from '@bsky.app/sdk/moderation'
import * as bsky from '#/types/bsky'
import {app} from '#/lexicons'
import {getAge} from '#/lib/strings/time' import {getAge} from '#/lib/strings/time'
import {regionName} from '#/locale/helpers' import {regionName} from '#/locale/helpers'
import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const' import {DEFAULT_LOGGED_OUT_LABEL_PREFERENCES} from '#/state/queries/preferences/const'
@@ -41,13 +42,26 @@ import {USRegionNameToRegionCode} from '#/geolocation/util'
* risk desyncing the write and read keys and silently losing grants. * risk desyncing the write and read keys and silently losing grants.
*/ */
export function getAgeAssuranceRegionConfigForGeolocation( export function getAgeAssuranceRegionConfigForGeolocation(
config: AppBskyAgeassuranceDefs.Config, config: app.bsky.ageassurance.defs.Config,
geolocation: Geolocation, geolocation: Geolocation,
): AppBskyAgeassuranceDefs.ConfigRegion | undefined { ): app.bsky.ageassurance.defs.ConfigRegion | undefined {
return getAgeAssuranceRegionConfig(config, { /*
* The SDK's region matcher takes no `platform` filter (and the generated
* `ConfigRegion` carries no `platforms` field yet), so platform scoping is
* applied here: regions restricted to other platforms are dropped before
* matching, which is what passing `platform` to the old api helper did.
* Fold this back into the SDK call once it accepts a platform.
*/
const scoped: app.bsky.ageassurance.defs.Config = {
...config,
regions: config.regions.filter(region => {
const platforms = (region as {platforms?: string[]}).platforms
return !platforms || platforms.includes(AGE_ASSURANCE_PLATFORM)
}),
}
return getAgeAssuranceRegionConfig(scoped, {
countryCode: geolocation.countryCode ?? '', countryCode: geolocation.countryCode ?? '',
regionCode: geolocation.regionCode, regionCode: geolocation.regionCode,
platform: AGE_ASSURANCE_PLATFORM,
}) })
} }
@@ -59,9 +73,9 @@ export function getAgeAssuranceRegionConfigForGeolocation(
* which can return undefined if the geolocation does not match any AA region. * which can return undefined if the geolocation does not match any AA region.
*/ */
export function getAgeAssuranceRegionConfigWithFallback( export function getAgeAssuranceRegionConfigWithFallback(
config: AppBskyAgeassuranceDefs.Config, config: app.bsky.ageassurance.defs.Config,
geolocation: Geolocation, geolocation: Geolocation,
): AppBskyAgeassuranceDefs.ConfigRegion { ): app.bsky.ageassurance.defs.ConfigRegion {
return ( return (
getAgeAssuranceRegionConfigForGeolocation(config, geolocation) || getAgeAssuranceRegionConfigForGeolocation(config, geolocation) ||
FALLBACK_REGION_CONFIG FALLBACK_REGION_CONFIG
@@ -74,9 +88,9 @@ export function getAgeAssuranceRegionConfigWithFallback(
* historical KWS-only behavior). * historical KWS-only behavior).
*/ */
export function getRegionAdditionalVerificationMethods( export function getRegionAdditionalVerificationMethods(
region: AppBskyAgeassuranceDefs.ConfigRegion, region: app.bsky.ageassurance.defs.ConfigRegion,
): NonNullable< ): NonNullable<
AppBskyAgeassuranceDefs.ConfigRegion['additionalVerificationMethods'] app.bsky.ageassurance.defs.ConfigRegion['additionalVerificationMethods']
> { > {
return region.additionalVerificationMethods ?? [] return region.additionalVerificationMethods ?? []
} }
@@ -86,7 +100,7 @@ export function getRegionAdditionalVerificationMethods(
* age APIs (Apple Declared Age Range / Google Play Age Signals). * age APIs (Apple Declared Age Range / Google Play Age Signals).
*/ */
export function regionAllowsDeviceVerification( export function regionAllowsDeviceVerification(
region: AppBskyAgeassuranceDefs.ConfigRegion, region: app.bsky.ageassurance.defs.ConfigRegion,
): boolean { ): boolean {
return getRegionAdditionalVerificationMethods(region).includes('device') return getRegionAdditionalVerificationMethods(region).includes('device')
} }
@@ -122,7 +136,7 @@ export function createRegionKey(region: {
* usable data. * usable data.
*/ */
export function getAgeAssuranceDataFromDeviceSignals( export function getAgeAssuranceDataFromDeviceSignals(
region: AppBskyAgeassuranceDefs.ConfigRegion, region: app.bsky.ageassurance.defs.ConfigRegion,
deviceSignals: AgeRange.AgeRangeResponse | undefined, deviceSignals: AgeRange.AgeRangeResponse | undefined,
): { ): {
assuredAge?: number assuredAge?: number
@@ -183,7 +197,7 @@ export function canBirthdateUpdateIncreaseAccess({
metadata, metadata,
deviceSignals, deviceSignals,
}: { }: {
region: AppBskyAgeassuranceDefs.ConfigRegion region: app.bsky.ageassurance.defs.ConfigRegion
metadata?: AgeAssuranceMetadata metadata?: AgeAssuranceMetadata
deviceSignals?: AgeRange.AgeRangeResponse deviceSignals?: AgeRange.AgeRangeResponse
}): boolean { }): boolean {
@@ -212,8 +226,14 @@ export function canBirthdateUpdateIncreaseAccess({
const thresholds = new Set<number>([region.minAccessAge]) const thresholds = new Set<number>([region.minAccessAge])
for (const rule of region.rules) { for (const rule of region.rules) {
if ( if (
AppBskyAgeassuranceDefs.isConfigRegionRuleIfDeclaredOverAge(rule) || bsky.isType(
AppBskyAgeassuranceDefs.isConfigRegionRuleIfDeclaredUnderAge(rule) app.bsky.ageassurance.defs.configRegionRuleIfDeclaredOverAge,
rule,
) ||
bsky.isType(
app.bsky.ageassurance.defs.configRegionRuleIfDeclaredUnderAge,
rule,
)
) { ) {
thresholds.add(rule.age) thresholds.add(rule.age)
} }
@@ -303,7 +323,7 @@ export function computeAgeAssuranceFlags({
deviceSignals, deviceSignals,
}: { }: {
state: AgeAssuranceState state: AgeAssuranceState
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion regionConfig: app.bsky.ageassurance.defs.ConfigRegion
metadata?: AgeAssuranceMetadata metadata?: AgeAssuranceMetadata
deviceSignals?: AgeRange.AgeRangeResponse deviceSignals?: AgeRange.AgeRangeResponse
}): AgeAssuranceFlags { }): AgeAssuranceFlags {