From 3246356fa6e4788467072d3ef3f2d9feccfeb36b Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 31 Aug 2026 17:35:57 +0300 Subject: [PATCH] handle age assurance data load failures --- src/ageAssurance/data.tsx | 13 +++++- src/ageAssurance/state.test.ts | 77 ++++++++++++++++++++++++++++++++++ src/ageAssurance/state.ts | 35 ++++++++++++++-- src/ageAssurance/types.ts | 3 +- 4 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 src/ageAssurance/state.test.ts diff --git a/src/ageAssurance/data.tsx b/src/ageAssurance/data.tsx index 369b9eedd7..a89ac04a00 100644 --- a/src/ageAssurance/data.tsx +++ b/src/ageAssurance/data.tsx @@ -347,6 +347,7 @@ export type OtherRequiredData = { birthdate: string | undefined actorDeclaration?: chat.bsky.actor.declaration.Main } +export type OtherRequiredDataStatus = 'pending' | 'error' | 'success' export function createOtherRequiredDataQueryKey({did}: {did: string}) { return ['otherRequiredData', did] } @@ -722,6 +723,11 @@ export type AgeAssuranceServerData = { */ state: app.bsky.ageassurance.defs.State | undefined metadata: AgeAssuranceMetadata | undefined + /** + * Whether the account data needed to compute age assurance is available. + * A successful response without a birthdate is still `success`. + */ + otherRequiredDataStatus: OtherRequiredDataStatus /** * The native on-device age signals for the region the user is currently in, * if they've granted access there. Already resolved from the region-keyed @@ -739,6 +745,7 @@ const AgeAssuranceServerDataContext = createContext({ declaredAge: undefined, birthdate: undefined, }, + otherRequiredDataStatus: 'pending', deviceSignals: undefined, }) export function useAgeAssuranceServerDataContext() { @@ -752,7 +759,8 @@ export function AgeAssuranceServerDataProvider({ const {data: config} = useConfigQuery() const serverState = useServerStateQuery() const {state, metadata} = serverState.data || {} - const {data} = useOtherRequiredDataQuery() + const {data, status} = useOtherRequiredDataQuery() + const otherRequiredDataStatus = data === undefined ? status : 'success' // `select` resolves the cached region-keyed map to the current region. const {data: deviceSignals} = useDeviceSignalsQuery() const ctx = useMemo( @@ -767,9 +775,10 @@ export function AgeAssuranceServerDataProvider({ : undefined, birthdate: data?.birthdate, }, + otherRequiredDataStatus, deviceSignals, }), - [config, state, data, metadata, deviceSignals], + [config, state, data, metadata, otherRequiredDataStatus, deviceSignals], ) return ( diff --git a/src/ageAssurance/state.test.ts b/src/ageAssurance/state.test.ts new file mode 100644 index 0000000000..03f028ea94 --- /dev/null +++ b/src/ageAssurance/state.test.ts @@ -0,0 +1,77 @@ +import {computeAgeAssuranceState} from '#/ageAssurance/state' +import {AgeAssuranceAccess, AgeAssuranceStatus} from '#/ageAssurance/types' + +const geolocation = { + countryCode: undefined, + regionCode: undefined, +} + +describe('computeAgeAssuranceState', () => { + it('waits for required account data before computing access', () => { + expect( + computeAgeAssuranceState({ + hasSession: true, + geolocation, + config: {regions: []}, + otherRequiredDataStatus: 'pending', + }), + ).toEqual({ + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Safe, + isLoading: true, + }) + }) + + it('surfaces required account data failures without computing access', () => { + expect( + computeAgeAssuranceState({ + hasSession: true, + geolocation, + config: {regions: []}, + otherRequiredDataStatus: 'error', + }), + ).toEqual({ + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Safe, + error: 'account-data', + }) + }) + + it('computes access after a successful response without a birthdate', () => { + expect( + computeAgeAssuranceState({ + hasSession: true, + geolocation, + config: {regions: []}, + metadata: {birthdate: undefined}, + otherRequiredDataStatus: 'success', + }), + ).toMatchObject({ + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.None, + }) + }) + + it('preserves authoritative terminal server state without account data', () => { + expect( + computeAgeAssuranceState({ + hasSession: true, + geolocation: {countryCode: 'AA', regionCode: undefined}, + config: { + regions: [ + { + countryCode: 'AA', + minAccessAge: 13, + rules: [], + }, + ], + }, + state: {status: 'blocked', access: 'none'}, + otherRequiredDataStatus: 'error', + }), + ).toMatchObject({ + status: AgeAssuranceStatus.Blocked, + access: AgeAssuranceAccess.None, + }) + }) +}) diff --git a/src/ageAssurance/state.ts b/src/ageAssurance/state.ts index fb799f934e..e1b75a58f9 100644 --- a/src/ageAssurance/state.ts +++ b/src/ageAssurance/state.ts @@ -9,6 +9,7 @@ import { getDeviceSignalsFromCacheForRegion, getOtherRequiredDataFromCache, getServerStateFromCache, + type OtherRequiredDataStatus, useAgeAssuranceServerDataContext, } from '#/ageAssurance/data' import {logger} from '#/ageAssurance/logger' @@ -35,12 +36,13 @@ import {device} from '#/storage' * server state before computing access based on AA config from the server + * geolocation and other data. */ -function computeAgeAssuranceState({ +export function computeAgeAssuranceState({ hasSession, geolocation, config, state, metadata, + otherRequiredDataStatus, deviceSignals, }: { hasSession: boolean @@ -48,6 +50,7 @@ function computeAgeAssuranceState({ config?: app.bsky.ageassurance.defs.Config state?: app.bsky.ageassurance.defs.State metadata?: AgeAssuranceMetadata + otherRequiredDataStatus: OtherRequiredDataStatus deviceSignals?: AgeRange.AgeRangeResponse }) { /** @@ -93,6 +96,22 @@ function computeAgeAssuranceState({ } } + if (otherRequiredDataStatus === 'pending') { + return { + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Safe, + isLoading: true, + } + } + + if (otherRequiredDataStatus === 'error') { + return { + status: AgeAssuranceStatus.Unknown, + access: AgeAssuranceAccess.Safe, + error: 'account-data' as const, + } + } + /* * Otherwise, we need to compute the access based on the latest data. For * accounts with an accurate birthdate, our default fallback rules should @@ -177,6 +196,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { geolocation, state: state.state, metadata, + otherRequiredDataStatus: 'success', deviceSignals, }) @@ -194,7 +214,7 @@ export function unsafeGetAndComputeAgeAssurance({did}: {did: string}) { export function useAgeAssuranceState(): AgeAssuranceState { const {hasSession} = useSession() const geolocation = useGeolocation() - const {config, state, metadata, deviceSignals} = + const {config, state, metadata, otherRequiredDataStatus, deviceSignals} = useAgeAssuranceServerDataContext() return useMemo( @@ -205,9 +225,18 @@ export function useAgeAssuranceState(): AgeAssuranceState { geolocation, state, metadata, + otherRequiredDataStatus, deviceSignals, }), - [hasSession, geolocation, config, state, metadata, deviceSignals], + [ + hasSession, + geolocation, + config, + state, + metadata, + otherRequiredDataStatus, + deviceSignals, + ], ) } diff --git a/src/ageAssurance/types.ts b/src/ageAssurance/types.ts index b9b775396a..b2eba6ac73 100644 --- a/src/ageAssurance/types.ts +++ b/src/ageAssurance/types.ts @@ -43,7 +43,8 @@ export type AgeAssuranceState = { lastInitiatedAt?: string status: AgeAssuranceStatus access: AgeAssuranceAccess - error?: 'config' // maybe other specific cases in the future + isLoading?: boolean + error?: 'config' | 'account-data' } export type AgeAssuranceFlags = {