From 3cb305d001339b39be077f04c889877447343bed Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 4 Aug 2026 04:20:51 +0300 Subject: [PATCH] move age assurance onto the sdk utils and scope regions by platform locally Co-Authored-By: Claude Fable 5 --- src/ageAssurance/const.ts | 18 ++---- src/ageAssurance/data.tsx | 42 ++++++------- src/ageAssurance/state.ts | 10 ++- src/ageAssurance/types.ts | 2 +- .../useComputeAgeAssuranceRegionAccess.ts | 2 +- src/ageAssurance/util.test.ts | 61 ++++++++++++------- src/ageAssurance/util.ts | 52 +++++++++++----- 7 files changed, 105 insertions(+), 82 deletions(-) diff --git a/src/ageAssurance/const.ts b/src/ageAssurance/const.ts index 326552681c..9d20a64ea6 100644 --- a/src/ageAssurance/const.ts +++ b/src/ageAssurance/const.ts @@ -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, - }, + }), ], } diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 2540c4b1f9..8da9863cbc 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -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( + return qc.getQueryData( configQueryKey, ) } @@ -130,7 +124,7 @@ export function prefetchConfig() { try { logger.debug(`prefetchAgeAssuranceConfig: resolving...`) const res = await networkRetry(3, () => getConfig()) - qc.setQueryData( + qc.setQueryData( configQueryKey, res, ) @@ -146,7 +140,7 @@ export function prefetchConfig() { export async function refetchConfig() { logger.debug(`refetchConfig: fetching...`) const res = await getConfig() - qc.setQueryData( + qc.setQueryData( configQueryKey, res, ) @@ -216,8 +210,8 @@ export function getServerStateFromCache({ did, }: { did: string -}): AppBskyAgeassuranceGetState.OutputSchema | undefined { - return qc.getQueryData( +}): app.bsky.ageassurance.getState.$OutputBody | undefined { + return qc.getQueryData( 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(qk, res) + qc.setQueryData(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( + qc.setQueryData( 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( + qc.setQueryData( 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, diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts index 2a0b76446b..65c7f4781c 100644 --- a/src/ageAssurance/state.ts +++ b/src/ageAssurance/state.ts @@ -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 }) { diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts index 5dcabeb268..79fd5f1820 100644 --- a/src/ageAssurance/types.ts +++ b/src/ageAssurance/types.ts @@ -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' diff --git a/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts b/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts index 5ba9e1ba6d..8f88398ce6 100644 --- a/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts +++ b/src/ageAssurance/useComputeAgeAssuranceRegionAccess.ts @@ -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' diff --git a/src/ageAssurance/util.test.ts b/src/ageAssurance/util.test.ts index 1da131b920..561d808f85 100644 --- a/src/ageAssurance/util.test.ts +++ b/src/ageAssurance/util.test.ts @@ -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) }) }) diff --git a/src/ageAssurance/util.ts b/src/ageAssurance/util.ts index fcda8056f1..41dc84cccd 100644 --- a/src/ageAssurance/util.ts +++ b/src/ageAssurance/util.ts @@ -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([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 {