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 {
ANDROID_API_LEVEL,
@@ -11,6 +6,7 @@ import {
IS_IOS,
IS_WEB,
} from '#/env'
import {app} from '#/lexicons'
/**
* 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 =
(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: '*',
regionCode: undefined,
minAccessAge: MIN_ACCESS_AGE,
rules: [
{
$type: ids.IfDeclaredOverAge,
app.bsky.ageassurance.defs.configRegionRuleIfDeclaredOverAge.build({
age: MIN_ACCESS_AGE,
access: AgeAssuranceAccess.Full,
},
{
$type: ids.Default,
}),
app.bsky.ageassurance.defs.configRegionRuleDefault.build({
access: AgeAssuranceAccess.None,
},
}),
],
}
+18 -24
View File
@@ -1,11 +1,5 @@
import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
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 {getPreferences} from '@bsky.app/sdk'
import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
@@ -38,7 +32,7 @@ import {
} from '#/ageAssurance/util'
import {IS_DEV} from '#/env'
import {useGeolocation} from '#/geolocation'
import {app} from '#/lexicons'
import {app, chat} from '#/lexicons'
import {device} from '#/storage'
/**
@@ -104,12 +98,12 @@ export async function getConfig() {
*/
return (await getPublicAppviewClient().call(
app.bsky.ageassurance.getConfig,
)) as AppBskyAgeassuranceGetConfig.OutputSchema
)) as app.bsky.ageassurance.getConfig.$OutputBody
}
export function getConfigFromCache():
| AppBskyAgeassuranceGetConfig.OutputSchema
| app.bsky.ageassurance.getConfig.$OutputBody
| undefined {
return qc.getQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
return qc.getQueryData<app.bsky.ageassurance.getConfig.$OutputBody>(
configQueryKey,
)
}
@@ -130,7 +124,7 @@ export function prefetchConfig() {
try {
logger.debug(`prefetchAgeAssuranceConfig: resolving...`)
const res = await networkRetry(3, () => getConfig())
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
qc.setQueryData<app.bsky.ageassurance.getConfig.$OutputBody>(
configQueryKey,
res,
)
@@ -146,7 +140,7 @@ export function prefetchConfig() {
export async function refetchConfig() {
logger.debug(`refetchConfig: fetching...`)
const res = await getConfig()
qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
qc.setQueryData<app.bsky.ageassurance.getConfig.$OutputBody>(
configQueryKey,
res,
)
@@ -216,8 +210,8 @@ export function getServerStateFromCache({
did,
}: {
did: string
}): AppBskyAgeassuranceGetState.OutputSchema | undefined {
return qc.getQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
}): app.bsky.ageassurance.getState.$OutputBody | undefined {
return qc.getQueryData<app.bsky.ageassurance.getState.$OutputBody>(
createServerStateQueryKey({did}),
)
}
@@ -243,7 +237,7 @@ export async function prefetchServerState({
logger.debug(`prefetchServerState: resolving...`)
const res = await networkRetry(3, () => getServerState({appviewClient}))
if (res) {
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res)
qc.setQueryData<app.bsky.ageassurance.getState.$OutputBody>(qk, res)
}
} catch (err) {
const e = err as Error
@@ -262,7 +256,7 @@ export async function refetchServerState({
logger.debug(`refetchServerState: fetching...`)
const res = await networkRetry(3, () => getServerState({appviewClient}))
if (res) {
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
qc.setQueryData<app.bsky.ageassurance.getState.$OutputBody>(
createServerStateQueryKey({did}),
res,
)
@@ -272,16 +266,16 @@ export async function refetchServerState({
export function usePatchServerState() {
const {currentAccount} = useSession()
return useCallback(
(next: AppBskyAgeassuranceDefs.State) => {
(next: app.bsky.ageassurance.defs.State) => {
if (!currentAccount) return
const did = currentAccount.did
const prev = getServerStateFromCache({did})
const merged: AppBskyAgeassuranceGetState.OutputSchema = {
const merged: app.bsky.ageassurance.getState.$OutputBody = {
metadata: {},
...(prev || {}),
state: next,
}
qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
qc.setQueryData<app.bsky.ageassurance.getState.$OutputBody>(
createServerStateQueryKey({did}),
merged,
)
@@ -347,7 +341,7 @@ export function useServerStateQuery() {
export type OtherRequiredData = {
birthdate: string | undefined
actorDeclaration?: ChatBskyActorDeclaration.Main
actorDeclaration?: chat.bsky.actor.declaration.Main
}
export function createOtherRequiredDataQueryKey({did}: {did: string}) {
return ['otherRequiredData', did]
@@ -422,7 +416,7 @@ export function setOtherRequiredDataActorDeclarationCache({
actorDeclaration,
}: {
did: string
actorDeclaration: ChatBskyActorDeclaration.Main
actorDeclaration: chat.bsky.actor.declaration.Main
}) {
const prev = getOtherRequiredDataFromCache({did})
const next: OtherRequiredData = {
@@ -564,7 +558,7 @@ export function getDeviceSignalsFromCacheForRegion({
region,
}: {
did: string
region: AppBskyAgeassuranceDefs.ConfigRegion
region: app.bsky.ageassurance.defs.ConfigRegion
}): AgeRange.AgeRangeResponse | undefined {
const regionKey = createRegionKey(region)
return getDeviceSignalsMapFromCache({did})?.[regionKey]
@@ -718,11 +712,11 @@ export type AgeAssuranceServerData = {
/**
* 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.
*/
state: AppBskyAgeassuranceDefs.State | undefined
state: app.bsky.ageassurance.defs.State | undefined
metadata: AgeAssuranceMetadata | undefined
/**
* 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 type * as AgeRange from 'expo-age-range'
import {
type AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess,
} from '@atproto/api'
import {computeAgeAssuranceRegionAccess} from '@bsky.app/sdk/utils'
import {type app} from '#/lexicons'
import {getAge} from '#/lib/strings/time'
import {useSession} from '#/state/session'
import {
@@ -47,8 +45,8 @@ function computeAgeAssuranceState({
}: {
hasSession: boolean
geolocation: Geolocation
config?: AppBskyAgeassuranceDefs.Config
state?: AppBskyAgeassuranceDefs.State
config?: app.bsky.ageassurance.defs.Config
state?: app.bsky.ageassurance.defs.State
metadata?: AgeAssuranceMetadata
deviceSignals?: AgeRange.AgeRangeResponse
}) {
+1 -1
View File
@@ -1,5 +1,5 @@
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'
@@ -1,5 +1,5 @@
import {useCallback} from 'react'
import {computeAgeAssuranceRegionAccess} from '@atproto/api'
import {computeAgeAssuranceRegionAccess} from '@bsky.app/sdk/utils'
import {useAgeAssuranceServerDataContext} from '#/ageAssurance/data'
import {logger} from '#/ageAssurance/logger'
+39 -22
View File
@@ -1,31 +1,48 @@
import {getAgeAssuranceRegionConfig} from '@atproto/api'
import {getAgeAssuranceRegionConfigForGeolocation} from '#/ageAssurance/util'
import {type app} from '#/lexicons'
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
* `@atproto/api` (see `getAgeAssuranceRegionConfig`). What we own - and test
* here - is that region resolution passes the current platform through. The
* jest preset is `jest-expo/ios`, so `AGE_ASSURANCE_PLATFORM` resolves to
* `ios` in these tests.
* Platform scoping is applied locally in `util.ts` (the SDK region matcher
* takes no platform filter). The jest preset is `jest-expo/ios`, so
* `AGE_ASSURANCE_PLATFORM` resolves to `ios` in these tests.
*/
describe('getAgeAssuranceRegionConfigForGeolocation', () => {
it('passes the current platform to the SDK region matcher', () => {
const config = {regions: []}
getAgeAssuranceRegionConfigForGeolocation(config, {
countryCode: 'US',
regionCode: 'TX',
})
expect(getAgeAssuranceRegionConfig).toHaveBeenCalledWith(config, {
countryCode: 'US',
regionCode: 'TX',
platform: 'ios',
})
const region = (
countryCode: string,
regionCode?: string,
platforms?: string[],
) =>
({
countryCode,
regionCode,
platforms,
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 type * as AgeRange from 'expo-age-range'
import {
AppBskyAgeassuranceDefs,
computeAgeAssuranceRegionAccess,
getAgeAssuranceRegionConfig,
} from '@atproto/api'
} from '@bsky.app/sdk/utils'
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 {regionName} from '#/locale/helpers'
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.
*/
export function getAgeAssuranceRegionConfigForGeolocation(
config: AppBskyAgeassuranceDefs.Config,
config: app.bsky.ageassurance.defs.Config,
geolocation: Geolocation,
): AppBskyAgeassuranceDefs.ConfigRegion | undefined {
return getAgeAssuranceRegionConfig(config, {
): app.bsky.ageassurance.defs.ConfigRegion | undefined {
/*
* 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 ?? '',
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.
*/
export function getAgeAssuranceRegionConfigWithFallback(
config: AppBskyAgeassuranceDefs.Config,
config: app.bsky.ageassurance.defs.Config,
geolocation: Geolocation,
): AppBskyAgeassuranceDefs.ConfigRegion {
): app.bsky.ageassurance.defs.ConfigRegion {
return (
getAgeAssuranceRegionConfigForGeolocation(config, geolocation) ||
FALLBACK_REGION_CONFIG
@@ -74,9 +88,9 @@ export function getAgeAssuranceRegionConfigWithFallback(
* historical KWS-only behavior).
*/
export function getRegionAdditionalVerificationMethods(
region: AppBskyAgeassuranceDefs.ConfigRegion,
region: app.bsky.ageassurance.defs.ConfigRegion,
): NonNullable<
AppBskyAgeassuranceDefs.ConfigRegion['additionalVerificationMethods']
app.bsky.ageassurance.defs.ConfigRegion['additionalVerificationMethods']
> {
return region.additionalVerificationMethods ?? []
}
@@ -86,7 +100,7 @@ export function getRegionAdditionalVerificationMethods(
* age APIs (Apple Declared Age Range / Google Play Age Signals).
*/
export function regionAllowsDeviceVerification(
region: AppBskyAgeassuranceDefs.ConfigRegion,
region: app.bsky.ageassurance.defs.ConfigRegion,
): boolean {
return getRegionAdditionalVerificationMethods(region).includes('device')
}
@@ -122,7 +136,7 @@ export function createRegionKey(region: {
* usable data.
*/
export function getAgeAssuranceDataFromDeviceSignals(
region: AppBskyAgeassuranceDefs.ConfigRegion,
region: app.bsky.ageassurance.defs.ConfigRegion,
deviceSignals: AgeRange.AgeRangeResponse | undefined,
): {
assuredAge?: number
@@ -183,7 +197,7 @@ export function canBirthdateUpdateIncreaseAccess({
metadata,
deviceSignals,
}: {
region: AppBskyAgeassuranceDefs.ConfigRegion
region: app.bsky.ageassurance.defs.ConfigRegion
metadata?: AgeAssuranceMetadata
deviceSignals?: AgeRange.AgeRangeResponse
}): boolean {
@@ -212,8 +226,14 @@ export function canBirthdateUpdateIncreaseAccess({
const thresholds = new Set<number>([region.minAccessAge])
for (const rule of region.rules) {
if (
AppBskyAgeassuranceDefs.isConfigRegionRuleIfDeclaredOverAge(rule) ||
AppBskyAgeassuranceDefs.isConfigRegionRuleIfDeclaredUnderAge(rule)
bsky.isType(
app.bsky.ageassurance.defs.configRegionRuleIfDeclaredOverAge,
rule,
) ||
bsky.isType(
app.bsky.ageassurance.defs.configRegionRuleIfDeclaredUnderAge,
rule,
)
) {
thresholds.add(rule.age)
}
@@ -303,7 +323,7 @@ export function computeAgeAssuranceFlags({
deviceSignals,
}: {
state: AgeAssuranceState
regionConfig: AppBskyAgeassuranceDefs.ConfigRegion
regionConfig: app.bsky.ageassurance.defs.ConfigRegion
metadata?: AgeAssuranceMetadata
deviceSignals?: AgeRange.AgeRangeResponse
}): AgeAssuranceFlags {