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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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'
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user